diff --git a/pyproject.toml b/pyproject.toml index 68ce4c0c..74fdae83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ seedgo = [] dev = [ "pytest", "pytest-cov", + "pytest-timeout", "ruff", "coverage", "pyright", diff --git a/src/aipass/ai_mail/apps/ai_mail.py b/src/aipass/ai_mail/apps/ai_mail.py index 15e949b5..42294ea4 100644 --- a/src/aipass/ai_mail/apps/ai_mail.py +++ b/src/aipass/ai_mail/apps/ai_mail.py @@ -23,7 +23,7 @@ from typing import Any, List # Handle broken pipe gracefully (e.g. output piped to head) # SIGPIPE does not exist on Windows -if hasattr(signal, 'SIGPIPE'): +if hasattr(signal, "SIGPIPE"): signal.signal(signal.SIGPIPE, signal.SIG_DFL) # Dashboard integration (optional, provided by prax) @@ -49,10 +49,11 @@ MODULES_DIR = MODULE_ROOT / "modules" # HELP DISPLAY # ============================================================================= + def print_help(): """Print drone-compliant help output""" parser = argparse.ArgumentParser( - description='AI_MAIL Branch Operations - Email system for branch communication', + description="AI_MAIL Branch Operations - Email system for branch communication", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" COMMANDS: @@ -93,7 +94,7 @@ EXAMPLES: drone @ai_mail close abc123 # Close single email drone @ai_mail close abc123 def456 ghi789 # Close multiple emails drone @ai_mail close all # Close ALL emails - """ + """, ) console.print(parser.format_help()) @@ -102,6 +103,7 @@ EXAMPLES: # INTROSPECTION DISPLAY # ============================================================================= + def print_introspection(): """Display discovered modules only (seedgo pattern)""" console.print() @@ -117,7 +119,7 @@ def print_introspection(): console.print() for module in modules: - module_name = module.__name__.split('.')[-1] + module_name = module.__name__.split(".")[-1] console.print(f" [cyan]•[/cyan] {module_name}") console.print() @@ -129,6 +131,7 @@ def print_introspection(): # MODULE DISCOVERY # ============================================================================= + def discover_modules() -> List[Any]: """ Auto-discover modules from modules/ directory @@ -158,7 +161,7 @@ def discover_modules() -> List[Any]: module = importlib.import_module(module_name) # Check for required interface - if hasattr(module, 'handle_command'): + if hasattr(module, "handle_command"): modules.append(module) logger.info(f" [+] {module_name}") else: @@ -170,10 +173,12 @@ def discover_modules() -> List[Any]: logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules") return modules + # ============================================================================= # COMMAND ROUTING # ============================================================================= + def route_command(command: str, args: List[str], modules: List[Any]) -> bool: """ Route command to appropriate module @@ -200,10 +205,12 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: return False + # ============================================================================= # MAIN # ============================================================================= + def main(): """Main entry point - routes commands to modules""" try: @@ -216,12 +223,12 @@ def main(): return 0 # Show version - if args[0] in ['--version', '-V']: + 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']: + if args[0] in ["--help", "-h", "help"]: print_help() return 0 @@ -242,8 +249,9 @@ def main(): return 1 except Exception as exc: - logger.error('[ai_mail] Unhandled error in main: %s', exc) + logger.error("[ai_mail] Unhandled error in main: %s", exc) return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/src/aipass/ai_mail/apps/handlers/__init__.py b/src/aipass/ai_mail/apps/handlers/__init__.py index 6fc462bc..5a5297ba 100755 --- a/src/aipass/ai_mail/apps/handlers/__init__.py +++ b/src/aipass/ai_mail/apps/handlers/__init__.py @@ -62,8 +62,10 @@ def _guard_branch_access(): # DEBUG: Print what we found import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) @@ -87,9 +89,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -103,7 +105,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/ai_mail/apps/handlers/central_writer.py b/src/aipass/ai_mail/apps/handlers/central_writer.py index 5e87a119..637cb458 100644 --- a/src/aipass/ai_mail/apps/handlers/central_writer.py +++ b/src/aipass/ai_mail/apps/handlers/central_writer.py @@ -24,8 +24,8 @@ import importlib.util # Remove current directory from sys.path temporarily to import stdlib json _saved_path = sys.path.copy() -sys.path = [p for p in sys.path if 'handlers' not in p] -spec = importlib.util.find_spec('json') +sys.path = [p for p in sys.path if "handlers" not in p] +spec = importlib.util.find_spec("json") if spec is None or spec.loader is None: raise ImportError("Failed to find stdlib json module") stdlib_json = importlib.util.module_from_spec(spec) @@ -55,6 +55,7 @@ BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json" # CORE FUNCTIONS # ============================================================================= + def find_all_inbox_files() -> List[Path]: """ Find all inbox.json files in .ai_mail.local directories. @@ -122,7 +123,7 @@ def read_inbox_stats(inbox_path: Path) -> Tuple[int, int]: json.JSONDecodeError: If inbox.json is malformed KeyError: If required fields are missing """ - with open(inbox_path, 'r', encoding='utf-8') as f: + with open(inbox_path, "r", encoding="utf-8") as f: inbox_data = stdlib_json.load(f) unread = inbox_data.get("unread_count", 0) @@ -142,7 +143,7 @@ def get_valid_branch_names() -> set: FileNotFoundError: If AIPASS_REGISTRY.json doesn't exist json.JSONDecodeError: If AIPASS_REGISTRY.json is malformed """ - with open(BRANCH_REGISTRY, 'r', encoding='utf-8') as f: + with open(BRANCH_REGISTRY, "r", encoding="utf-8") as f: registry_data = stdlib_json.load(f) return {branch["name"].upper() for branch in registry_data.get("branches", [])} @@ -181,10 +182,7 @@ def aggregate_branch_stats() -> Dict[str, Dict[str, int]]: unread, total = read_inbox_stats(inbox_path) - branch_stats[branch_name] = { - "unread": unread, - "total": total - } + branch_stats[branch_name] = {"unread": unread, "total": total} except (FileNotFoundError, stdlib_json.JSONDecodeError, KeyError) as e: # Skip branches with missing/malformed inbox files # Continue processing other branches @@ -211,10 +209,7 @@ def calculate_system_totals(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str total_unread = sum(stats["unread"] for stats in branch_stats.values()) total_messages = sum(stats["total"] for stats in branch_stats.values()) - return { - "total_unread": total_unread, - "total_messages": total_messages - } + return {"total_unread": total_unread, "total_messages": total_messages} def build_central_data(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str, Any]: @@ -233,7 +228,7 @@ def build_central_data(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str, Any "service": "ai_mail", "last_updated": datetime.now().date().isoformat(), # Date only - avoids phantom git changes "branch_stats": branch_stats, - "system_totals": system_totals + "system_totals": system_totals, } @@ -251,7 +246,7 @@ def write_central_file(data: Dict[str, Any]) -> None: # Ensure AI_CENTRAL directory exists AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True) - with open(CENTRAL_FILE, 'w', encoding='utf-8') as f: + with open(CENTRAL_FILE, "w", encoding="utf-8") as f: stdlib_json.dump(data, f, indent=2, ensure_ascii=False) @@ -259,6 +254,7 @@ def write_central_file(data: Dict[str, Any]) -> None: # PUBLIC API # ============================================================================= + def update_central() -> Dict[str, Any]: """ Update AI_MAIL.central.json with current branch inbox stats. @@ -314,10 +310,7 @@ if __name__ == "__main__": console = Console() console.print() - console.print(Panel.fit( - "[bold cyan]AI_MAIL Central Writer[/bold cyan]", - border_style="bright_blue" - )) + console.print(Panel.fit("[bold cyan]AI_MAIL Central Writer[/bold cyan]", border_style="bright_blue")) console.print() try: @@ -334,18 +327,14 @@ if __name__ == "__main__": table.add_column("Total", justify="right", style="blue") for branch, data in sorted(stats["branch_stats"].items()): - table.add_row( - branch, - str(data["unread"]), - str(data["total"]) - ) + table.add_row(branch, str(data["unread"]), str(data["total"])) # Add totals row table.add_section() table.add_row( "[bold]SYSTEM TOTALS[/bold]", f"[bold yellow]{stats['system_totals']['total_unread']}[/bold yellow]", - f"[bold blue]{stats['system_totals']['total_messages']}[/bold blue]" + f"[bold blue]{stats['system_totals']['total_messages']}[/bold blue]", ) console.print(table) diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py index 102d74ef..468d0c69 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -55,7 +55,6 @@ SCHEDULER_CONFIG = _REPO_ROOT / ".aipass" / "scheduler_config.json" SHUTDOWN = False - def _notify_telegram(message: str) -> bool: """Send a notification to Patrick's Telegram via the scheduler bot.""" try: @@ -96,7 +95,7 @@ def _read_json(filepath: Path) -> Optional[Dict[str, Any]]: if not filepath.exists(): return None try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: logger.warning("[daemon] Failed to read JSON %s: %s", filepath, e) @@ -107,7 +106,7 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool: """Write data to a JSON file, returning success.""" filepath.parent.mkdir(parents=True, exist_ok=True) try: - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except OSError as e: @@ -125,20 +124,12 @@ def _set_session_name(branch_path: Path, name: str) -> bool: projects_dir = Path("~/.claude/projects").expanduser() / encoded_cwd if not projects_dir.exists(): return False - jsonl_files = sorted( - projects_dir.glob("*.jsonl"), - key=lambda f: f.stat().st_mtime, - reverse=True - ) + jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True) if not jsonl_files: return False latest = jsonl_files[0] session_id = latest.stem - entry = json.dumps({ - "type": "custom-title", - "customTitle": name, - "sessionId": session_id - }) + entry = json.dumps({"type": "custom-title", "customTitle": name, "sessionId": session_id}) try: with open(latest, "a", encoding="utf-8") as f: f.write(entry + "\n") @@ -154,7 +145,7 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]: if not lock_file.exists(): return None try: - with open(lock_file, 'r', encoding='utf-8') as f: + with open(lock_file, "r", encoding="utf-8") as f: data = json.load(f) pid = data.get("pid") if pid is not None: @@ -173,18 +164,13 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]: lock_time = datetime.fromisoformat(ts) age = (datetime.now() - lock_time).total_seconds() if age > 600: - logger.warning( - "Stale lock removed at %s (PID %s dead, age %.0fs)", - lock_file, pid, age - ) + logger.warning("Stale lock removed at %s (PID %s dead, age %.0fs)", lock_file, pid, age) lock_file.unlink(missing_ok=True) return None except (ValueError, TypeError): logger.info("Unparseable lock timestamp at %s", lock_file) # Dead process, remove stale lock - logger.warning( - "Stale lock removed at %s (PID %s no longer running)", lock_file, pid - ) + logger.warning("Stale lock removed at %s (PID %s no longer running)", lock_file, pid) lock_file.unlink(missing_ok=True) return None except (json.JSONDecodeError, OSError): @@ -196,16 +182,12 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]: def _acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]: """Acquire dispatch lock for branch. Atomic creation via O_CREAT|O_EXCL.""" lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" - lock_data = { - "pid": pid, - "timestamp": datetime.now().isoformat(), - "branch": str(branch_path) - } + lock_data = {"pid": pid, "timestamp": datetime.now().isoformat(), "branch": str(branch_path)} try: lock_file.parent.mkdir(parents=True, exist_ok=True) fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) try: - os.write(fd, json.dumps(lock_data, indent=2).encode('utf-8')) + os.write(fd, json.dumps(lock_data, indent=2).encode("utf-8")) finally: os.close(fd) return True, "Lock acquired" @@ -228,7 +210,7 @@ def load_config() -> Dict[str, Any]: "session_rotation_cycles": 12, "cold_start_prompt": "Hi. Check inbox, process new emails, update memories when done.", "wake_prompt": "Wake. Check inbox, process new emails, continue work. Update memories when done.", - "autonomous_branches": [] + "autonomous_branches": [], } config = _read_json(CONFIG_FILE) @@ -346,8 +328,7 @@ def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]: age = (now - msg_time).total_seconds() if age > orphan_threshold_seconds: logger.warning( - "Retrying orphaned dispatch email %s (opened %.0f min ago)", - msg.get("id", "?"), age / 60 + "Retrying orphaned dispatch email %s (opened %.0f min ago)", msg.get("id", "?"), age / 60 ) return msg except (ValueError, TypeError): @@ -357,11 +338,7 @@ def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]: def spawn_agent( - branch_path: Path, - branch_email: str, - message: Dict[str, Any], - config: Dict[str, Any], - state: Dict[str, Any] + branch_path: Path, branch_email: str, message: Dict[str, Any], config: Dict[str, Any], state: Dict[str, Any] ) -> bool: """ Spawn a Claude agent at the target branch via dispatch_monitor wrapper. @@ -389,16 +366,19 @@ def spawn_agent( lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock") # Prompt — no lock cleanup instruction (dispatch_monitor handles it) - prompt = ( - f"Hi. Check inbox for task from {sender} (message ID: {msg_id}). " - f"Execute it. Send confirmation when done." - ) + prompt = f"Hi. Check inbox for task from {sender} (message ID: {msg_id}). Execute it. Send confirmation when done." claude_cmd = [ - "claude", "-c", "-p", prompt, - "--max-turns", str(max_turns), - "--permission-mode", "bypassPermissions", - "--output-format", "json" + "claude", + "-c", + "-p", + prompt, + "--max-turns", + str(max_turns), + "--permission-mode", + "bypassPermissions", + "--output-format", + "json", ] # Build monitor command (dispatch_monitor wraps claude, handles bounce + lock cleanup) @@ -408,9 +388,14 @@ def spawn_agent( STDERR_LOG = str(LOG_DIR / "dispatch_stderr.log") monitor_cmd = [ - sys.executable, str(MONITOR_SCRIPT), - branch_email, lock_file_path, sender, STDERR_LOG, - "--", *claude_cmd + sys.executable, + str(MONITOR_SCRIPT), + branch_email, + lock_file_path, + sender, + STDERR_LOG, + "--", + *claude_cmd, ] spawn_env = os.environ.copy() @@ -432,7 +417,7 @@ def spawn_agent( stderr=subprocess.DEVNULL, start_new_session=True, cwd=str(branch_path), - env=spawn_env + env=spawn_env, ) monitor_pid = process.pid @@ -455,14 +440,15 @@ def spawn_agent( # Desktop notification — show who woke and why notif_title = f"Daemon → {branch_email}" - notif_body = f"Task from {sender}: \"{subject[:80]}\"" if subject else f"Dispatch from {sender}" + notif_body = f'Task from {sender}: "{subject[:80]}"' if subject else f"Dispatch from {sender}" try: from aipass.ai_mail.apps.handlers.notify import send_notification + send_notification(notif_title, notif_body, source=branch_email.lstrip("@")) except Exception: logger.info(f"Desktop notification unavailable for {branch_email}") - logger.info(f"SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject=\"{subject[:60]}\"") + logger.info(f'SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject="{subject[:60]}"') log_dispatch(branch_email, monitor_pid, "spawned") _notify_telegram(f"[Dispatch] {branch_email} woke\nTask from {sender}: {subject[:80]}") return True @@ -482,20 +468,20 @@ def is_protected_branch(branch_email: str) -> bool: def _read_session_type(pid_str: str) -> str: """Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset.""" if sys.platform != "linux": - return 'interactive' + return "interactive" try: - with open(f'/proc/{pid_str}/environ', 'rb') as f: + with open(f"/proc/{pid_str}/environ", "rb") as f: data = f.read() - for entry in data.split(b'\0'): - if entry.startswith(b'AIPASS_SESSION_TYPE='): - return entry.split(b'=', 1)[1].decode('utf-8') + for entry in data.split(b"\0"): + if entry.startswith(b"AIPASS_SESSION_TYPE="): + return entry.split(b"=", 1)[1].decode("utf-8") except (OSError, PermissionError): logger.info("Cannot read session type for PID %s", pid_str) - return 'interactive' + return "interactive" # Session types that should NOT block dispatch (idle/background sessions) -_NON_BLOCKING_SESSION_TYPES = {'telegram', 'dispatched', 'daemon'} +_NON_BLOCKING_SESSION_TYPES = {"telegram", "dispatched", "daemon"} def _is_branch_occupied(branch_path: Path) -> bool: @@ -507,21 +493,18 @@ def _is_branch_occupied(branch_path: Path) -> bool: """ resolved = branch_path.resolve() try: - result = subprocess.run( - ['pgrep', '-x', 'claude'], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["pgrep", "-x", "claude"], capture_output=True, text=True, timeout=5) if result.returncode != 0: return False - for pid_str in result.stdout.strip().split('\n'): + for pid_str in result.stdout.strip().split("\n"): pid_str = pid_str.strip() if not pid_str: continue try: if sys.platform != "linux": continue - cwd = os.readlink(f'/proc/{pid_str}/cwd') + cwd = os.readlink(f"/proc/{pid_str}/cwd") if Path(cwd).resolve() == resolved: session_type = _read_session_type(pid_str) if session_type not in _NON_BLOCKING_SESSION_TYPES: diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py index cbfaa53d..e6516cb1 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -41,15 +41,14 @@ HARD_TIMEOUT = 7200 # 2 hours POLL_INTERVAL = 5 -def _send_bounce(branch_email: str, reason: str, sender: str, - lock_file: str, stderr_log: str) -> bool: +def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool: """Send return-to-sender bounce email via drone.""" subject = f"BOUNCE: Dispatch to {branch_email} failed" # Read last few lines of stderr log for diagnostics stderr_tail = "" try: - with open(stderr_log, 'r', encoding='utf-8') as f: + with open(stderr_log, "r", encoding="utf-8") as f: lines = f.readlines() stderr_tail = "".join(lines[-20:]).strip() except (OSError, FileNotFoundError) as e: @@ -68,8 +67,10 @@ def _send_bounce(branch_email: str, reason: str, sender: str, try: result = subprocess.run( ["drone", "@ai_mail", "send", sender, subject, body], - capture_output=True, text=True, timeout=30, - cwd=str(Path(lock_file).parent.parent) + capture_output=True, + text=True, + timeout=30, + cwd=str(Path(lock_file).parent.parent), ) return result.returncode == 0 except (subprocess.SubprocessError, OSError) as e: @@ -77,14 +78,18 @@ def _send_bounce(branch_email: str, reason: str, sender: str, # Fallback: write bounce to a file if email fails try: bounce_file = Path(lock_file).parent / "last_bounce.json" - with open(bounce_file, 'w', encoding='utf-8') as f: - json.dump({ - "branch": branch_email, - "reason": reason, - "sender": sender, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), - "stderr_tail": stderr_tail - }, f, indent=2) + with open(bounce_file, "w", encoding="utf-8") as f: + json.dump( + { + "branch": branch_email, + "reason": reason, + "sender": sender, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "stderr_tail": stderr_tail, + }, + f, + indent=2, + ) except OSError: logger.info("[monitor] Failed to write bounce file fallback") return False @@ -93,11 +98,10 @@ def _send_bounce(branch_email: str, reason: str, sender: str, def _check_rate_limited(stderr_log: str) -> bool: """Check if stderr indicates API rate limiting or overload.""" try: - with open(stderr_log, 'r', encoding='utf-8') as f: + with open(stderr_log, "r", encoding="utf-8") as f: content = f.read() lower = content.lower() - return ("rate_limit" in lower or "429" in content or - "overloaded" in lower or "529" in content) + return "rate_limit" in lower or "429" in content or "overloaded" in lower or "529" in content except OSError as e: logger.warning("[monitor] _check_rate_limited failed reading %s: %s", stderr_log, e) return False @@ -114,11 +118,7 @@ def _get_jsonl_projects_dir(cwd: str) -> Path: Claude encodes the cwd by replacing path separators and ':' with '-'. Windows path ``C:\\repo\\AIPass`` becomes ``C--repo-AIPass``. """ - encoded = (cwd.replace("\\", "-") - .replace("/", "-") - .replace(":", "-") - .replace("_", "-") - .replace(".", "-")) + encoded = cwd.replace("\\", "-").replace("/", "-").replace(":", "-").replace("_", "-").replace(".", "-") return Path.home() / ".claude" / "projects" / encoded @@ -175,9 +175,9 @@ def _kill_process(process: subprocess.Popen, branch_email: str): logger.warning("[monitor] %s SIGKILL didn't work", branch_email) -def _run_with_startup_check(claude_cmd: list, stdout_log: str, - stderr_fh, cwd: str, spawn_env: dict, - branch_email: str) -> tuple: +def _run_with_startup_check( + claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str +) -> tuple: """ Run claude with startup timeout check. @@ -189,7 +189,7 @@ def _run_with_startup_check(claude_cmd: list, stdout_log: str, # Open stdout fresh for this attempt (truncate — max_turns detection reads whole file) stdout_fh = None try: - stdout_fh = open(stdout_log, 'w', encoding='utf-8') + stdout_fh = open(stdout_log, "w", encoding="utf-8") except OSError as e: logger.warning("[monitor] Failed to open stdout log %s: %s", stdout_log, e) @@ -199,7 +199,7 @@ def _run_with_startup_check(claude_cmd: list, stdout_log: str, stdout=stdout_fh if stdout_fh is not None else subprocess.DEVNULL, stderr=stderr_fh, cwd=cwd, - env=spawn_env + env=spawn_env, ) except Exception as e: logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e) @@ -231,8 +231,9 @@ def _run_with_startup_check(claude_cmd: list, stdout_log: str, if not started and process.poll() is None: # Startup timeout — no JSONL activity after STARTUP_TIMEOUT seconds - logger.warning("[monitor] %s no JSONL activity after %ds — startup timeout (killing)", - branch_email, STARTUP_TIMEOUT) + logger.warning( + "[monitor] %s no JSONL activity after %ds — startup timeout (killing)", branch_email, STARTUP_TIMEOUT + ) _kill_process(process, branch_email) return -3, True @@ -265,7 +266,7 @@ def main(): lock_file = sys.argv[2] sender = sys.argv[3] stderr_log = sys.argv[4] - claude_cmd = sys.argv[sep_idx + 1:] + claude_cmd = sys.argv[sep_idx + 1 :] if not claude_cmd: logger.warning("[monitor] No claude command after --") @@ -278,11 +279,13 @@ def main(): try: stderr_path = Path(stderr_log) if stderr_path.exists() and stderr_path.stat().st_size > 512_000: - rotated = stderr_path.with_suffix('.log.1') + rotated = stderr_path.with_suffix(".log.1") stderr_path.replace(rotated) - stderr_fh = open(stderr_log, 'a', encoding='utf-8') - stderr_fh.write(f"\n--- Monitor for {branch_email} started at " - f"{time.strftime('%Y-%m-%dT%H:%M:%S')} (PID {os.getpid()}) ---\n") + stderr_fh = open(stderr_log, "a", encoding="utf-8") + stderr_fh.write( + f"\n--- Monitor for {branch_email} started at " + f"{time.strftime('%Y-%m-%dT%H:%M:%S')} (PID {os.getpid()}) ---\n" + ) stderr_fh.flush() except OSError as e: logger.warning("[monitor] Failed to open stderr log %s: %s", stderr_log, e) @@ -293,6 +296,7 @@ def main(): spawn_env["AIPASS_SESSION_TYPE"] = "dispatched" # Guarantee venv bin is on PATH so agents can find drone/claude from aipass.ai_mail.apps.handlers.paths import find_repo_root + _repo_root = find_repo_root() venv_bin = str(_repo_root / ".venv" / "bin") if venv_bin not in spawn_env.get("PATH", ""): @@ -324,7 +328,7 @@ def main(): try: stdout_path = Path(stdout_log) if stdout_path.exists() and stdout_path.stat().st_size > 512_000: - rotated = stdout_path.with_suffix('.log.1') + rotated = stdout_path.with_suffix(".log.1") stdout_path.replace(rotated) except OSError as e: logger.warning("[monitor] Failed to rotate stdout log: %s", e) @@ -350,21 +354,14 @@ def main(): mode = "resume" if has_resume else "fresh" if stderr_fh is not None: - stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at " - f"{time.strftime('%H:%M:%S')} ---\n") + stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n") stderr_fh.flush() exit_code, startup_failed = _run_with_startup_check( - cmd, stdout_log, stderr_fh if stderr_fh is not None else subprocess.DEVNULL, - cwd, spawn_env, branch_email + cmd, stdout_log, stderr_fh if stderr_fh is not None else subprocess.DEVNULL, cwd, spawn_env, branch_email ) - attempts.append({ - "attempt": attempt, - "exit_code": exit_code, - "startup_failed": startup_failed, - "mode": mode - }) + attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": startup_failed, "mode": mode}) # Success — done if exit_code == 0: @@ -374,11 +371,14 @@ def main(): # Log failure if startup_failed: - logger.warning("[monitor] %s attempt %d/3: startup timeout (zero output after %ds)", - branch_email, attempt, STARTUP_TIMEOUT) + logger.warning( + "[monitor] %s attempt %d/3: startup timeout (zero output after %ds)", + branch_email, + attempt, + STARTUP_TIMEOUT, + ) else: - logger.warning("[monitor] %s attempt %d/3: exit code %d", - branch_email, attempt, exit_code) + logger.warning("[monitor] %s attempt %d/3: exit code %d", branch_email, attempt, exit_code) # No more retries if attempt >= 3: @@ -386,8 +386,7 @@ def main(): # Rate limit — longer delay before retry if _check_rate_limited(stderr_log): - logger.info("[monitor] %s rate limited — waiting %ds before retry", - branch_email, RATE_LIMIT_DELAY) + logger.info("[monitor] %s rate limited — waiting %ds before retry", branch_email, RATE_LIMIT_DELAY) time.sleep(RATE_LIMIT_DELAY) else: time.sleep(5) # Brief pause between retries @@ -399,7 +398,7 @@ def main(): # Check for max-turns hit (Claude exits 0 but output contains stop_reason) max_turns_hit = False try: - with open(stdout_log, 'r', encoding='utf-8') as f: + with open(stdout_log, "r", encoding="utf-8") as f: stdout_content = f.read() if '"stop_reason":"max_turns"' in stdout_content or '"stop_reason": "max_turns"' in stdout_content: max_turns_hit = True @@ -412,8 +411,7 @@ def main(): try: suffix = " [MAX TURNS HIT]" if max_turns_hit else "" retry_note = f" (took {len(attempts)} attempts)" if len(attempts) > 1 else "" - stderr_fh.write(f"\n--- Agent exited: code={exit_code}, " - f"duration={duration}s{suffix}{retry_note} ---\n") + stderr_fh.write(f"\n--- Agent exited: code={exit_code}, duration={duration}s{suffix}{retry_note} ---\n") stderr_fh.flush() except OSError: logger.info("[monitor] Failed to write agent exit log") @@ -435,7 +433,7 @@ def main(): # Check stderr for specific error categories try: - with open(stderr_log, 'r', encoding='utf-8') as f: + with open(stderr_log, "r", encoding="utf-8") as f: content = f.read() if "rate_limit" in content.lower() or "429" in content: reason = f"API rate limit (all {len(attempts)} attempts failed, {duration}s)" @@ -463,11 +461,9 @@ def main(): # Desktop notification on completion try: from aipass.ai_mail.apps.handlers.notify import send_notification + icon = "dialog-information" if exit_code == 0 else "dialog-warning" - send_notification( - f"@{branch_name} {status}", f"Duration: {duration}s", - source=branch_name, icon=icon - ) + send_notification(f"@{branch_name} {status}", f"Duration: {duration}s", source=branch_name, icon=icon) except Exception: logger.info("[monitor] Desktop notification unavailable") diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/status.py b/src/aipass/ai_mail/apps/handlers/dispatch/status.py index de52affa..1b417d0b 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/status.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/status.py @@ -33,7 +33,7 @@ def load_dispatch_log() -> List[Dict[str, Any]]: return [] try: - with open(DISPATCH_LOG_FILE, 'r', encoding='utf-8') as f: + with open(DISPATCH_LOG_FILE, "r", encoding="utf-8") as f: data = json.load(f) return data.get("dispatches", []) except (json.JSONDecodeError, IOError) as e: @@ -50,12 +50,9 @@ def save_dispatch_log(dispatches: List[Dict[str, Any]]) -> bool: # Keep last 50 dispatches dispatches = dispatches[-50:] - data = { - "last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "dispatches": dispatches - } + data = {"last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "dispatches": dispatches} - with open(DISPATCH_LOG_FILE, 'w', encoding='utf-8') as f: + with open(DISPATCH_LOG_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except IOError as e: @@ -84,7 +81,7 @@ def log_dispatch(branch: str, pid: Optional[int], status: str, error_msg: Option "branch": branch, "pid": pid, "status": status, - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } if error_msg: @@ -102,11 +99,7 @@ def check_pid_status(pid: int) -> str: 'RUNNING', 'COMPLETED', or 'UNKNOWN' """ try: - result = subprocess.run( - ['ps', '-p', str(pid)], - capture_output=True, - timeout=5 - ) + result = subprocess.run(["ps", "-p", str(pid)], capture_output=True, timeout=5) if result.returncode == 0: return "RUNNING" else: diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 46c62eaa..839b001e 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -74,6 +74,7 @@ DEFAULT_MODEL = "sonnet" # ─── Status Step Tracking ─────────────────────────────── + class DispatchStatus: """Collects step-by-step status for a dispatch operation.""" @@ -118,12 +119,13 @@ class DispatchStatus: # ─── Helpers ──────────────────────────────────────────── + def _read_json(filepath: Path) -> Optional[dict]: """Read and parse a JSON file, returning None on failure.""" if not filepath.exists(): return None try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: logger.warning("[wake] Failed to read %s: %s", filepath, e) @@ -136,7 +138,7 @@ def _check_lock(branch_path: Path) -> Optional[dict]: if not lock_file.exists(): return None try: - with open(lock_file, 'r', encoding='utf-8') as f: + with open(lock_file, "r", encoding="utf-8") as f: data = json.load(f) pid = data.get("pid") if pid is not None: @@ -153,6 +155,7 @@ def _check_lock(branch_path: Path) -> Optional[dict]: if ts: try: from datetime import datetime + lock_time = datetime.fromisoformat(ts) age = (datetime.now() - lock_time).total_seconds() if age > 600: @@ -171,15 +174,11 @@ def _check_lock(branch_path: Path) -> Optional[dict]: def _acquire_lock(branch_path: Path, pid: int) -> Tuple[bool, str]: """Acquire dispatch lock for branch. Atomic creation.""" lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" - lock_data = { - "pid": pid, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), - "branch": str(branch_path) - } + lock_data = {"pid": pid, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "branch": str(branch_path)} try: lock_file.parent.mkdir(parents=True, exist_ok=True) fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY) - with os.fdopen(fd, 'w') as f: + with os.fdopen(fd, "w") as f: json.dump(lock_data, f, indent=2) return True, "Lock acquired" except FileExistsError as e: @@ -208,20 +207,12 @@ def _set_session_name(branch_path: Path, name: str) -> bool: projects_dir = Path("~/.claude/projects").expanduser() / encoded_cwd if not projects_dir.exists(): return False - jsonl_files = sorted( - projects_dir.glob("*.jsonl"), - key=lambda f: f.stat().st_mtime, - reverse=True - ) + jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True) if not jsonl_files: return False latest = jsonl_files[0] session_id = latest.stem - entry = json.dumps({ - "type": "custom-title", - "customTitle": name, - "sessionId": session_id - }) + entry = json.dumps({"type": "custom-title", "customTitle": name, "sessionId": session_id}) try: with open(latest, "a", encoding="utf-8") as f: f.write(entry + "\n") @@ -234,40 +225,37 @@ def _set_session_name(branch_path: Path, name: str) -> bool: def _read_session_type(pid_str: str) -> str: """Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset.""" if sys.platform != "linux": - return 'interactive' + return "interactive" try: - with open(f'/proc/{pid_str}/environ', 'rb') as f: + with open(f"/proc/{pid_str}/environ", "rb") as f: data = f.read() - for entry in data.split(b'\0'): - if entry.startswith(b'AIPASS_SESSION_TYPE='): - return entry.split(b'=', 1)[1].decode('utf-8') + for entry in data.split(b"\0"): + if entry.startswith(b"AIPASS_SESSION_TYPE="): + return entry.split(b"=", 1)[1].decode("utf-8") except (OSError, PermissionError): logger.info("[wake] Cannot read session type for PID %s", pid_str) - return 'interactive' + return "interactive" # Session types that should NOT block dispatch (idle/background sessions) -_NON_BLOCKING_SESSION_TYPES = {'telegram', 'dispatched', 'daemon'} +_NON_BLOCKING_SESSION_TYPES = {"telegram", "dispatched", "daemon"} def _is_branch_occupied(branch_path: Path) -> bool: """Check if an interactive Claude session is running in this branch directory.""" resolved = str(branch_path.resolve()) try: - result = subprocess.run( - ['pgrep', '-x', 'claude'], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["pgrep", "-x", "claude"], capture_output=True, text=True, timeout=5) if result.returncode != 0: return False - for pid_str in result.stdout.strip().split('\n'): + for pid_str in result.stdout.strip().split("\n"): pid_str = pid_str.strip() if not pid_str: continue try: if sys.platform != "linux": continue - cwd = os.readlink(f'/proc/{pid_str}/cwd') + cwd = os.readlink(f"/proc/{pid_str}/cwd") if str(Path(cwd).resolve()) == resolved: session_type = _read_session_type(pid_str) if session_type not in _NON_BLOCKING_SESSION_TYPES: @@ -284,13 +272,10 @@ def _clean_zombies() -> int: """Find and report zombie Claude processes. Returns count found.""" count = 0 try: - result = subprocess.run( - ['ps', '-eo', 'pid,stat,comm'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.strip().split('\n'): + result = subprocess.run(["ps", "-eo", "pid,stat,comm"], capture_output=True, text=True, timeout=5) + for line in result.stdout.strip().split("\n"): parts = line.split() - if len(parts) >= 3 and parts[2] == 'claude' and 'Z' in parts[1]: + if len(parts) >= 3 and parts[2] == "claude" and "Z" in parts[1]: count += 1 logger.info("[wake] Found zombie Claude process PID %s", parts[0]) except (subprocess.SubprocessError, OSError): @@ -304,10 +289,10 @@ def _check_pid_alive(pid: int) -> bool: os.kill(pid, 0) # Also verify not zombie via /proc (Linux only) if sys.platform == "linux": - with open(f'/proc/{pid}/status', 'r') as f: + with open(f"/proc/{pid}/status", "r") as f: for line in f: - if line.startswith('State:'): - return 'Z' not in line + if line.startswith("State:"): + return "Z" not in line return True except (ProcessLookupError, FileNotFoundError) as e: logger.warning("[wake] PID %s not found: %s", pid, e) @@ -319,6 +304,7 @@ def _check_pid_alive(pid: int) -> bool: # ─── Branch Resolution ────────────────────────────────── + def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]: """Resolve a branch email to its absolute filesystem path. @@ -344,6 +330,7 @@ def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]: if caller_cwd: try: from aipass.ai_mail.apps.handlers.registry.read import get_caller_project_branches + caller_branches = get_caller_project_branches(caller_cwd) branch_path_str = caller_branches.get(email, "") if branch_path_str: @@ -358,10 +345,15 @@ def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]: # ─── Main Wake Function ───────────────────────────────── -def wake_branch(branch_email: str, custom_message: Optional[str] = None, - fresh: bool = False, auto: bool = False, - sender: str = "@devpulse", - model: Optional[str] = None) -> Tuple[DispatchStatus, bool]: + +def wake_branch( + branch_email: str, + custom_message: Optional[str] = None, + fresh: bool = False, + auto: bool = False, + sender: str = "@devpulse", + model: Optional[str] = None, +) -> Tuple[DispatchStatus, bool]: """ Spawn a Claude agent at the target branch with step-by-step status. @@ -377,7 +369,9 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, Returns: Tuple of (DispatchStatus with all steps, overall success bool) """ - json_handler.log_operation("wake_branch", {"branch": branch_email, "fresh": fresh, "auto": auto, "model": model or DEFAULT_MODEL}) + json_handler.log_operation( + "wake_branch", {"branch": branch_email, "fresh": fresh, "auto": auto, "model": model or DEFAULT_MODEL} + ) status = DispatchStatus() @@ -445,19 +439,32 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, if fresh: claude_cmd = [ - _CLAUDE_BIN, "-p", prompt, - "--model", resolved_model, - "--max-turns", str(max_turns), - "--permission-mode", "bypassPermissions", - "--output-format", "json" + _CLAUDE_BIN, + "-p", + prompt, + "--model", + resolved_model, + "--max-turns", + str(max_turns), + "--permission-mode", + "bypassPermissions", + "--output-format", + "json", ] else: claude_cmd = [ - _CLAUDE_BIN, "-c", "-p", prompt, - "--model", resolved_model, - "--max-turns", str(max_turns), - "--permission-mode", "bypassPermissions", - "--output-format", "json" + _CLAUDE_BIN, + "-c", + "-p", + prompt, + "--model", + resolved_model, + "--max-turns", + str(max_turns), + "--permission-mode", + "bypassPermissions", + "--output-format", + "json", ] # Set session name for /resume picker @@ -472,11 +479,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, stderr_log = str(log_dir / "dispatch_stderr.log") # Build monitor command - monitor_cmd = [ - sys.executable, str(MONITOR_SCRIPT), - email, lock_file_path, sender, stderr_log, - "--", *claude_cmd - ] + monitor_cmd = [sys.executable, str(MONITOR_SCRIPT), email, lock_file_path, sender, stderr_log, "--", *claude_cmd] # Prepare environment spawn_env = os.environ.copy() @@ -502,7 +505,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, stderr=subprocess.DEVNULL, start_new_session=True, cwd=str(branch_path), - env=spawn_env + env=spawn_env, ) monitor_pid = process.pid @@ -539,6 +542,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, notif_body = custom_message[:80] if custom_message else "Manual wake: check inbox" try: from aipass.ai_mail.apps.handlers.notify import send_notification + send_notification(f"@{email.lstrip('@')} waking", notif_body, source=email.lstrip("@")) except Exception: logger.info("[wake] Desktop notification unavailable") @@ -552,7 +556,7 @@ if __name__ == "__main__": args = sys.argv[1:] if not args or args[0] in ("--help", "-h"): - print("Usage: wake.py [--fresh] [--auto] [--sender @branch] [--model sonnet|opus] @branch [\"optional message\"]") + print('Usage: wake.py [--fresh] [--auto] [--sender @branch] [--model sonnet|opus] @branch ["optional message"]') print(" Manually spawn a Claude agent at a branch (daemon not required)") print() print("Flags:") @@ -572,7 +576,7 @@ if __name__ == "__main__": print("Examples:") print(" wake.py @flow # Default: check inbox (resume)") print(" wake.py --fresh @flow # Fresh session, check inbox") - print(" wake.py @vera \"Review NOTEPAD\" # Custom prompt (resume)") + print(' wake.py @vera "Review NOTEPAD" # Custom prompt (resume)') print(" wake.py --fresh --sender @vera @seedgo # Fresh, bounce to @vera") sys.exit(0) @@ -586,13 +590,13 @@ if __name__ == "__main__": idx = args.index("--sender") if idx + 1 < len(args): use_sender = args[idx + 1] - args = args[:idx] + args[idx + 2:] + args = args[:idx] + args[idx + 2 :] if "--model" in args: idx = args.index("--model") if idx + 1 < len(args): use_model = args[idx + 1] - args = args[:idx] + args[idx + 2:] + args = args[:idx] + args[idx + 2 :] args = [a for a in args if a not in ("--fresh", "--auto")] @@ -604,8 +608,7 @@ if __name__ == "__main__": message = args[1] if len(args) > 1 else None dispatch_status, success = wake_branch( - branch, message, fresh=use_fresh, auto=use_auto, sender=use_sender, - model=use_model + branch, message, fresh=use_fresh, auto=use_auto, sender=use_sender, model=use_model ) print(dispatch_status.format()) sys.exit(0 if success else 1) diff --git a/src/aipass/ai_mail/apps/handlers/email/contacts.py b/src/aipass/ai_mail/apps/handlers/email/contacts.py index aa81e8f7..8f40286a 100644 --- a/src/aipass/ai_mail/apps/handlers/email/contacts.py +++ b/src/aipass/ai_mail/apps/handlers/email/contacts.py @@ -28,6 +28,7 @@ CONTACTS_FILE = find_repo_root() / "src/aipass/ai_mail/.ai_mail.local/contacts.j # INTERNAL HELPERS # ============================================= + def _load_contacts() -> Dict: """Load contacts.json from disk with fallback to empty structure. @@ -38,7 +39,8 @@ def _load_contacts() -> Dict: return {"contacts": {}} try: import json - with open(CONTACTS_FILE, 'r', encoding='utf-8') as f: + + with open(CONTACTS_FILE, "r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict) or "contacts" not in data: return {"contacts": {}} @@ -59,8 +61,9 @@ def _save_contacts(data: Dict) -> bool: """ try: import json + CONTACTS_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(CONTACTS_FILE, 'w', encoding='utf-8') as f: + with open(CONTACTS_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -72,6 +75,7 @@ def _save_contacts(data: Dict) -> bool: # PUBLIC API # ============================================= + def get_contact(branch_name: str) -> Optional[Dict]: """Look up a contact by branch name. diff --git a/src/aipass/ai_mail/apps/handlers/email/create.py b/src/aipass/ai_mail/apps/handlers/email/create.py index 51fdf5ab..04c5dc84 100644 --- a/src/aipass/ai_mail/apps/handlers/email/create.py +++ b/src/aipass/ai_mail/apps/handlers/email/create.py @@ -30,11 +30,19 @@ def _get_append_footer(): global _append_footer if _append_footer is None: from aipass.ai_mail.apps.handlers.email.footer import append_footer + _append_footer = append_footer return _append_footer -def create_email_file(to_branch: str, subject: str, message: str, user_info: Dict, reply_to: str | None = None, dispatched_to: str | None = None) -> Path: +def create_email_file( + to_branch: str, + subject: str, + message: str, + user_info: Dict, + reply_to: str | None = None, + dispatched_to: str | None = None, +) -> Path: """ Create email file and save to sent folder. @@ -68,7 +76,7 @@ def create_email_file(to_branch: str, subject: str, message: str, user_info: Dic "subject": subject, "message": message_with_footer, "timestamp": timestamp_str, - "status": "sent" + "status": "sent", } # Add reply_to if specified (for redirecting replies to different branch) @@ -80,7 +88,7 @@ def create_email_file(to_branch: str, subject: str, message: str, user_info: Dic email_data["dispatched_to"] = dispatched_to # Create filename (safe, no special chars) - safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject) + safe_subject = "".join(c if c.isalnum() or c in (" ", "-", "_") else "_" for c in subject) safe_subject = safe_subject[:50].strip() # Limit length filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{safe_subject}.json" @@ -90,7 +98,7 @@ def create_email_file(to_branch: str, subject: str, message: str, user_info: Dic sent_folder.mkdir(parents=True, exist_ok=True) email_file = sent_folder / filename - with open(email_file, 'w', encoding='utf-8') as f: + with open(email_file, "w", encoding="utf-8") as f: json.dump(email_data, f, indent=2) # Trigger auto-purge if sent folder exceeds threshold @@ -107,6 +115,7 @@ def _trigger_sent_purge(mailbox_path: Path) -> None: """ try: from aipass.ai_mail.apps.handlers.email.purge import purge_sent_folder + purge_sent_folder(mailbox_path) except Exception as e: logger.warning("[create] _trigger_sent_purge() failed: %s", e) @@ -126,7 +135,7 @@ def load_email_file(email_file: Path) -> Optional[Dict]: return None try: - with open(email_file, 'r', encoding='utf-8') as f: + with open(email_file, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.warning("[create] load_email_file(%s) failed: %s", email_file, e) @@ -135,10 +144,11 @@ def load_email_file(email_file: Path) -> Optional[Dict]: if __name__ == "__main__": from rich.console import Console + c = Console() - c.print("\n" + "="*70) + c.print("\n" + "=" * 70) c.print("EMAIL FILE CREATION HANDLER") - c.print("="*70) + c.print("=" * 70) c.print("\nPURPOSE:") c.print(" Creates and stores email files in sent folders") c.print() @@ -155,4 +165,4 @@ if __name__ == "__main__": c.print(" from ai_mail.apps.handlers.email.create import create_email_file") c.print(" from ai_mail.apps.handlers.email.create import load_email_file") c.print() - c.print("="*70 + "\n") + c.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py b/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py index 0e1e1e78..8b969977 100644 --- a/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py +++ b/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py @@ -33,6 +33,7 @@ def _get_write_section(): global _write_section if _write_section is None: from aipass.prax.apps.modules.dashboard import write_section + _write_section = write_section return _write_section @@ -136,7 +137,7 @@ def _calculate_section_data(inbox_data: Dict) -> Dict: "opened": opened_count, "total": total_count, "oldest_unread_age": oldest_unread_age, - "last_dispatch_received": last_dispatch_iso + "last_dispatch_received": last_dispatch_iso, } @@ -176,10 +177,10 @@ def push_dashboard_update(branch_path: Path) -> bool: "opened": 0, "total": 0, "oldest_unread_age": None, - "last_dispatch_received": None + "last_dispatch_received": None, } else: - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) section_data = _calculate_section_data(inbox_data) diff --git a/src/aipass/ai_mail/apps/handlers/email/delivery.py b/src/aipass/ai_mail/apps/handlers/email/delivery.py index 45867053..f67ddeeb 100644 --- a/src/aipass/ai_mail/apps/handlers/email/delivery.py +++ b/src/aipass/ai_mail/apps/handlers/email/delivery.py @@ -38,6 +38,7 @@ def _load_caller_project_branches(caller_cwd: str) -> Dict[str, str]: implementation used by both delivery and wake for cross-project resolution. """ from aipass.ai_mail.apps.handlers.registry.read import get_caller_project_branches + return get_caller_project_branches(caller_cwd) @@ -53,6 +54,7 @@ def _auto_register_contact(email: str, branch_path: Path, inbox_file: Path) -> N """ try: from aipass.ai_mail.apps.handlers.email.contacts import register_contact + name_key = email.lstrip("@").lower() register_contact(name_key, "AIPass", str(inbox_file)) except Exception as e: @@ -75,6 +77,7 @@ def _auto_register_sender(branch_name: str, caller_cwd: str) -> None: inbox_file = path / ".ai_mail.local" / "inbox.json" if inbox_file.exists(): from aipass.ai_mail.apps.handlers.email.contacts import register_contact + name_key = branch_name.lstrip("@").lower() register_contact(name_key, "", str(inbox_file)) return @@ -87,11 +90,11 @@ def _get_inbox_lock(): global _INBOX_LOCK if _INBOX_LOCK is None: from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock + _INBOX_LOCK = inbox_lock return _INBOX_LOCK - def _migrate_inbox_format(inbox_data: Dict, inbox_file: Path) -> Dict: """ Auto-migrate old inbox format to v2 schema. @@ -137,7 +140,8 @@ def _migrate_inbox_format(inbox_data: Dict, inbox_file: Path) -> Dict: if "unread_count" not in inbox_data: inbox_data["unread_count"] = sum( - 1 for msg in inbox_data["messages"] + 1 + for msg in inbox_data["messages"] if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False)) ) migrated = True @@ -145,7 +149,7 @@ def _migrate_inbox_format(inbox_data: Dict, inbox_file: Path) -> Dict: # Persist migration to disk if migrated: try: - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) except Exception as e: logger.warning("[delivery] _migrate_inbox_format() failed to persist migration for %s: %s", inbox_file, e) @@ -170,7 +174,7 @@ def _is_private_branch_email(email: str) -> bool: if not registry_path.exists(): return False try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) for branch in registry.get("branches", []): if branch.get("email", "") == email: @@ -202,9 +206,7 @@ def _resolve_reply_path() -> str: def deliver_email_to_branch( - to_branch: str, - email_data: Dict, - on_delivered: Optional[Callable] = None + to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None ) -> Tuple[bool, str]: """ Deliver email to target branch's .ai_mail.local/inbox.json file. @@ -230,18 +232,18 @@ def deliver_email_to_branch( json_handler.log_operation("deliver_email", {"to": to_branch, "subject": email_data.get("subject", "")}) # Handle path input from DRONE's @ resolution - if to_branch.startswith('/'): + if to_branch.startswith("/"): branches_list = get_all_branches() path_to_email = {b["path"]: b["email"] for b in branches_list} if to_branch in path_to_email: to_branch = path_to_email[to_branch] else: # Stage 2: Longest-path-first prefix matching against registry - sorted_branches = sorted(branches_list, key=lambda b: len(b['path']), reverse=True) + sorted_branches = sorted(branches_list, key=lambda b: len(b["path"]), reverse=True) matched = False for b in sorted_branches: - if to_branch.startswith(b['path'] + '/') or to_branch == b['path']: - to_branch = b['email'] + if to_branch.startswith(b["path"] + "/") or to_branch == b["path"]: + to_branch = b["email"] matched = True break if not matched: @@ -264,7 +266,7 @@ def deliver_email_to_branch( # Private branch inbound blocking: reject delivery to private branches # Self-send is allowed (private branch can send to itself) - sender_email = email_data.get('from', '') + sender_email = email_data.get("from", "") if _is_private_branch_email(to_branch) and sender_email != to_branch: return False, f"Cannot deliver to private branch: {to_branch}" @@ -285,13 +287,8 @@ def deliver_email_to_branch( mailbox_dir = inbox_file.parent mailbox_dir.mkdir(parents=True, exist_ok=True) (mailbox_dir / "sent").mkdir(exist_ok=True) - inbox_data_init = { - "mailbox": "inbox", - "total_messages": 0, - "unread_count": 0, - "messages": [] - } - with open(inbox_file, 'w', encoding='utf-8') as f: + inbox_data_init = {"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []} + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data_init, f, indent=2) except Exception as e: logger.warning("[delivery] auto-provision inbox failed for %s: %s", to_branch, e) @@ -301,7 +298,7 @@ def deliver_email_to_branch( try: with _get_inbox_lock()(inbox_file): try: - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) except Exception as e: logger.warning("[delivery] failed to read inbox %s: %s", inbox_file, e) @@ -313,21 +310,21 @@ def deliver_email_to_branch( # Create message object (v2 schema: status instead of read) message = { "id": str(uuid.uuid4())[:8], - "timestamp": email_data['timestamp'], - "from": email_data['from'], - "from_name": email_data['from_name'], - "subject": email_data['subject'], - "message": email_data['message'], + "timestamp": email_data["timestamp"], + "from": email_data["from"], + "from_name": email_data["from_name"], + "subject": email_data["subject"], + "message": email_data["message"], "status": "new", - "auto_execute": email_data.get('auto_execute', False), - "priority": email_data.get('priority', 'normal') + "auto_execute": email_data.get("auto_execute", False), + "priority": email_data.get("priority", "normal"), } - if email_data.get('reply_to'): - message["reply_to"] = email_data['reply_to'] + if email_data.get("reply_to"): + message["reply_to"] = email_data["reply_to"] - if email_data.get('dispatched_to'): - message["dispatched_to"] = email_data['dispatched_to'] + if email_data.get("dispatched_to"): + message["dispatched_to"] = email_data["dispatched_to"] # Store reply_path for cross-project replies. # Pass-through from email_data, or auto-detect from AIPASS_CALLER_CWD. @@ -340,14 +337,15 @@ def deliver_email_to_branch( inbox_data["total_messages"] = len(inbox_data["messages"]) messages = inbox_data["messages"] new_count = sum( - 1 for msg in messages + 1 + for msg in messages if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False)) ) opened_count = sum(1 for msg in messages if msg.get("status") == "opened") inbox_data["unread_count"] = new_count try: - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) except Exception as e: logger.warning("[delivery] failed to write inbox %s: %s", inbox_file, e) @@ -367,7 +365,7 @@ def deliver_email_to_branch( _auto_register_sender(caller_branch, caller_cwd) # Send desktop notification for new email - _send_desktop_notification(email_data['from'], to_branch, email_data['subject'], email_data.get('message', '')) + _send_desktop_notification(email_data["from"], to_branch, email_data["subject"], email_data.get("message", "")) # Invoke post-delivery callback (dashboard updates, central sync, etc.) if on_delivered: @@ -406,9 +404,7 @@ def _send_desktop_notification(sender: str, recipient: str, subject: str, messag cutoff = now - _NOTIFICATION_WINDOW if recipient in _NOTIFICATION_TIMESTAMPS: - _NOTIFICATION_TIMESTAMPS[recipient] = [ - t for t in _NOTIFICATION_TIMESTAMPS[recipient] if t > cutoff - ] + _NOTIFICATION_TIMESTAMPS[recipient] = [t for t in _NOTIFICATION_TIMESTAMPS[recipient] if t > cutoff] else: _NOTIFICATION_TIMESTAMPS[recipient] = [] @@ -416,17 +412,18 @@ def _send_desktop_notification(sender: str, recipient: str, subject: str, messag return # Build informative notification - sender_name = sender.replace('@', '').upper() - recipient_name = recipient.replace('@', '').upper() + sender_name = sender.replace("@", "").upper() + recipient_name = recipient.replace("@", "").upper() title = f"{sender_name} -> {recipient_name}" body = subject if message: - preview = message[:100].replace('\n', ' ').strip() + preview = message[:100].replace("\n", " ").strip() if preview: body = f"{subject}\n{preview}" try: from aipass.ai_mail.apps.handlers.notify import send_notification + send_notification(title, body, source=sender_name) _NOTIFICATION_TIMESTAMPS[recipient].append(now) except Exception as e: @@ -436,10 +433,11 @@ def _send_desktop_notification(sender: str, recipient: str, subject: str, messag if __name__ == "__main__": from rich.console import Console + console = Console() - console.print("\n" + "="*70) + console.print("\n" + "=" * 70) console.print("EMAIL DELIVERY HANDLER") - console.print("="*70) + console.print("=" * 70) console.print("\nPURPOSE:") console.print(" Delivers emails to branch inboxes") console.print() @@ -457,4 +455,4 @@ if __name__ == "__main__": console.print(" from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch") console.print(" from aipass.ai_mail.apps.handlers.registry.read import get_all_branches") console.print() - console.print("="*70 + "\n") + console.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/footer.py b/src/aipass/ai_mail/apps/handlers/email/footer.py index 8bd61bef..c5ef8dab 100644 --- a/src/aipass/ai_mail/apps/handlers/email/footer.py +++ b/src/aipass/ai_mail/apps/handlers/email/footer.py @@ -54,9 +54,9 @@ def append_footer(message: str) -> str: if __name__ == "__main__": - print("\n" + "="*70) + print("\n" + "=" * 70) print("EMAIL FOOTER HANDLER") - print("="*70) + print("=" * 70) print("\nPURPOSE:") print(" Generates standard footer for all outgoing emails") print() @@ -77,4 +77,4 @@ if __name__ == "__main__": print(" from ai_mail.apps.handlers.email.footer import append_footer") print(" from ai_mail.apps.handlers.email.footer import get_footer") print() - print("="*70 + "\n") + print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/format.py b/src/aipass/ai_mail/apps/handlers/email/format.py index ab90cb6d..c37d13a8 100644 --- a/src/aipass/ai_mail/apps/handlers/email/format.py +++ b/src/aipass/ai_mail/apps/handlers/email/format.py @@ -98,16 +98,13 @@ def format_email_header(email_data: Dict) -> str: Formatted header string """ json_handler.log_operation("format_email_header", {"subject": email_data.get("subject", "")}) - sender = format_sender_display( - email_data.get('from_name', 'Unknown'), - email_data.get('from', 'unknown') - ) + sender = format_sender_display(email_data.get("from_name", "Unknown"), email_data.get("from", "unknown")) lines = [ "=" * 70, f"From: {sender}", f"Date: {email_data.get('timestamp', 'Unknown')}", f"Subject: {email_data.get('subject', 'No Subject')}", - "=" * 70 + "=" * 70, ] return "\n".join(lines) @@ -127,24 +124,25 @@ def format_email_list_item(index: int, email_data: Dict, show_unread: bool = Tru lines = [] # Unread marker + ID for copy-paste - msg_id = email_data.get('id', '????????') + msg_id = email_data.get("id", "????????") if show_unread: # v2: check status first, fall back to read for backward compat status = email_data.get("status") is_new = status == "new" if status else not email_data.get("read", False) unread_marker = "📨" if is_new else "📬" - sender = format_sender_display( - email_data.get('from_name', 'Unknown'), - email_data.get('from', 'unknown') + sender = format_sender_display(email_data.get("from_name", "Unknown"), email_data.get("from", "unknown")) + lines.append( + f"\n{index}. {unread_marker} \\[{msg_id}] From: {sender} @ {email_data.get('timestamp', 'Unknown')}" ) - lines.append(f"\n{index}. {unread_marker} \\[{msg_id}] From: {sender} @ {email_data.get('timestamp', 'Unknown')}") else: - lines.append(f"\n{index}. \\[{msg_id}] To: {email_data.get('to', 'Unknown')} @ {email_data.get('timestamp', 'Unknown')}") + lines.append( + f"\n{index}. \\[{msg_id}] To: {email_data.get('to', 'Unknown')} @ {email_data.get('timestamp', 'Unknown')}" + ) lines.append(f" Subject: {email_data.get('subject', 'No Subject')}") # Preview - message = email_data.get('message', '') + message = email_data.get("message", "") preview = format_email_preview(message, 100) lines.append(f" {preview}") @@ -153,9 +151,10 @@ def format_email_list_item(index: int, email_data: Dict, show_unread: bool = Tru if __name__ == "__main__": from aipass.cli.apps.modules import console - console.print("\n" + "="*70) + + console.print("\n" + "=" * 70) console.print("EMAIL FORMATTING HANDLER") - console.print("="*70) + console.print("=" * 70) console.print("\nPURPOSE:") console.print(" Email display formatting and text utilities") console.print() @@ -174,4 +173,4 @@ if __name__ == "__main__": console.print(" from ai_mail.apps.handlers.email.format import format_email_preview") console.print(" from ai_mail.apps.handlers.email.format import format_email_header") console.print() - console.print("="*70 + "\n") + console.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/header.py b/src/aipass/ai_mail/apps/handlers/email/header.py index b8c4f827..2aff3842 100644 --- a/src/aipass/ai_mail/apps/handlers/email/header.py +++ b/src/aipass/ai_mail/apps/handlers/email/header.py @@ -74,9 +74,9 @@ def prepend_dispatch_header(message: str, no_memory_save: bool = False) -> str: if __name__ == "__main__": - print("\n" + "="*70) + print("\n" + "=" * 70) print("EMAIL HEADER HANDLER") - print("="*70) + print("=" * 70) print("\nPURPOSE:") print(" Generates header for dispatch emails (critical reminders)") print() @@ -96,4 +96,4 @@ if __name__ == "__main__": print("USAGE FROM MODULES:") print(" from ai_mail.apps.handlers.email.header import prepend_dispatch_header") print() - print("="*70 + "\n") + print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/identity.py b/src/aipass/ai_mail/apps/handlers/email/identity.py index ffeefea5..683d850a 100644 --- a/src/aipass/ai_mail/apps/handlers/email/identity.py +++ b/src/aipass/ai_mail/apps/handlers/email/identity.py @@ -26,6 +26,7 @@ from aipass.ai_mail.apps.handlers.json import json_handler # PUBLIC API # ============================================= + def create_identity(branch_path: Path, branch_name: str, project: str) -> bool: """Write identity.json to branch_path/.ai_mail.local/. @@ -48,7 +49,7 @@ def create_identity(branch_path: Path, branch_name: str, project: str) -> bool: "inbox": str(inbox_path), } identity_file = mail_dir / "identity.json" - with open(identity_file, 'w', encoding='utf-8') as f: + with open(identity_file, "w", encoding="utf-8") as f: json.dump(identity_data, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -71,7 +72,7 @@ def read_identity(branch_path: Path) -> Optional[Dict]: if not identity_file.exists(): return None try: - with open(identity_file, 'r', encoding='utf-8') as f: + with open(identity_file, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.warning("[identity] read_identity(%s) failed: %s", branch_path, e) diff --git a/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py b/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py index baf707e0..4c436f51 100644 --- a/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py +++ b/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py @@ -33,6 +33,7 @@ def _get_inbox_lock(): global _inbox_lock if _inbox_lock is None: from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock + _inbox_lock = inbox_lock return _inbox_lock @@ -40,12 +41,14 @@ def _get_inbox_lock(): def _get_push_dashboard_update() -> Any: """Lazy import push_dashboard_update from dashboard_sync.""" from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update + return push_dashboard_update def _get_update_central() -> Any: """Lazy import update_central.""" from aipass.ai_mail.apps.handlers.central_writer import update_central + return update_central @@ -66,7 +69,7 @@ def _save_to_deleted_folder(mailbox_path: Path, message: Dict) -> Path: # Generate filename (same pattern as sent/) timestamp = datetime.now() subject = message.get("subject", "No Subject") - safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject) + safe_subject = "".join(c if c.isalnum() or c in (" ", "-", "_") else "_" for c in subject) safe_subject = safe_subject[:50].strip() filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{safe_subject}.json" @@ -74,7 +77,7 @@ def _save_to_deleted_folder(mailbox_path: Path, message: Dict) -> Path: message["archived_at"] = timestamp.isoformat() email_file = deleted_folder / filename - with open(email_file, 'w', encoding='utf-8') as f: + with open(email_file, "w", encoding="utf-8") as f: json.dump(message, f, indent=2, ensure_ascii=False) return email_file @@ -96,7 +99,7 @@ def _migrate_deleted_json_if_exists(mailbox_path: Path) -> int: return 0 try: - with open(deleted_json, 'r', encoding='utf-8') as f: + with open(deleted_json, "r", encoding="utf-8") as f: data = json.load(f) messages = data.get("messages", []) @@ -153,7 +156,7 @@ def mark_read_and_archive(branch_path: Path, message_id: str) -> Tuple[bool, str try: with _get_inbox_lock()(inbox_file): # Load inbox - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) # Find message by ID @@ -181,14 +184,15 @@ def mark_read_and_archive(branch_path: Path, message_id: str) -> Tuple[bool, str inbox_data["total_messages"] = len(messages) # v2 status counts new_count = sum( - 1 for m in messages + 1 + for m in messages if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) ) opened_count = sum(1 for m in messages if m.get("status") == "opened") inbox_data["unread_count"] = new_count # Save inbox - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) # Save to deleted/ folder (new pattern) @@ -229,7 +233,7 @@ def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]: try: with _get_inbox_lock()(inbox_file): # Load inbox - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) messages = inbox_data.get("messages", []) @@ -249,7 +253,7 @@ def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]: inbox_data["unread_count"] = 0 # Save inbox - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) # Update dashboard (outside lock - not inbox.json) @@ -287,6 +291,7 @@ def _trigger_deleted_purge(branch_path: Path) -> None: """ try: from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder + mailbox_path = branch_path / ".ai_mail.local" purge_deleted_folder(mailbox_path) except Exception as e: @@ -297,6 +302,7 @@ def _trigger_deleted_purge(branch_path: Path) -> None: # V2 SCHEMA FUNCTIONS (status: new/opened/closed) # ============================================================================= + def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optional[Dict]]: """ Mark an email as opened (viewed). Does NOT archive. @@ -317,7 +323,7 @@ def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optio try: with _get_inbox_lock()(inbox_file): - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) messages = inbox_data.get("messages", []) @@ -338,13 +344,14 @@ def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optio # Recalculate status counts (v2 schema) new_count = sum( - 1 for m in messages + 1 + for m in messages if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) ) opened_count = sum(1 for m in messages if m.get("status") == "opened") inbox_data["unread_count"] = new_count - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) # Update dashboard (outside lock - not inbox.json) @@ -382,7 +389,7 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops try: with _get_inbox_lock()(inbox_file): - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) messages = inbox_data.get("messages", []) @@ -410,13 +417,14 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops inbox_data["total_messages"] = len(messages) # v2 status counts new_count = sum( - 1 for m in messages + 1 + for m in messages if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) ) opened_count = sum(1 for m in messages if m.get("status") == "opened") inbox_data["unread_count"] = new_count - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) # Save to deleted/ folder (inside lock to ensure consistency) @@ -438,10 +446,11 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops if __name__ == "__main__": from rich.console import Console + c = Console() - c.print("\n" + "="*70) + c.print("\n" + "=" * 70) c.print("INBOX CLEANUP HANDLER") - c.print("="*70) + c.print("=" * 70) c.print("\nPURPOSE:") c.print(" Marks emails as read and moves them to deleted/ folder") c.print() @@ -462,4 +471,4 @@ if __name__ == "__main__": c.print(" - Automatically migrates deleted.json to deleted/ on first access") c.print(" - Old deleted.json archived to .archive/") c.print() - c.print("="*70 + "\n") + c.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py b/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py index 33a4bf6a..531631bd 100644 --- a/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py +++ b/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py @@ -33,7 +33,6 @@ else: import fcntl - @contextmanager def inbox_lock(inbox_file: Path): """ @@ -58,7 +57,7 @@ def inbox_lock(inbox_file: Path): try: # Create/open lock file - lock_fd = open(lock_file, 'w', encoding='utf-8') + lock_fd = open(lock_file, "w", encoding="utf-8") # Acquire exclusive lock (blocking - waits for other processes) if sys.platform == "win32": diff --git a/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py b/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py index e36a4485..c657a097 100644 --- a/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py +++ b/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py @@ -29,11 +29,11 @@ def _get_inbox_lock(): global _inbox_lock if _inbox_lock is None: from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock + _inbox_lock = inbox_lock return _inbox_lock - def load_inbox(inbox_file: Path) -> Dict: """ Load inbox data from inbox.json file. @@ -52,7 +52,7 @@ def load_inbox(inbox_file: Path) -> Dict: return {"messages": []} try: - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) # Validate structure @@ -80,7 +80,8 @@ def load_inbox(inbox_file: Path) -> Dict: if "unread_count" not in inbox_data: inbox_data["unread_count"] = sum( - 1 for msg in inbox_data["messages"] + 1 + for msg in inbox_data["messages"] if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False)) ) migrated = True @@ -89,7 +90,7 @@ def load_inbox(inbox_file: Path) -> Dict: if migrated: try: with _get_inbox_lock()(inbox_file): - with open(inbox_file, 'w', encoding='utf-8') as f: + with open(inbox_file, "w", encoding="utf-8") as f: json.dump(inbox_data, f, indent=2, ensure_ascii=False) except Exception as e: logger.warning("[inbox] Migration persist failed for %s: %s", inbox_file, e) @@ -104,9 +105,10 @@ def load_inbox(inbox_file: Path) -> Dict: if __name__ == "__main__": from aipass.cli.apps.modules import console - console.print("\n" + "="*70) + + console.print("\n" + "=" * 70) console.print("INBOX OPERATIONS HANDLER") - console.print("="*70) + console.print("=" * 70) console.print("\nPURPOSE:") console.print(" Handles inbox file I/O operations") console.print() @@ -123,4 +125,4 @@ if __name__ == "__main__": console.print(" from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox") console.print(" inbox_data = load_inbox(Path('/path/to/inbox.json'))") console.print() - console.print("="*70 + "\n") + console.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/purge.py b/src/aipass/ai_mail/apps/handlers/email/purge.py index 8c320f58..f6319a4e 100644 --- a/src/aipass/ai_mail/apps/handlers/email/purge.py +++ b/src/aipass/ai_mail/apps/handlers/email/purge.py @@ -36,7 +36,9 @@ MAX_EMAILS = 10 # These are resolved relative to repo root if available; vectorization is best-effort _REPO_ROOT = find_repo_root() MEMORY_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3" -CHROMA_SUBPROCESS_SCRIPT = _REPO_ROOT / "src" / "aipass" / "memory" / "apps" / "handlers" / "storage" / "chroma_subprocess.py" +CHROMA_SUBPROCESS_SCRIPT = ( + _REPO_ROOT / "src" / "aipass" / "memory" / "apps" / "handlers" / "storage" / "chroma_subprocess.py" +) def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]: @@ -57,11 +59,7 @@ def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]: return {"success": True, "purged_count": 0, "message": "Sent folder empty"} # Get all email files sorted by modification time (newest first) - email_files = sorted( - sent_folder.glob("*.json"), - key=lambda f: f.stat().st_mtime, - reverse=True - ) + email_files = sorted(sent_folder.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True) total_count = len(email_files) if total_count <= MAX_EMAILS: @@ -91,11 +89,7 @@ def purge_deleted_folder(mailbox_path: Path) -> Dict[str, Any]: return {"success": True, "purged_count": 0, "message": "Deleted folder empty"} # Get all email files sorted by modification time (newest first) - email_files = sorted( - deleted_folder.glob("*.json"), - key=lambda f: f.stat().st_mtime, - reverse=True - ) + email_files = sorted(deleted_folder.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True) total_count = len(email_files) if total_count <= MAX_EMAILS: @@ -131,7 +125,7 @@ def _purge_email_files(mailbox_path: Path, files: List[Path], folder_type: str) load_errors = [] for file_path in files: try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: email_data = json.load(f) email_data["_source_file"] = str(file_path.name) emails_data.append(email_data) @@ -169,7 +163,7 @@ def _purge_email_files(mailbox_path: Path, files: List[Path], folder_type: str) "purged_count": deleted_count, "vectorized": True, "load_errors": load_errors if load_errors else None, - "delete_errors": delete_errors if delete_errors else None + "delete_errors": delete_errors if delete_errors else None, } @@ -199,22 +193,24 @@ def _vectorize_emails(emails: List[Dict[str, Any]], folder_type: str) -> Dict[st text = f"{subject}\n\n{message}" texts.append(text) - metadatas.append({ - "type": f"email_{folder_type}", - "from": email.get("from", ""), - "to": email.get("to", ""), - "subject": subject, - "timestamp": email.get("timestamp", ""), - "archived_at": datetime.now().isoformat() - }) + metadatas.append( + { + "type": f"email_{folder_type}", + "from": email.get("from", ""), + "to": email.get("to", ""), + "subject": subject, + "timestamp": email.get("timestamp", ""), + "archived_at": datetime.now().isoformat(), + } + ) # Call @memory vectorization via subprocess (handler independence) input_data = { - 'operation': 'vectorize_and_store', - 'branch': 'AI_MAIL', - 'memory_type': f'email_{folder_type}', - 'texts': texts, - 'metadatas': metadatas + "operation": "vectorize_and_store", + "branch": "AI_MAIL", + "memory_type": f"email_{folder_type}", + "texts": texts, + "metadatas": metadatas, } result = subprocess.run( @@ -222,7 +218,7 @@ def _vectorize_emails(emails: List[Dict[str, Any]], folder_type: str) -> Dict[st input=json.dumps(input_data), capture_output=True, text=True, - timeout=120 + timeout=120, ) if result.returncode != 0: @@ -257,16 +253,16 @@ def run_purge(mailbox_path: Path) -> Dict[str, Any]: return { "success": sent_result["success"] and deleted_result["success"], "sent": sent_result, - "deleted": deleted_result + "deleted": deleted_result, } if __name__ == "__main__": from aipass.cli.apps.modules import console - console.print("\n" + "="*70) + console.print("\n" + "=" * 70) console.print("SENT/DELETED AUTO-PURGE HANDLER") - console.print("="*70) + console.print("=" * 70) console.print("\nPURPOSE:") console.print(" Auto-purge sent/deleted folders when they exceed 10 emails") console.print() @@ -289,4 +285,4 @@ if __name__ == "__main__": console.print(" - create.py (after email sent)") console.print(" - inbox_cleanup.py (after email deleted)") console.print() - console.print("="*70 + "\n") + console.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/reply.py b/src/aipass/ai_mail/apps/handlers/email/reply.py index f32b33ca..c9f4bcca 100644 --- a/src/aipass/ai_mail/apps/handlers/email/reply.py +++ b/src/aipass/ai_mail/apps/handlers/email/reply.py @@ -39,7 +39,7 @@ def get_email_by_id(inbox_file: Path, message_id: str) -> Optional[Dict]: return None try: - with open(inbox_file, 'r', encoding='utf-8') as f: + with open(inbox_file, "r", encoding="utf-8") as f: inbox_data = json.load(f) for msg in inbox_data.get("messages", []): @@ -52,11 +52,7 @@ def get_email_by_id(inbox_file: Path, message_id: str) -> Optional[Dict]: return None -def send_reply( - from_branch_path: Path, - original_email: Dict, - reply_message: str -) -> Tuple[bool, str, Optional[str]]: +def send_reply(from_branch_path: Path, original_email: Dict, reply_message: str) -> Tuple[bool, str, Optional[str]]: """ Send a reply to an email's original sender. @@ -71,7 +67,9 @@ def send_reply( Returns: Tuple of (success: bool, message: str, reply_id: str or None) """ - json_handler.log_operation("send_reply", {"from_branch": str(from_branch_path), "reply_to": original_email.get("from", "unknown")}) + json_handler.log_operation( + "send_reply", {"from_branch": str(from_branch_path), "reply_to": original_email.get("from", "unknown")} + ) # Import here to avoid circular imports from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch from aipass.ai_mail.apps.handlers.registry.read import get_all_branches @@ -91,7 +89,7 @@ def send_reply( # Normalize dispatched_to to email format if it's a path # DRONE's preprocess_args converts @branch to paths, so we may receive # a filesystem path instead of "@trigger" - if dispatched_to and not dispatched_to.startswith('@'): + if dispatched_to and not dispatched_to.startswith("@"): # It's a path - look up email in registry dispatch_info = get_branch_info_from_registry(Path(dispatched_to)) if dispatch_info: @@ -121,7 +119,7 @@ def send_reply( "subject": reply_subject, "message": reply_message, "timestamp": timestamp, - "in_reply_to": original_email.get("id") # Link to original message + "in_reply_to": original_email.get("id"), # Link to original message } # Find recipient branch @@ -136,9 +134,7 @@ def send_reply( # Fallback: cross-project delivery via reply_path stored at receive time stored_reply_path = original_email.get("reply_path") if stored_reply_path: - return _deliver_via_reply_path( - stored_reply_path, reply_email_data, from_branch_path, original_email - ) + return _deliver_via_reply_path(stored_reply_path, reply_email_data, from_branch_path, original_email) return False, f"Could not find branch for {reply_destination}", None # Deliver the reply (pass email address, not path) @@ -153,7 +149,7 @@ def send_reply( reply_id = str(uuid.uuid4())[:8] reply_email_data["id"] = reply_id sent_file = sent_folder / f"{reply_id}.json" - with open(sent_file, 'w', encoding='utf-8') as f: + with open(sent_file, "w", encoding="utf-8") as f: json.dump(reply_email_data, f, indent=2) # Auto-close the original email @@ -204,8 +200,7 @@ def _deliver_via_reply_path( inbox_data.setdefault("messages", []).insert(0, reply_email_data) inbox_data["total_messages"] = len(inbox_data["messages"]) - new_count = sum(1 for m in inbox_data["messages"] - if m.get("status") == "new" or not m.get("read", False)) + new_count = sum(1 for m in inbox_data["messages"] if m.get("status") == "new" or not m.get("read", False)) inbox_data["unread_count"] = new_count try: @@ -229,6 +224,7 @@ def _deliver_via_reply_path( # Auto-close the original email from aipass.ai_mail.apps.handlers.email.inbox_cleanup import mark_as_closed_and_archive + original_id = original_email.get("id") if original_id: close_success, close_msg = mark_as_closed_and_archive(from_branch_path, original_id) @@ -241,9 +237,10 @@ def _deliver_via_reply_path( if __name__ == "__main__": from aipass.cli.apps.modules import console - console.print("\n" + "="*70) + + console.print("\n" + "=" * 70) console.print("EMAIL REPLY HANDLER") - console.print("="*70) + console.print("=" * 70) console.print("\nPURPOSE:") console.print(" Sends reply to email's original sender and auto-closes original") console.print() @@ -258,4 +255,4 @@ if __name__ == "__main__": console.print(" 4. Save to sender's sent folder") console.print(" 5. Auto-close original email") console.print() - console.print("="*70 + "\n") + console.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/send.py b/src/aipass/ai_mail/apps/handlers/email/send.py index 47add1e2..77d6a123 100644 --- a/src/aipass/ai_mail/apps/handlers/email/send.py +++ b/src/aipass/ai_mail/apps/handlers/email/send.py @@ -53,15 +53,15 @@ def resolve_sender_info( "email_address": email_addr, "display_name": branch_info["name"], "mailbox_path": str(branch_path / ".ai_mail.local"), - "timestamp_format": "%Y-%m-%d %H:%M:%S" + "timestamp_format": "%Y-%m-%d %H:%M:%S", } else: - branch_name = from_branch.lstrip('@').upper() + branch_name = from_branch.lstrip("@").upper() return { "email_address": email_addr, "display_name": branch_name, - "mailbox_path": str(ai_mail_dir.parent / from_branch.lstrip('@').lower() / ".ai_mail.local"), - "timestamp_format": "%Y-%m-%d %H:%M:%S" + "mailbox_path": str(ai_mail_dir.parent / from_branch.lstrip("@").lower() / ".ai_mail.local"), + "timestamp_format": "%Y-%m-%d %H:%M:%S", } else: return get_current_user_fn() @@ -91,7 +91,9 @@ def send_to_broadcast( On failure: 4th element is an error string. On success: 4th element is a list of (branch_name, success, error_msg) tuples. """ - email_file = create_email_file_fn("all", subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to) + email_file = create_email_file_fn( + "all", subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to + ) email_data = load_email_file_fn(email_file) if email_data is None: @@ -101,13 +103,15 @@ def send_to_broadcast( results = [] # List of (branch_name, success, error_msg) for branch in branches: delivery_data = email_data.copy() - delivery_data['to'] = branch['email'] - delivery_data['auto_execute'] = auto_execute + delivery_data["to"] = branch["email"] + delivery_data["auto_execute"] = auto_execute if no_memory_save: - delivery_data['no_memory_save'] = True + delivery_data["no_memory_save"] = True - success, error_msg = deliver_email_to_branch_fn(branch['email'], delivery_data, on_delivered=on_delivered_callback) - results.append((branch.get('name', branch['email']), success, error_msg)) + success, error_msg = deliver_email_to_branch_fn( + branch["email"], delivery_data, on_delivered=on_delivered_callback + ) + results.append((branch.get("name", branch["email"]), success, error_msg)) success_count = sum(1 for _, s, _ in results if s) log_operation_fn("broadcast_sent", {"recipients": len(branches), "successful": success_count}) @@ -115,7 +119,8 @@ def send_to_broadcast( # Fire trigger event (best-effort) try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('email_broadcast_sent', recipients=len(branches), successful=success_count, subject=subject) + + trigger.fire("email_broadcast_sent", recipients=len(branches), successful=success_count, subject=subject) except ImportError as e: logger.warning("[send] trigger import unavailable for broadcast event: %s", e) @@ -151,18 +156,20 @@ def send_to_single( Returns: Tuple of (success, error_msg). error_msg is None on success. """ - email_file = create_email_file_fn(to_branch, subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to) + email_file = create_email_file_fn( + to_branch, subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to + ) email_data = load_email_file_fn(email_file) if email_data is None: log_operation_fn("email_failed", {"to": to_branch, "error": "Email file could not be loaded"}) return False, "Email file could not be loaded" - email_data['auto_execute'] = auto_execute + email_data["auto_execute"] = auto_execute if dispatched_to: - email_data['dispatched_to'] = dispatched_to + email_data["dispatched_to"] = dispatched_to if no_memory_save: - email_data['no_memory_save'] = True + email_data["no_memory_save"] = True success, error_msg = deliver_email_to_branch_fn(to_branch, email_data, on_delivered=on_delivered_callback) @@ -172,7 +179,8 @@ def send_to_single( # Fire trigger event (best-effort) try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('email_sent', to=to_branch, subject=subject, auto_execute=auto_execute) + + trigger.fire("email_sent", to=to_branch, subject=subject, auto_execute=auto_execute) except ImportError as e: logger.warning("[send] trigger import unavailable for send event: %s", e) @@ -239,7 +247,7 @@ def collect_interactive_input(branches: List[Dict[str, Any]]) -> Optional[Dict[s try: confirm = input("\nSend? (y/n): ").strip().lower() - if confirm != 'y': + if confirm != "y": return None except (KeyboardInterrupt, EOFError) as e: logger.warning("[send] confirmation cancelled: %s", e) diff --git a/src/aipass/ai_mail/apps/handlers/email/send_args.py b/src/aipass/ai_mail/apps/handlers/email/send_args.py index 3ecfb0c2..f03010ca 100644 --- a/src/aipass/ai_mail/apps/handlers/email/send_args.py +++ b/src/aipass/ai_mail/apps/handlers/email/send_args.py @@ -41,20 +41,20 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]: working_args = list(args) # Extract --dispatch / --auto-execute - auto_execute = '--dispatch' in working_args or '--auto-execute' in working_args - working_args = [a for a in working_args if a not in ('--dispatch', '--auto-execute')] + auto_execute = "--dispatch" in working_args or "--auto-execute" in working_args + working_args = [a for a in working_args if a not in ("--dispatch", "--auto-execute")] # Extract --no-memory-save - no_memory_save = '--no-memory-save' in working_args - working_args = [a for a in working_args if a != '--no-memory-save'] + no_memory_save = "--no-memory-save" in working_args + working_args = [a for a in working_args if a != "--no-memory-save"] # Extract --from (explicit sender identity override) from_branch = None - if '--from' in working_args: - idx = working_args.index('--from') + if "--from" in working_args: + idx = working_args.index("--from") if idx + 1 < len(working_args): from_branch = working_args[idx + 1] - working_args = working_args[:idx] + working_args[idx + 2:] + working_args = working_args[:idx] + working_args[idx + 2 :] else: return { "auto_execute": auto_execute, @@ -70,11 +70,11 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]: # Extract --reply-to reply_to = None - if '--reply-to' in working_args: - idx = working_args.index('--reply-to') + if "--reply-to" in working_args: + idx = working_args.index("--reply-to") if idx + 1 < len(working_args): reply_to = working_args[idx + 1] - working_args = working_args[:idx] + working_args[idx + 2:] + working_args = working_args[:idx] + working_args[idx + 2 :] else: return { "auto_execute": auto_execute, @@ -92,9 +92,9 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]: recipients = [] rest = [] for a in working_args: - if a.startswith('@') and not rest: + if a.startswith("@") and not rest: recipients.append(a) - elif a.startswith('/') and not rest: + elif a.startswith("/") and not rest: recipients.append(a) else: rest.append(a) @@ -145,7 +145,7 @@ def resolve_dispatch_target( if not auto_execute: return None - if branch.startswith('/') or branch.startswith('~'): + if branch.startswith("/") or branch.startswith("~"): if get_branch_info_fn: branch_info = get_branch_info_fn(Path(branch)) if branch_info: diff --git a/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py b/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py index 7551129c..3ff67bb5 100644 --- a/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py +++ b/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py @@ -10,14 +10,7 @@ from .json_handler import ( log_operation, increment_counter, update_data_metrics, - ensure_module_jsons + ensure_module_jsons, ) -__all__ = [ - 'load_json', - 'save_json', - 'log_operation', - 'increment_counter', - 'update_data_metrics', - 'ensure_module_jsons' -] \ No newline at end of file +__all__ = ["load_json", "save_json", "log_operation", "increment_counter", "update_data_metrics", "ensure_module_jsons"] diff --git a/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py b/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py index 3121af9f..409b2c15 100644 --- a/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py +++ b/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py @@ -45,7 +45,7 @@ def _get_caller_module_name() -> str: module_name = caller_path.stem # Validate module name - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name # Fallback @@ -63,7 +63,7 @@ def load_template(json_type: str, module_name: str) -> Any: return None try: - with open(template_path, 'r', encoding='utf-8') as f: + with open(template_path, "r", encoding="utf-8") as f: template = json.load(f) # Replace placeholders @@ -111,7 +111,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: if json_path.exists(): try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if validate_json_structure(data, json_type): @@ -124,7 +124,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: return False try: - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(template, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -140,7 +140,7 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: json_path = get_json_path(module_name, json_type) try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.warning("[json] Failed to load JSON for %s: %s", module_name, e) @@ -158,7 +158,7 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: data["last_updated"] = datetime.now().date().isoformat() try: - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -208,10 +208,7 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam log = [] # Create new entry - entry: Dict[str, Any] = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } + entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation} if data: entry["data"] = data @@ -257,9 +254,9 @@ def update_data_metrics(module_name: str, **metrics) -> bool: if __name__ == "__main__": - print("\n" + "="*70) + print("\n" + "=" * 70) print("JSON HANDLER - AI_MAIL Working Implementation") - print("="*70) + print("=" * 70) print("\n[TESTING] Creating AI_MAIL JSONs...") # Test auto-creation @@ -271,4 +268,4 @@ if __name__ == "__main__": print(" - ai_mail_config.json") print(" - ai_mail_data.json") print(" - ai_mail_log.json") - print("\n" + "="*70 + "\n") \ No newline at end of file + print("\n" + "=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/notify.py b/src/aipass/ai_mail/apps/handlers/notify.py index 1448671e..023e68f9 100644 --- a/src/aipass/ai_mail/apps/handlers/notify.py +++ b/src/aipass/ai_mail/apps/handlers/notify.py @@ -23,8 +23,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.ai_mail.apps.handlers.json import json_handler -def send_notification(title: str, body: str, source: str = "ai_mail", - icon: str = "dialog-information") -> bool: +def send_notification(title: str, body: str, source: str = "ai_mail", icon: str = "dialog-information") -> bool: """Send a persistent desktop notification. Args: @@ -47,17 +46,14 @@ def send_notification(title: str, body: str, source: str = "ai_mail", return _send_via_notify_send(title, body, icon) -def _send_via_dbus(title: str, body: str, source: str, - icon: str) -> bool: +def _send_via_dbus(title: str, body: str, source: str, icon: str) -> bool: """Send notification via D-Bus using system python.""" try: # Use system python which has dbus module (venv python may not) # Find python3 cross-platform (not hardcoded /usr/bin/python3) system_python = shutil.which("python3") or shutil.which("python") or sys.executable result = subprocess.run( - [system_python, "-c", _DBUS_SCRIPT, - source, icon, title, body], - capture_output=True, text=True, timeout=5 + [system_python, "-c", _DBUS_SCRIPT, source, icon, title, body], capture_output=True, text=True, timeout=5 ) return result.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError, OSError) as e: @@ -68,10 +64,7 @@ def _send_via_dbus(title: str, body: str, source: str, def _send_via_notify_send(title: str, body: str, icon: str) -> bool: """Fallback: send via notify-send.""" try: - subprocess.run( - ["notify-send", "-i", icon, title, body], - capture_output=True, timeout=5 - ) + subprocess.run(["notify-send", "-i", icon, title, body], capture_output=True, timeout=5) return True except (subprocess.SubprocessError, FileNotFoundError, OSError) as e: logger.warning("[notify] notify-send fallback failed: %s", e) diff --git a/src/aipass/ai_mail/apps/handlers/registry/read.py b/src/aipass/ai_mail/apps/handlers/registry/read.py index 21725401..35f54da9 100644 --- a/src/aipass/ai_mail/apps/handlers/registry/read.py +++ b/src/aipass/ai_mail/apps/handlers/registry/read.py @@ -59,7 +59,7 @@ def get_all_branches() -> List[Dict]: return [] try: - with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + with open(BRANCH_REGISTRY_PATH, "r", encoding="utf-8") as f: registry_data = json.load(f) # Handle both formats: list of dicts or dict keyed by name @@ -82,11 +82,7 @@ def get_all_branches() -> List[Dict]: else: email = _derive_email_from_branch_name(branch_name) - branches.append({ - "name": branch_name, - "path": path, - "email": email - }) + branches.append({"name": branch_name, "path": path, "email": email}) return branches @@ -112,18 +108,18 @@ def _derive_email_from_branch_name(branch_name: str) -> str: Returns: Email address in format "@email" """ - if '.' in branch_name: + if "." in branch_name: # Special case: AIPASS.admin -> admin - email_part = branch_name.split('.')[-1].lower() - elif ' ' in branch_name: + email_part = branch_name.split(".")[-1].lower() + elif " " in branch_name: # Handle spaces: take first word email_part = branch_name.split()[0].lower() - elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS': + elif "-" in branch_name and branch_name.split("-")[0] == "AIPASS": # AIPASS-prefixed branches: use second part to avoid collision - email_part = branch_name.split('-', 1)[1].lower() + email_part = branch_name.split("-", 1)[1].lower() else: # Take first word before hyphen or whole name - email_part = branch_name.split('-')[0].lower() + email_part = branch_name.split("-")[0].lower() return f"@{email_part}" @@ -200,9 +196,10 @@ def get_caller_project_branches(caller_cwd: str) -> Dict[str, str]: if __name__ == "__main__": from aipass.cli.apps.modules import console - console.print("\n" + "="*70) + + console.print("\n" + "=" * 70) console.print("AI_MAIL HANDLER: registry/read.py") - console.print("="*70) + console.print("=" * 70) console.print("\nRegistry Read Handler") console.print() console.print("FUNCTIONS PROVIDED:") @@ -219,4 +216,4 @@ if __name__ == "__main__": if len(branches) > 5: console.print(f" ... and {len(branches) - 5} more") - console.print("\n" + "="*70 + "\n") + console.print("\n" + "=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/trigger/__init__.py b/src/aipass/ai_mail/apps/handlers/trigger/__init__.py index 44a7199e..b50fb5fd 100644 --- a/src/aipass/ai_mail/apps/handlers/trigger/__init__.py +++ b/src/aipass/ai_mail/apps/handlers/trigger/__init__.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: __init__.py - Trigger Event Handlers Package diff --git a/src/aipass/ai_mail/apps/handlers/users/__init__.py b/src/aipass/ai_mail/apps/handlers/users/__init__.py index f97d2da2..9e00e1a4 100644 --- a/src/aipass/ai_mail/apps/handlers/users/__init__.py +++ b/src/aipass/ai_mail/apps/handlers/users/__init__.py @@ -7,14 +7,10 @@ Handles user information retrieval for AI_Mail system. # load.py moved to apps/.archive/users_load(disabled).py # config_generator.py moved to apps/.archive/config_generator(disabled).py -from .user import ( - get_current_user, - get_user_by_email, - get_all_users -) +from .user import get_current_user, get_user_by_email, get_all_users __all__ = [ - 'get_current_user', - 'get_user_by_email', - 'get_all_users', + "get_current_user", + "get_user_by_email", + "get_all_users", ] diff --git a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py index bcc72033..2ab75b3d 100644 --- a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py +++ b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py @@ -45,6 +45,7 @@ def _get_contact_info(branch_name: str) -> Optional[Dict]: """ try: from aipass.ai_mail.apps.handlers.email.contacts import get_contact + contact = get_contact(branch_name) if not contact: return None @@ -104,6 +105,7 @@ def _get_branches_list(registry: dict) -> list: # BRANCH DETECTION FUNCTIONS # ============================================= + def detect_branch_from_pwd() -> Optional[Dict]: """ Detect which branch is calling based on current working directory. @@ -196,7 +198,7 @@ def _lookup_branch_by_name(branch_name: str) -> Optional[Dict]: if BRANCH_REGISTRY_PATH.exists(): try: - with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + with open(BRANCH_REGISTRY_PATH, "r", encoding="utf-8") as f: registry = json.load(f) for branch in _get_branches_list(registry): if branch.get("name", "").lower() == name_lower: @@ -208,13 +210,15 @@ def _lookup_branch_by_name(branch_name: str) -> Optional[Dict]: caller_registry = _find_caller_registry() if caller_registry: try: - with open(caller_registry, 'r', encoding='utf-8') as f: + with open(caller_registry, "r", encoding="utf-8") as f: registry = json.load(f) for branch in _get_branches_list(registry): if branch.get("name", "").lower() == name_lower: return branch except Exception as e: - logger.warning("[identity] _lookup_branch_by_name(%s) caller registry %s failed: %s", branch_name, caller_registry, e) + logger.warning( + "[identity] _lookup_branch_by_name(%s) caller registry %s failed: %s", branch_name, caller_registry, e + ) return None @@ -263,7 +267,7 @@ def get_branch_info_from_registry(branch_path: Path) -> Optional[Dict]: if BRANCH_REGISTRY_PATH.exists(): try: - with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + with open(BRANCH_REGISTRY_PATH, "r", encoding="utf-8") as f: registry = json.load(f) registry_dir = BRANCH_REGISTRY_PATH.parent for branch in _get_branches_list(registry): @@ -281,7 +285,7 @@ def get_branch_info_from_registry(branch_path: Path) -> Optional[Dict]: caller_registry = _find_caller_registry() if caller_registry: try: - with open(caller_registry, 'r', encoding='utf-8') as f: + with open(caller_registry, "r", encoding="utf-8") as f: registry = json.load(f) registry_dir = caller_registry.parent for branch in _get_branches_list(registry): @@ -301,9 +305,9 @@ def get_branch_info_from_registry(branch_path: Path) -> Optional[Dict]: if __name__ == "__main__": from aipass.cli.apps.modules import console - console.print("\n" + "="*70) + console.print("\n" + "=" * 70) console.print("BRANCH AUTO-DETECTION HANDLER") - console.print("="*70) + console.print("=" * 70) console.print("\nPURPOSE:") console.print(" Detects which branch is calling AI_MAIL based on PWD/CWD") console.print(" Walks up directory tree to find branch root") @@ -325,4 +329,4 @@ if __name__ == "__main__": console.print(" 3. Look up branch path in AIPASS_REGISTRY.json") console.print(" 4. Return branch info (name, email, path, etc.)") console.print() - console.print("="*70 + "\n") + console.print("=" * 70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/users/user.py b/src/aipass/ai_mail/apps/handlers/users/user.py index d3059ee6..aae07868 100644 --- a/src/aipass/ai_mail/apps/handlers/users/user.py +++ b/src/aipass/ai_mail/apps/handlers/users/user.py @@ -31,6 +31,7 @@ from .branch_detection import detect_branch_from_pwd # USER INFO FUNCTIONS # ============================================= + def get_current_user() -> Dict: """ Get current user's information from branch detection (AIPASS_REGISTRY.json) @@ -65,6 +66,7 @@ def get_current_user() -> Dict: # Extract info from branch_info (from AIPASS_REGISTRY.json) from .branch_detection import BRANCH_REGISTRY_PATH + _repo_root = BRANCH_REGISTRY_PATH.parent branch_name = branch_info.get("name") @@ -92,7 +94,7 @@ def get_current_user() -> Dict: "email_address": email, "display_name": branch_name, "mailbox_path": str(mailbox_path), - "timestamp_format": "%Y-%m-%d %H:%M:%S" + "timestamp_format": "%Y-%m-%d %H:%M:%S", } @@ -115,9 +117,10 @@ def get_user_by_email(email: str) -> Optional[Dict]: try: import json + _repo_root = registry_path.parent - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) for branch in _get_branches_list(registry): @@ -129,7 +132,7 @@ def get_user_by_email(email: str) -> Optional[Dict]: "email_address": branch.get("email"), "display_name": branch.get("name"), "mailbox_path": str(branch_path / ".ai_mail.local"), - "timestamp_format": "%Y-%m-%d %H:%M:%S" + "timestamp_format": "%Y-%m-%d %H:%M:%S", } return None except Exception as e: @@ -145,13 +148,15 @@ def get_all_users() -> Dict[str, Dict]: Dict mapping branch emails to user info dicts """ from .branch_detection import BRANCH_REGISTRY_PATH, _get_branches_list + registry_path = BRANCH_REGISTRY_PATH if not registry_path.exists(): return {} try: import json - with open(registry_path, 'r', encoding='utf-8') as f: + + with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) _repo_root = registry_path.parent @@ -166,7 +171,7 @@ def get_all_users() -> Dict[str, Dict]: "email_address": email, "display_name": branch.get("name"), "mailbox_path": str(branch_path / ".ai_mail.local"), - "timestamp_format": "%Y-%m-%d %H:%M:%S" + "timestamp_format": "%Y-%m-%d %H:%M:%S", } return users except Exception as e: diff --git a/src/aipass/ai_mail/apps/modules/dispatch.py b/src/aipass/ai_mail/apps/modules/dispatch.py index ec6af82c..2af2c82c 100644 --- a/src/aipass/ai_mail/apps/modules/dispatch.py +++ b/src/aipass/ai_mail/apps/modules/dispatch.py @@ -20,11 +20,7 @@ from typing import List from aipass.prax.apps.modules.logger import system_logger as logger from aipass.cli.apps.modules import console, error from aipass.ai_mail.apps.handlers.json import json_handler -from aipass.ai_mail.apps.handlers.dispatch.status import ( - load_dispatch_log, - check_pid_status, - calculate_age -) +from aipass.ai_mail.apps.handlers.dispatch.status import load_dispatch_log, check_pid_status, calculate_age def print_help() -> None: @@ -77,7 +73,7 @@ def handle_command(command: str, args: List[str]) -> bool: if command != "dispatch": return False - if args and args[0] in ['--help', '-h', 'help']: + if args and args[0] in ["--help", "-h", "help"]: print_help() return True @@ -157,10 +153,10 @@ def _orchestrate_status() -> bool: def _orchestrate_wake(args: List[str]) -> bool: """Orchestrate manual branch wake.""" - if not args or args[0] in ['--help', '-h', 'help']: + if not args or args[0] in ["--help", "-h", "help"]: console.print("\n[bold]Wake - Manual branch spawn[/bold]") - console.print(" Usage: dispatch wake @branch [\"custom message\"]") - console.print(" Or: drone wake @branch [\"custom message\"]\n") + console.print(' Usage: dispatch wake @branch ["custom message"]') + console.print(' Or: drone wake @branch ["custom message"]\n') return True # Parse --fresh, --sender, --model flags @@ -195,9 +191,9 @@ def _orchestrate_wake(args: List[str]) -> bool: console.print(f"\n⏳ Waking {branch_email}...") from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch + dispatch_status, success = wake_branch( - branch_email, custom_message, fresh=use_fresh, sender=use_sender, - model=use_model + branch_email, custom_message, fresh=use_fresh, sender=use_sender, model=use_model ) # Print step-by-step status @@ -236,7 +232,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: i += 1 if len(filtered) < 3: - error("Usage: dispatch @target \"Subject\" \"Body\" [--fresh] [--no-memory-save]") + error('Usage: dispatch @target "Subject" "Body" [--fresh] [--no-memory-save]') return True target = filtered[0] @@ -244,9 +240,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: body = filtered[2] logger.info(f"[dispatch] Combined dispatch: send + wake for {target}") - json_handler.log_operation("dispatch_send_and_wake", { - "target": target, "subject": subject, "fresh": use_fresh - }) + json_handler.log_operation("dispatch_send_and_wake", {"target": target, "subject": subject, "fresh": use_fresh}) # --- Step 1: Send dispatch email --- console.print(f"\nSending dispatch email to {target}...") @@ -270,21 +264,34 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: _repo_root = _ai_mail_dir.parents[2] def _delivery_callback(branch_path, new_count, opened_count, total): - on_email_delivered(branch_path, new_count, opened_count, total, - push_dashboard_fn=push_dashboard_update, - update_central_fn=update_central) + on_email_delivered( + branch_path, + new_count, + opened_count, + total, + push_dashboard_fn=push_dashboard_update, + update_central_fn=update_central, + ) try: - user_info = resolve_sender_info( - from_branch, _repo_root, _ai_mail_dir, get_branch_by_email, get_current_user - ) + user_info = resolve_sender_info(from_branch, _repo_root, _ai_mail_dir, get_branch_by_email, get_current_user) message = prepend_dispatch_header(body, no_memory_save=no_memory_save) send_ok, send_error = send_to_single( - target, subject, message, user_info, True, no_memory_save, - None, target, create_email_file, load_email_file, - deliver_email_to_branch, _delivery_callback, - json_handler.log_operation, update_central + target, + subject, + message, + user_info, + True, + no_memory_save, + None, + target, + create_email_file, + load_email_file, + deliver_email_to_branch, + _delivery_callback, + json_handler.log_operation, + update_central, ) if not send_ok: @@ -296,7 +303,8 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('email_dispatched', to=target, subject=subject) + + trigger.fire("email_dispatched", to=target, subject=subject) except Exception as e: logger.warning("[dispatch] trigger fire failed: %s", e) @@ -309,18 +317,14 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: console.print(f"\nWaking {target}...") from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch + dispatch_status, wake_ok = wake_branch( - target, fresh=use_fresh, - sender=user_info.get("email_address", "@ai_mail"), - model=use_model + target, fresh=use_fresh, sender=user_info.get("email_address", "@ai_mail"), model=use_model ) console.print(dispatch_status.format()) if not wake_ok: - console.print( - f"[yellow]Email sent but wake failed — retry: " - f"drone @ai_mail dispatch wake {target}[/yellow]" - ) + console.print(f"[yellow]Email sent but wake failed — retry: drone @ai_mail dispatch wake {target}[/yellow]") return True @@ -331,6 +335,7 @@ def _orchestrate_daemon() -> bool: console.print("\n[bold]Starting dispatch daemon...[/bold]") from aipass.ai_mail.apps.handlers.dispatch.daemon import run_daemon + run_daemon() return True @@ -339,7 +344,9 @@ def print_introspection(): """Display module introspection info.""" console.print() console.print("dispatch Module") - console.print("Orchestrates dispatch commands: combined send+wake, status tracking, daemon management, and manual wake.") + console.print( + "Orchestrates dispatch commands: combined send+wake, status tracking, daemon management, and manual wake." + ) console.print() console.print("Connected Handlers:") console.print(" handlers/dispatch/") @@ -361,7 +368,7 @@ if __name__ == "__main__": print_help() sys.exit(0) - if sys.argv[1] in ['--help', '-h', 'help']: + if sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/ai_mail/apps/modules/email.py b/src/aipass/ai_mail/apps/modules/email.py index 5b14ba9c..175a53d0 100644 --- a/src/aipass/ai_mail/apps/modules/email.py +++ b/src/aipass/ai_mail/apps/modules/email.py @@ -39,7 +39,8 @@ from aipass.ai_mail.apps.handlers.email.format import format_email_list_item, fo from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox from aipass.ai_mail.apps.handlers.email.inbox_cleanup import ( mark_all_read_and_archive, - mark_as_opened, mark_as_closed_and_archive + mark_as_opened, + mark_as_closed_and_archive, ) from aipass.ai_mail.apps.handlers.email.reply import get_email_by_id, send_reply from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header @@ -47,7 +48,10 @@ from aipass.ai_mail.apps.handlers.users.user import get_current_user from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_branch_by_email from aipass.ai_mail.apps.handlers.json import json_handler from aipass.ai_mail.apps.handlers.email.send import ( - resolve_sender_info, send_to_broadcast, send_to_single, collect_interactive_input + resolve_sender_info, + send_to_broadcast, + send_to_single, + collect_interactive_input, ) from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target @@ -63,9 +67,14 @@ except ImportError as e: def _delivery_callback(branch_path, new_count, opened_count, total): """Post-delivery callback: delegates to error_dispatch handler.""" - on_email_delivered(branch_path, new_count, opened_count, total, - push_dashboard_fn=push_dashboard_update, - update_central_fn=update_central) + on_email_delivered( + branch_path, + new_count, + opened_count, + total, + push_dashboard_fn=push_dashboard_update, + update_central_fn=update_central, + ) def _resolve_branch_path() -> Path: @@ -122,15 +131,20 @@ def handle_command(command: str, args: List[str]) -> bool: valid = ["send", "email", "inbox", "view", "close", "reply", "sent", "contacts", "read", "register"] if command not in valid: return False - if args and args[0] in ['--help', '-h', 'help']: + if args and args[0] in ["--help", "-h", "help"]: print_help() return True dispatch = { - "send": handle_send, "email": handle_send, - "inbox": handle_inbox, "view": handle_view, - "close": handle_close, "reply": handle_reply, "read": handle_view, - "sent": handle_sent, "contacts": handle_contacts, + "send": handle_send, + "email": handle_send, + "inbox": handle_inbox, + "view": handle_view, + "close": handle_close, + "reply": handle_reply, + "read": handle_view, + "sent": handle_sent, + "contacts": handle_contacts, "register": handle_register, } return dispatch[command](args) @@ -142,8 +156,8 @@ def handle_send(args: List[str]) -> bool: parsed = parse_send_args(args) if parsed["mode"] == "error": - error(parsed['error']) - console.print(" Multiple: send @branch1 @branch2 \"Subject\" \"Message\"") + error(parsed["error"]) + console.print(' Multiple: send @branch1 @branch2 "Subject" "Message"') return False if parsed["mode"] == "interactive": @@ -154,17 +168,31 @@ def handle_send(args: List[str]) -> bool: from_branch = parsed.get("from_branch") if len(recipients) == 1: target = resolve_dispatch_target(recipients[0], parsed["auto_execute"], _get_branch_info_fn()) - return _send_direct(recipients[0], parsed["subject"], parsed["message"], - parsed["auto_execute"], parsed["reply_to"], target, parsed["no_memory_save"], - from_branch=from_branch) + return _send_direct( + recipients[0], + parsed["subject"], + parsed["message"], + parsed["auto_execute"], + parsed["reply_to"], + target, + parsed["no_memory_save"], + from_branch=from_branch, + ) console.print(f"\n[bold]Group send to {len(recipients)} recipients...[/bold]") ok = 0 for r in recipients: target = resolve_dispatch_target(r, parsed["auto_execute"], _get_branch_info_fn()) - if _send_direct(r, parsed["subject"], parsed["message"], - parsed["auto_execute"], parsed["reply_to"], target, parsed["no_memory_save"], - from_branch=from_branch): + if _send_direct( + r, + parsed["subject"], + parsed["message"], + parsed["auto_execute"], + parsed["reply_to"], + target, + parsed["no_memory_save"], + from_branch=from_branch, + ): ok += 1 console.print(f"\nGroup send complete: {ok}/{len(recipients)} delivered") return ok > 0 @@ -174,6 +202,7 @@ def _get_branch_info_fn(): """Return branch info lookup fn for dispatch target resolution, or None.""" try: from aipass.ai_mail.apps.handlers.users.branch_detection import get_branch_info_from_registry + return get_branch_info_from_registry except ImportError as e: logger.warning("[email] branch_detection import unavailable: %s", e) @@ -198,24 +227,44 @@ def _send_interactive() -> bool: console.print("\n" + "=" * 50) console.print(f"To: {result['to']}\nSubject: {result['subject']}\nMessage:\n{result['message']}") console.print("=" * 50) - return _send_direct(result['to'], result['subject'], result['message']) + return _send_direct(result["to"], result["subject"], result["message"]) -def _send_direct(to_branch, subject, message, auto_execute=False, - reply_to=None, dispatched_to=None, no_memory_save=False, from_branch=None) -> bool: +def _send_direct( + to_branch, + subject, + message, + auto_execute=False, + reply_to=None, + dispatched_to=None, + no_memory_save=False, + from_branch=None, +) -> bool: """Direct email send - thin wrapper over send handlers.""" try: user_info = resolve_sender_info(from_branch, _REPO_ROOT, _AI_MAIL_DIR, get_branch_by_email, get_current_user) if auto_execute: message = prepend_dispatch_header(message, no_memory_save=no_memory_save) - if to_branch.lower() in ['all', '@all']: + if to_branch.lower() in ["all", "@all"]: return _send_broadcast(subject, message, user_info, auto_execute, no_memory_save, reply_to, dispatched_to) success, error_msg = send_to_single( - to_branch, subject, message, user_info, auto_execute, no_memory_save, - reply_to, dispatched_to, create_email_file, load_email_file, - deliver_email_to_branch, _delivery_callback, json_handler.log_operation, update_central) + to_branch, + subject, + message, + user_info, + auto_execute, + no_memory_save, + reply_to, + dispatched_to, + create_email_file, + load_email_file, + deliver_email_to_branch, + _delivery_callback, + json_handler.log_operation, + update_central, + ) if success: label = "\\[dispatch: queued for daemon]" if auto_execute else "" @@ -240,7 +289,7 @@ def _send_direct(to_branch, subject, message, auto_execute=False, def _fire_dispatch_trigger(to_branch: str, subject: str) -> None: """Fire email_dispatched trigger event if auto_execute enabled.""" try: - trigger.fire('email_dispatched', to=to_branch, subject=subject) + trigger.fire("email_dispatched", to=to_branch, subject=subject) except Exception as e: logger.warning("[email] trigger fire for email_dispatched failed: %s", e) @@ -250,9 +299,21 @@ def _send_broadcast(subject, message, user_info, auto_execute, no_memory_save, r branches = get_all_branches() console.print(f"\nBroadcasting to {len(branches)} branches...") ok, success_count, total, results = send_to_broadcast( - subject, message, user_info, auto_execute, no_memory_save, reply_to, dispatched_to, - branches, create_email_file, load_email_file, deliver_email_to_branch, - _delivery_callback, json_handler.log_operation, update_central) + subject, + message, + user_info, + auto_execute, + no_memory_save, + reply_to, + dispatched_to, + branches, + create_email_file, + load_email_file, + deliver_email_to_branch, + _delivery_callback, + json_handler.log_operation, + update_central, + ) if isinstance(results, str) or results is None: error("Failed to load email file for broadcast") return False @@ -270,6 +331,7 @@ def handle_inbox(args: List[str]) -> bool: json_handler.log_operation("inbox_viewed") try: first_arg = args[0] if args else None + def _get_user_with_fallback(): try: return get_current_user() @@ -279,9 +341,10 @@ def handle_inbox(args: List[str]) -> bool: "mailbox_path": str(_AI_MAIL_DIR / ".ai_mail.local"), "display_name": "AI_MAIL", } + ok, info = resolve_inbox_target(first_arg, _REPO_ROOT, get_branch_by_email, _get_user_with_fallback) if not ok: - error(info['error']) + error(info["error"]) return False inbox_file = info["inbox_file"] @@ -343,9 +406,9 @@ def handle_view(args: List[str]) -> bool: return True header = format_email_header(email_data) console.print(f"\n{header}") - console.print(f"\n{email_data.get('message', '')}\n{'='*70}") + console.print(f"\n{email_data.get('message', '')}\n{'=' * 70}") console.print(f"[dim]Status: opened | ID: {args[0]}[/dim]") - console.print(f"[dim]To reply: drone @ai_mail reply {args[0]} \"your message\"[/dim]") + console.print(f'[dim]To reply: drone @ai_mail reply {args[0]} "your message"[/dim]') console.print(f"[dim]To close: drone @ai_mail close {args[0]}[/dim]") json_handler.log_operation("email_viewed", {"message_id": args[0]}) return True @@ -390,8 +453,7 @@ def handle_close(args: List[str]) -> bool: except ImportError as e: logger.warning("[email] purge import unavailable: %s", e) run_purge = None - batch_close_post_ops(branch_path, push_dashboard_update, update_central, - run_purge) + batch_close_post_ops(branch_path, push_dashboard_update, update_central, run_purge) console.print(f"\nClosed {closed}, failed {failed}") return True except Exception as e: @@ -404,7 +466,7 @@ def handle_reply(args: List[str]) -> bool: """Reply to an email.""" json_handler.log_operation("reply_email_initiated", {"args": args}) if len(args) < 2: - error("Usage: drone @ai_mail reply \"your message\"") + error('Usage: drone @ai_mail reply "your message"') return True try: branch_path = _resolve_branch_path() @@ -485,6 +547,7 @@ def handle_register(args: List[str]) -> bool: inbox_path = args[1] project = args[2] if len(args) > 2 else "" from aipass.ai_mail.apps.handlers.email.contacts import register_contact + ok = register_contact(branch_name, project, inbox_path) if ok: console.print(f"[green]Registered @{branch_name} -> {inbox_path}[/green]") @@ -539,7 +602,7 @@ def print_introspection(): if __name__ == "__main__": - if len(sys.argv) == 1 or sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) == 1 or sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) command = sys.argv[1] diff --git a/src/aipass/ai_mail/tests/conftest.py b/src/aipass/ai_mail/tests/conftest.py index e2545ff9..012d1183 100644 --- a/src/aipass/ai_mail/tests/conftest.py +++ b/src/aipass/ai_mail/tests/conftest.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: tests/conftest.py @@ -15,6 +14,7 @@ # ============================================= """Shared pytest fixtures for ai_mail tests""" + import os import tempfile @@ -45,10 +45,7 @@ def sample_test_data() -> dict: Customize this fixture for your module's needs """ - return { - "test_key": "test_value", - "sample_data": "example" - } + return {"test_key": "test_value", "sample_data": "example"} @pytest.fixture diff --git a/src/aipass/ai_mail/tests/test_central_writer.py b/src/aipass/ai_mail/tests/test_central_writer.py index 5fd4a866..25d411d4 100644 --- a/src/aipass/ai_mail/tests/test_central_writer.py +++ b/src/aipass/ai_mail/tests/test_central_writer.py @@ -230,11 +230,13 @@ def test_aggregate_branch_stats_multiple_branches(tmp_path, monkeypatch): ) monkeypatch.setattr( - mod, "find_all_inbox_files", + mod, + "find_all_inbox_files", lambda: [seedgo_mail / "inbox.json", drone_mail / "inbox.json"], ) monkeypatch.setattr( - mod, "get_valid_branch_names", + mod, + "get_valid_branch_names", lambda: {"SEEDGO", "DRONE"}, ) @@ -262,11 +264,13 @@ def test_aggregate_branch_stats_skips_unregistered(tmp_path, monkeypatch): ) monkeypatch.setattr( - mod, "find_all_inbox_files", + mod, + "find_all_inbox_files", lambda: [rogue_mail / "inbox.json", valid_mail / "inbox.json"], ) monkeypatch.setattr( - mod, "get_valid_branch_names", + mod, + "get_valid_branch_names", lambda: {"SEEDGO"}, # ROGUE not registered ) @@ -290,11 +294,13 @@ def test_aggregate_branch_stats_skips_malformed_inbox(tmp_path, monkeypatch): ) monkeypatch.setattr( - mod, "find_all_inbox_files", + mod, + "find_all_inbox_files", lambda: [bad_mail / "inbox.json", good_mail / "inbox.json"], ) monkeypatch.setattr( - mod, "get_valid_branch_names", + mod, + "get_valid_branch_names", lambda: {"BAD", "GOOD"}, ) diff --git a/src/aipass/ai_mail/tests/test_contacts.py b/src/aipass/ai_mail/tests/test_contacts.py index 131aa01d..0ec0997c 100644 --- a/src/aipass/ai_mail/tests/test_contacts.py +++ b/src/aipass/ai_mail/tests/test_contacts.py @@ -23,6 +23,7 @@ from aipass.ai_mail.apps.handlers.email.contacts import ( # ---- Fixtures ------------------------------------------------ + @pytest.fixture(autouse=True) def _silence_json_handler(): """Prevent log_operation from writing real JSON files during tests.""" @@ -41,6 +42,7 @@ def isolated_contacts(tmp_path, monkeypatch): # ---- _load_contacts() tests -------------------------------- + def test_load_contacts_missing_file(isolated_contacts): """Missing contacts.json returns default empty structure.""" result = _load_contacts() @@ -65,6 +67,7 @@ def test_load_contacts_missing_key(isolated_contacts): # ---- get_contact() tests ----------------------------------- + def test_get_contact_empty(isolated_contacts): """get_contact with no contacts returns None.""" result = get_contact("devpulse") @@ -95,6 +98,7 @@ def test_get_contact_not_found(isolated_contacts): # ---- register_contact() tests ------------------------------ + def test_register_contact_creates_entry(isolated_contacts): """register_contact writes a new entry with correct fields.""" ok = register_contact("devpulse", "AIPass", "/path/to/inbox.json") @@ -146,6 +150,7 @@ def test_register_contact_persists_to_disk(isolated_contacts): # ---- all_contacts() tests ---------------------------------- + def test_all_contacts_empty(isolated_contacts): """all_contacts returns empty dict when no contacts registered.""" result = all_contacts() diff --git a/src/aipass/ai_mail/tests/test_dispatch_monitor.py b/src/aipass/ai_mail/tests/test_dispatch_monitor.py index f1875766..0b715615 100644 --- a/src/aipass/ai_mail/tests/test_dispatch_monitor.py +++ b/src/aipass/ai_mail/tests/test_dispatch_monitor.py @@ -35,7 +35,8 @@ from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import ( def _suppress_log_operation(monkeypatch): """Prevent json_handler.log_operation from touching real files.""" monkeypatch.setattr( - mod, "json_handler", + mod, + "json_handler", MagicMock(), ) @@ -164,9 +165,7 @@ def test_run_startup_check_success(tmp_path, monkeypatch): monkeypatch.setattr(mod, "_snapshot_jsonl_sizes", lambda d: {}) monkeypatch.setattr(mod, "_check_jsonl_activity", fake_activity) - exit_code, startup_failed = _run_with_startup_check( - ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" - ) + exit_code, startup_failed = _run_with_startup_check(["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test") assert exit_code == 0 assert startup_failed is False @@ -190,9 +189,7 @@ def test_run_startup_check_timeout(tmp_path, monkeypatch): mock_kill = MagicMock() monkeypatch.setattr(mod, "_kill_process", mock_kill) - exit_code, startup_failed = _run_with_startup_check( - ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" - ) + exit_code, startup_failed = _run_with_startup_check(["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test") assert exit_code == -3 assert startup_failed is True mock_kill.assert_called_once() @@ -213,9 +210,7 @@ def test_run_startup_check_process_exits_during_startup_no_output(tmp_path, monk monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **kw: mock_proc) - exit_code, startup_failed = _run_with_startup_check( - ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" - ) + exit_code, startup_failed = _run_with_startup_check(["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test") assert exit_code == 1 assert startup_failed is True # Zero output = startup failure @@ -249,9 +244,7 @@ def test_run_startup_check_process_exits_during_startup_with_output(tmp_path, mo monkeypatch.setattr(mod, "_snapshot_jsonl_sizes", lambda d: {}) monkeypatch.setattr(mod, "_check_jsonl_activity", lambda d, s: True) - exit_code, startup_failed = _run_with_startup_check( - ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" - ) + exit_code, startup_failed = _run_with_startup_check(["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test") assert exit_code == 1 assert startup_failed is False # Had JSONL activity = normal failure, not startup @@ -287,9 +280,7 @@ def test_run_startup_check_hard_timeout(tmp_path, monkeypatch): monkeypatch.setattr(mod, "_snapshot_jsonl_sizes", lambda d: {}) monkeypatch.setattr(mod, "_check_jsonl_activity", lambda d, s: True) - exit_code, startup_failed = _run_with_startup_check( - ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" - ) + exit_code, startup_failed = _run_with_startup_check(["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test") assert exit_code == -1 assert startup_failed is False mock_kill.assert_called_once() @@ -308,9 +299,7 @@ def test_run_startup_check_spawn_failure(tmp_path, monkeypatch): monkeypatch.setattr(mod.subprocess, "Popen", raise_oserror) - exit_code, startup_failed = _run_with_startup_check( - ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" - ) + exit_code, startup_failed = _run_with_startup_check(["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test") assert exit_code == -2 assert startup_failed is False @@ -340,7 +329,10 @@ def main_argv(tmp_path): "@sender", str(stderr_log), "--", - "claude", "-c", "--model", "opus", + "claude", + "-c", + "--model", + "opus", ] return argv, lock_file, stderr_log @@ -380,11 +372,15 @@ def test_main_second_attempt_success(monkeypatch, main_argv): monkeypatch.setattr(mod, "_run_with_startup_check", mock_run) monkeypatch.setattr(mod, "_send_bounce", mock_bounce) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) - monkeypatch.setattr(mod, "time", MagicMock( - time=time.time, - strftime=time.strftime, - sleep=MagicMock(), - )) + monkeypatch.setattr( + mod, + "time", + MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + ), + ) monkeypatch.setattr( "aipass.ai_mail.apps.handlers.paths.find_repo_root", MagicMock(return_value=Path("/fake/repo")), @@ -412,11 +408,15 @@ def test_main_third_attempt_fresh(monkeypatch, main_argv): monkeypatch.setattr(mod, "_run_with_startup_check", track_run) monkeypatch.setattr(mod, "_send_bounce", MagicMock()) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) - monkeypatch.setattr(mod, "time", MagicMock( - time=time.time, - strftime=time.strftime, - sleep=MagicMock(), - )) + monkeypatch.setattr( + mod, + "time", + MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + ), + ) monkeypatch.setattr( "aipass.ai_mail.apps.handlers.paths.find_repo_root", MagicMock(return_value=Path("/fake/repo")), @@ -441,15 +441,18 @@ def test_main_all_three_fail_sends_bounce(monkeypatch, main_argv): mock_bounce = MagicMock() monkeypatch.setattr("sys.argv", argv) - monkeypatch.setattr(mod, "_run_with_startup_check", - MagicMock(side_effect=[(1, False), (-3, True), (1, False)])) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (-3, True), (1, False)])) monkeypatch.setattr(mod, "_send_bounce", mock_bounce) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) - monkeypatch.setattr(mod, "time", MagicMock( - time=time.time, - strftime=time.strftime, - sleep=MagicMock(), - )) + monkeypatch.setattr( + mod, + "time", + MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + ), + ) monkeypatch.setattr( "aipass.ai_mail.apps.handlers.paths.find_repo_root", MagicMock(return_value=Path("/fake/repo")), @@ -474,8 +477,7 @@ def test_main_rate_limit_delay(monkeypatch, main_argv): mock_time.sleep = MagicMock() monkeypatch.setattr("sys.argv", argv) - monkeypatch.setattr(mod, "_run_with_startup_check", - MagicMock(side_effect=[(1, False), (0, False)])) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (0, False)])) monkeypatch.setattr(mod, "_send_bounce", MagicMock()) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=True)) monkeypatch.setattr(mod, "time", mock_time) @@ -551,8 +553,7 @@ def test_send_bounce_missing_stderr(tmp_path, monkeypatch): monkeypatch.setattr(mod.subprocess, "run", mock_sub_run) # Pass a nonexistent stderr log - result = _send_bounce("@test", "failed", "@sender", str(lock), - str(tmp_path / "nonexistent.log")) + result = _send_bounce("@test", "failed", "@sender", str(lock), str(tmp_path / "nonexistent.log")) assert result is True # The body should contain "(no stderr captured)" fallback call_args = mock_sub_run.call_args @@ -568,8 +569,7 @@ def test_notification_uses_at_branch_format(monkeypatch, main_argv): argv, lock_file, stderr_log = main_argv monkeypatch.setattr("sys.argv", argv) - monkeypatch.setattr(mod, "_run_with_startup_check", - MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) monkeypatch.setattr(mod, "_send_bounce", MagicMock()) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) monkeypatch.setattr( @@ -615,9 +615,7 @@ def test_kill_process_terminate_timeout_falls_back_to_sigkill(): """SIGTERM times out — falls back to SIGKILL.""" mock_proc = MagicMock() mock_proc.terminate = MagicMock() - mock_proc.wait = MagicMock( - side_effect=[subprocess.TimeoutExpired(cmd="claude", timeout=10), None] - ) + mock_proc.wait = MagicMock(side_effect=[subprocess.TimeoutExpired(cmd="claude", timeout=10), None]) mock_proc.kill = MagicMock() _kill_process(mock_proc, "@test") @@ -676,8 +674,13 @@ def test_stderr_rotation_on_large_file(tmp_path, monkeypatch): stderr_log.write_text("x" * 520_000, encoding="utf-8") argv = [ - "dispatch_monitor.py", "@test", str(tmp_path / ".dispatch.lock"), - "@sender", str(stderr_log), "--", "claude", + "dispatch_monitor.py", + "@test", + str(tmp_path / ".dispatch.lock"), + "@sender", + str(stderr_log), + "--", + "claude", ] monkeypatch.setattr("sys.argv", argv) @@ -724,8 +727,13 @@ def test_stdout_rotation_on_large_file(tmp_path, monkeypatch): stdout_log.write_text("x" * 520_000, encoding="utf-8") argv = [ - "dispatch_monitor.py", "@test", str(lock), - "@sender", str(stderr_log), "--", "claude", + "dispatch_monitor.py", + "@test", + str(lock), + "@sender", + str(stderr_log), + "--", + "claude", ] monkeypatch.setattr("sys.argv", argv) @@ -771,13 +779,18 @@ def test_lock_cleanup_on_failure(monkeypatch, main_argv): argv, lock_file, stderr_log = main_argv monkeypatch.setattr("sys.argv", argv) - monkeypatch.setattr(mod, "_run_with_startup_check", - MagicMock(side_effect=[(1, False), (1, False), (1, False)])) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (1, False), (1, False)])) monkeypatch.setattr(mod, "_send_bounce", MagicMock()) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) - monkeypatch.setattr(mod, "time", MagicMock( - time=time.time, strftime=time.strftime, sleep=MagicMock(), - )) + monkeypatch.setattr( + mod, + "time", + MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + ), + ) monkeypatch.setattr( "aipass.ai_mail.apps.handlers.paths.find_repo_root", MagicMock(return_value=Path("/fake/repo")), @@ -857,9 +870,7 @@ def test_kill_process_sigkill_fallback(): """terminate() times out — falls back to kill().""" mock_proc = MagicMock() mock_proc.terminate = MagicMock() - mock_proc.wait = MagicMock( - side_effect=[subprocess.TimeoutExpired(cmd="claude", timeout=10), None] - ) + mock_proc.wait = MagicMock(side_effect=[subprocess.TimeoutExpired(cmd="claude", timeout=10), None]) mock_proc.kill = MagicMock() _kill_process(mock_proc, "@test") @@ -883,9 +894,7 @@ def test_main_max_turns_detected(monkeypatch, main_argv): def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch): # Write max_turns stop_reason into stdout log - Path(stdout_log_path).write_text( - '{"stop_reason":"max_turns"}', encoding="utf-8" - ) + Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8") return (0, False) monkeypatch.setattr("sys.argv", argv) @@ -930,8 +939,13 @@ def test_stderr_rotation(tmp_path, monkeypatch): lock.write_text("{}", encoding="utf-8") argv = [ - "dispatch_monitor.py", "@test", str(lock), - "@sender", str(stderr_log), "--", "claude", + "dispatch_monitor.py", + "@test", + str(lock), + "@sender", + str(stderr_log), + "--", + "claude", ] monkeypatch.setattr("sys.argv", argv) @@ -968,8 +982,13 @@ def test_stdout_rotation(tmp_path, monkeypatch): stdout_log.write_text("x" * 520_000, encoding="utf-8") argv = [ - "dispatch_monitor.py", "@test", str(lock), - "@sender", str(stderr_log), "--", "claude", + "dispatch_monitor.py", + "@test", + str(lock), + "@sender", + str(stderr_log), + "--", + "claude", ] monkeypatch.setattr("sys.argv", argv) @@ -1015,13 +1034,18 @@ def test_lock_cleaned_on_failure(monkeypatch, main_argv): argv, lock_file, stderr_log = main_argv monkeypatch.setattr("sys.argv", argv) - monkeypatch.setattr(mod, "_run_with_startup_check", - MagicMock(side_effect=[(1, False), (1, False), (1, False)])) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (1, False), (1, False)])) monkeypatch.setattr(mod, "_send_bounce", MagicMock()) monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) - monkeypatch.setattr(mod, "time", MagicMock( - time=time.time, strftime=time.strftime, sleep=MagicMock(), - )) + monkeypatch.setattr( + mod, + "time", + MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + ), + ) monkeypatch.setattr( "aipass.ai_mail.apps.handlers.paths.find_repo_root", MagicMock(return_value=Path("/fake/repo")), diff --git a/src/aipass/ai_mail/tests/test_dispatch_status.py b/src/aipass/ai_mail/tests/test_dispatch_status.py index 1693355d..72f36e8b 100644 --- a/src/aipass/ai_mail/tests/test_dispatch_status.py +++ b/src/aipass/ai_mail/tests/test_dispatch_status.py @@ -111,8 +111,7 @@ def test_save_dispatch_log(dispatch_log_file): def test_save_dispatch_log_truncates_to_50(dispatch_log_file): """Saving 60 entries keeps only the last 50.""" entries = [ - {"branch": f"@branch_{i}", "pid": i, "status": "spawned", "timestamp": "2026-03-24 12:00:00"} - for i in range(60) + {"branch": f"@branch_{i}", "pid": i, "status": "spawned", "timestamp": "2026-03-24 12:00:00"} for i in range(60) ] result = save_dispatch_log(entries) diff --git a/src/aipass/ai_mail/tests/test_identity.py b/src/aipass/ai_mail/tests/test_identity.py index 2ad57f8c..4abfe3ab 100644 --- a/src/aipass/ai_mail/tests/test_identity.py +++ b/src/aipass/ai_mail/tests/test_identity.py @@ -23,6 +23,7 @@ from aipass.ai_mail.apps.handlers.email.identity import ( # ---- Fixtures ------------------------------------------------ + @pytest.fixture(autouse=True) def _silence_json_handler(): """Prevent log_operation from writing real JSON files during tests.""" @@ -33,6 +34,7 @@ def _silence_json_handler(): # ---- create_identity() tests -------------------------------- + def test_create_identity_writes_file(tmp_path): """create_identity writes identity.json with correct fields.""" ok = create_identity(tmp_path, "devpulse", "AIPass") @@ -83,6 +85,7 @@ def test_create_identity_overwrites_existing(tmp_path): # ---- read_identity() tests ---------------------------------- + def test_read_identity_reads_back(tmp_path): """read_identity returns the dict written by create_identity.""" create_identity(tmp_path, "devpulse", "AIPass") diff --git a/src/aipass/ai_mail/tests/test_json_handler.py b/src/aipass/ai_mail/tests/test_json_handler.py index daf42969..cd0a46b4 100644 --- a/src/aipass/ai_mail/tests/test_json_handler.py +++ b/src/aipass/ai_mail/tests/test_json_handler.py @@ -48,11 +48,7 @@ def template_dir(tmp_path): @pytest.fixture def config_template(template_dir, monkeypatch): """Write a config template and point JSON_TEMPLATES_DIR at it.""" - tpl = { - "module_name": "{{MODULE_NAME}}", - "version": "1.0.0", - "config": {"max_log_entries": 50} - } + tpl = {"module_name": "{{MODULE_NAME}}", "version": "1.0.0", "config": {"max_log_entries": 50}} tpl_file = template_dir / "config.json" tpl_file.write_text(json.dumps(tpl)) monkeypatch.setattr(jh_mod, "JSON_TEMPLATES_DIR", template_dir.parent) diff --git a/src/aipass/ai_mail/tests/test_notify.py b/src/aipass/ai_mail/tests/test_notify.py index 80b44cc2..31e9b30e 100644 --- a/src/aipass/ai_mail/tests/test_notify.py +++ b/src/aipass/ai_mail/tests/test_notify.py @@ -126,6 +126,7 @@ def test_send_via_dbus_returns_false_on_nonzero_exit(monkeypatch): def test_send_via_dbus_returns_false_on_subprocess_error(monkeypatch): """Returns False on SubprocessError.""" + def raise_error(*a, **kw): raise subprocess.SubprocessError("timeout") @@ -137,6 +138,7 @@ def test_send_via_dbus_returns_false_on_subprocess_error(monkeypatch): def test_send_via_dbus_returns_false_on_file_not_found(monkeypatch): """Returns False when python binary not found.""" + def raise_error(*a, **kw): raise FileNotFoundError("python3") @@ -178,6 +180,7 @@ def test_send_via_notify_send_passes_correct_args(monkeypatch): def test_send_via_notify_send_returns_false_on_file_not_found(monkeypatch): """Returns False when notify-send is not installed.""" + def raise_error(*a, **kw): raise FileNotFoundError("notify-send") @@ -188,6 +191,7 @@ def test_send_via_notify_send_returns_false_on_file_not_found(monkeypatch): def test_send_via_notify_send_returns_false_on_subprocess_error(monkeypatch): """Returns False on SubprocessError.""" + def raise_error(*a, **kw): raise subprocess.SubprocessError("broken pipe") diff --git a/src/aipass/ai_mail/tests/test_registry_read.py b/src/aipass/ai_mail/tests/test_registry_read.py index 0cbbbd14..89fbf302 100644 --- a/src/aipass/ai_mail/tests/test_registry_read.py +++ b/src/aipass/ai_mail/tests/test_registry_read.py @@ -160,11 +160,7 @@ class TestGetCallerProjectBranches: """Returns email->path mapping from a *_REGISTRY.json in caller_cwd.""" branch_path = tmp_path / "src" / "strategy" branch_path.mkdir(parents=True) - registry = { - "branches": [ - {"name": "STRATEGY", "email": "@strategy", "path": str(branch_path)} - ] - } + registry = {"branches": [{"name": "STRATEGY", "email": "@strategy", "path": str(branch_path)}]} (tmp_path / "VERA_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8") result = get_caller_project_branches(str(tmp_path)) assert result == {"@strategy": str(branch_path)} @@ -173,11 +169,7 @@ class TestGetCallerProjectBranches: """Walks up from caller_cwd to find registry in parent.""" branch_path = tmp_path / "src" / "strategy" branch_path.mkdir(parents=True) - registry = { - "branches": [ - {"name": "STRATEGY", "email": "@strategy", "path": str(branch_path)} - ] - } + registry = {"branches": [{"name": "STRATEGY", "email": "@strategy", "path": str(branch_path)}]} (tmp_path / "VERA_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8") subdir = tmp_path / "src" / "strategy" / "apps" subdir.mkdir(parents=True) @@ -188,11 +180,7 @@ class TestGetCallerProjectBranches: """Resolves relative paths in registry relative to the registry file.""" branch_path = tmp_path / "src" / "strategy" branch_path.mkdir(parents=True) - registry = { - "branches": [ - {"name": "STRATEGY", "email": "@strategy", "path": "src/strategy"} - ] - } + registry = {"branches": [{"name": "STRATEGY", "email": "@strategy", "path": "src/strategy"}]} (tmp_path / "VERA_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8") result = get_caller_project_branches(str(tmp_path)) assert result == {"@strategy": str(branch_path)} @@ -201,11 +189,7 @@ class TestGetCallerProjectBranches: """Handles dict-format branches (AIPass format).""" branch_path = tmp_path / "src" / "quality" branch_path.mkdir(parents=True) - registry = { - "branches": { - "quality": {"email": "@quality", "path": str(branch_path)} - } - } + registry = {"branches": {"quality": {"email": "@quality", "path": str(branch_path)}}} (tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8") result = get_caller_project_branches(str(tmp_path)) assert result == {"@quality": str(branch_path)} diff --git a/src/aipass/ai_mail/tests/test_send_identity.py b/src/aipass/ai_mail/tests/test_send_identity.py index d3e0ecc8..49a33de2 100755 --- a/src/aipass/ai_mail/tests/test_send_identity.py +++ b/src/aipass/ai_mail/tests/test_send_identity.py @@ -56,12 +56,16 @@ def temp_branch(tmp_path): branch_dir = tmp_path / "src" / "aipass" / "test_branch" trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "passport.json").write_text(json.dumps({ - "branch_info": { - "branch_name": "test_branch", - "branch_email": "@test_branch", - } - })) + (trinity / "passport.json").write_text( + json.dumps( + { + "branch_info": { + "branch_name": "test_branch", + "branch_email": "@test_branch", + } + } + ) + ) return branch_dir @@ -73,21 +77,29 @@ def two_branches(tmp_path): branch_dir = tmp_path / "src" / "aipass" / name trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "passport.json").write_text(json.dumps({ - "branch_info": {"branch_name": name, "branch_email": f"@{name}"} - })) + (trinity / "passport.json").write_text( + json.dumps({"branch_info": {"branch_name": name, "branch_email": f"@{name}"}}) + ) branches[name] = branch_dir - registry = {"branches": { - "alpha": { - "name": "ALPHA", "path": str(branches["alpha"]), - "email": "@alpha", "status": "active", "description": "Test A", - }, - "beta": { - "name": "BETA", "path": str(branches["beta"]), - "email": "@beta", "status": "active", "description": "Test B", - }, - }} + registry = { + "branches": { + "alpha": { + "name": "ALPHA", + "path": str(branches["alpha"]), + "email": "@alpha", + "status": "active", + "description": "Test A", + }, + "beta": { + "name": "BETA", + "path": str(branches["beta"]), + "email": "@beta", + "status": "active", + "description": "Test B", + }, + } + } registry_path = tmp_path / "AIPASS_REGISTRY.json" registry_path.write_text(json.dumps(registry, indent=2)) return branches, registry_path @@ -140,30 +152,36 @@ def list_format_registry(tmp_path): branch_dir = tmp_path / "src" / "aipass" / "test_cwd_branch" trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "passport.json").write_text(json.dumps({ - "branch_info": { - "branch_name": "test_cwd_branch", - "branch_email": "@test_cwd_branch", - } - })) + (trinity / "passport.json").write_text( + json.dumps( + { + "branch_info": { + "branch_name": "test_cwd_branch", + "branch_email": "@test_cwd_branch", + } + } + ) + ) # List format — matches production AIPASS_REGISTRY.json - registry = {"branches": [ - { - "name": "TEST_CWD_BRANCH", - "path": str(branch_dir), - "email": "@test_cwd_branch", - "status": "active", - "description": "CWD detection test branch", - }, - { - "name": "SPAWN", - "path": str(tmp_path / "src" / "aipass" / "spawn"), - "email": "@spawn", - "status": "active", - "description": "Mock spawn for identity testing", - }, - ]} + registry = { + "branches": [ + { + "name": "TEST_CWD_BRANCH", + "path": str(branch_dir), + "email": "@test_cwd_branch", + "status": "active", + "description": "CWD detection test branch", + }, + { + "name": "SPAWN", + "path": str(tmp_path / "src" / "aipass" / "spawn"), + "email": "@spawn", + "status": "active", + "description": "Mock spawn for identity testing", + }, + ] + } registry_path = tmp_path / "AIPASS_REGISTRY.json" registry_path.write_text(json.dumps(registry, indent=2)) return branch_dir, registry_path @@ -407,9 +425,7 @@ class TestSendArgsFromFlag: def test_from_flag_with_dispatch(self): """--from and --dispatch should both work together.""" - result = parse_send_args([ - "@ai_mail", "Subject", "Body", "--from", "@backup", "--dispatch" - ]) + result = parse_send_args(["@ai_mail", "Subject", "Body", "--from", "@backup", "--dispatch"]) assert result["from_branch"] == "@backup" assert result["auto_execute"] is True @@ -432,6 +448,7 @@ class TestResolveSenderInfo: def test_explicit_from_branch_resolves(self, tmp_path): """Explicit from_branch should use registry lookup, not CWD.""" + def mock_get_branch_by_email(email): if email == "@spawn": return { @@ -522,16 +539,10 @@ class TestDispatchEnvIsolation: @staticmethod def _load_active_source(): """Load dispatch_monitor.py source with comment lines filtered out.""" - monitor_path = ( - Path(__file__).resolve().parents[1] - / "apps" / "handlers" / "dispatch" / "dispatch_monitor.py" - ) + monitor_path = Path(__file__).resolve().parents[1] / "apps" / "handlers" / "dispatch" / "dispatch_monitor.py" source = monitor_path.read_text() - active_lines = [ - line for line in source.splitlines() - if not line.strip().startswith('#') - ] - return '\n'.join(active_lines) + active_lines = [line for line in source.splitlines() if not line.strip().startswith("#")] + return "\n".join(active_lines) def test_dispatch_monitor_sets_branch_name_in_env(self): """dispatch_monitor.py must set AIPASS_BRANCH_NAME from branch_email. @@ -540,8 +551,9 @@ class TestDispatchEnvIsolation: Comment lines are filtered out — a commented-out line won't pass. """ active_source = self._load_active_source() - assert 'spawn_env["AIPASS_BRANCH_NAME"] = branch_email.lstrip("@")' in active_source, \ + assert 'spawn_env["AIPASS_BRANCH_NAME"] = branch_email.lstrip("@")' in active_source, ( "dispatch_monitor.py must set AIPASS_BRANCH_NAME = branch_email.lstrip('@') in spawn_env" + ) def test_dispatch_monitor_strips_caller_vars(self): """dispatch_monitor.py must strip AIPASS_CALLER_BRANCH and AIPASS_CALLER_CWD. @@ -550,10 +562,12 @@ class TestDispatchEnvIsolation: Checks full pop() calls including the None default. """ active_source = self._load_active_source() - assert 'spawn_env.pop("AIPASS_CALLER_BRANCH", None)' in active_source, \ + assert 'spawn_env.pop("AIPASS_CALLER_BRANCH", None)' in active_source, ( "dispatch_monitor.py must strip AIPASS_CALLER_BRANCH from spawn_env" - assert 'spawn_env.pop("AIPASS_CALLER_CWD", None)' in active_source, \ + ) + assert 'spawn_env.pop("AIPASS_CALLER_CWD", None)' in active_source, ( "dispatch_monitor.py must strip AIPASS_CALLER_CWD from spawn_env" + ) def test_dispatch_monitor_passes_spawn_env_to_subprocess(self): """dispatch_monitor.py must pass env=spawn_env to subprocess.run. @@ -562,8 +576,7 @@ class TestDispatchEnvIsolation: would inherit os.environ instead of the cleaned spawn_env. """ active_source = self._load_active_source() - assert 'env=spawn_env' in active_source, \ - "dispatch_monitor.py must pass env=spawn_env to subprocess.run" + assert "env=spawn_env" in active_source, "dispatch_monitor.py must pass env=spawn_env to subprocess.run" def test_detect_resolves_identity_when_cwd_is_wrong(self, clean_env, tmp_path, list_format_registry): """When AIPASS_CALLER_BRANCH is set but CWD is outside any branch, @@ -598,8 +611,9 @@ class TestAntiRegression: with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", registry_path): os.environ["AIPASS_CALLER_CWD"] = str(tmp_path) result = detect_branch_from_pwd() - assert result is None, \ + assert result is None, ( "With CWD outside any branch, detection must return None, not silently detect a branch" + ) def test_invalid_caller_cwd_doesnt_crash(self, clean_env, tmp_path, temp_registry): """Stale or nonexistent AIPASS_CALLER_CWD should return None, not crash.""" @@ -620,18 +634,25 @@ class TestAntiRegression: os.environ["AIPASS_CALLER_CWD"] = str(branch_dir) result = detect_branch_from_pwd() assert result is not None, "Empty AIPASS_CALLER_BRANCH should fall through to CWD" - assert result["email"] == "@test_cwd_branch", \ + assert result["email"] == "@test_cwd_branch", ( "CWD points to test_cwd_branch, so after fallthrough, it should be detected" + ) def test_send_args_all_flags_combined(self): """All flags together should parse without interference.""" - result = parse_send_args([ - "@spawn", "Subject", "Body", - "--from", "@backup", - "--dispatch", - "--no-memory-save", - "--reply-to", "@flow", - ]) + result = parse_send_args( + [ + "@spawn", + "Subject", + "Body", + "--from", + "@backup", + "--dispatch", + "--no-memory-save", + "--reply-to", + "@flow", + ] + ) assert result["from_branch"] == "@backup" assert result["auto_execute"] is True assert result["no_memory_save"] is True @@ -663,8 +684,10 @@ class TestFindCallerRegistry: registry.write_text('{"branches": []}', encoding="utf-8") os.environ["AIPASS_CALLER_CWD"] = str(tmp_path) - with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", - tmp_path / "other" / "AIPASS_REGISTRY.json"): + with patch( + "aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", + tmp_path / "other" / "AIPASS_REGISTRY.json", + ): result = _find_caller_registry() assert result == registry @@ -677,8 +700,10 @@ class TestFindCallerRegistry: nested.mkdir(parents=True) os.environ["AIPASS_CALLER_CWD"] = str(nested) - with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", - tmp_path / "other" / "AIPASS_REGISTRY.json"): + with patch( + "aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", + tmp_path / "other" / "AIPASS_REGISTRY.json", + ): result = _find_caller_registry() assert result == registry @@ -704,9 +729,12 @@ class TestCallerRegistryFallback: def test_lookup_by_name_falls_back_to_caller_registry(self, clean_env, tmp_path): """_lookup_branch_by_name finds external branch via caller registry.""" caller_registry = tmp_path / "AIPASS_REGISTRY.json" - caller_registry.write_text(json.dumps({"branches": [ - {"name": "VERA", "path": str(tmp_path / "vera"), "email": "@vera", "status": "active"} - ]}), encoding="utf-8") + caller_registry.write_text( + json.dumps( + {"branches": [{"name": "VERA", "path": str(tmp_path / "vera"), "email": "@vera", "status": "active"}]} + ), + encoding="utf-8", + ) empty_aipass = tmp_path / "other" / "AIPASS_REGISTRY.json" empty_aipass.parent.mkdir(parents=True) @@ -723,16 +751,35 @@ class TestCallerRegistryFallback: def test_lookup_by_name_prefers_aipass_registry(self, clean_env, tmp_path): """_lookup_branch_by_name returns AIPass result first when branch exists in both.""" aipass_registry = tmp_path / "AIPASS_REGISTRY.json" - aipass_registry.write_text(json.dumps({"branches": [ - {"name": "SPAWN", "path": str(tmp_path / "spawn"), "email": "@spawn-aipass", "status": "active"} - ]}), encoding="utf-8") + aipass_registry.write_text( + json.dumps( + { + "branches": [ + {"name": "SPAWN", "path": str(tmp_path / "spawn"), "email": "@spawn-aipass", "status": "active"} + ] + } + ), + encoding="utf-8", + ) caller_dir = tmp_path / "external" caller_dir.mkdir() caller_registry = caller_dir / "AIPASS_REGISTRY.json" - caller_registry.write_text(json.dumps({"branches": [ - {"name": "SPAWN", "path": str(tmp_path / "other_spawn"), "email": "@spawn-external", "status": "active"} - ]}), encoding="utf-8") + caller_registry.write_text( + json.dumps( + { + "branches": [ + { + "name": "SPAWN", + "path": str(tmp_path / "other_spawn"), + "email": "@spawn-external", + "status": "active", + } + ] + } + ), + encoding="utf-8", + ) os.environ["AIPASS_CALLER_CWD"] = str(caller_dir) with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", aipass_registry): @@ -747,9 +794,10 @@ class TestCallerRegistryFallback: vera_dir.mkdir(parents=True) caller_registry = tmp_path / "vera_studio" / "AIPASS_REGISTRY.json" - caller_registry.write_text(json.dumps({"branches": [ - {"name": "VERA", "path": str(vera_dir), "email": "@vera", "status": "active"} - ]}), encoding="utf-8") + caller_registry.write_text( + json.dumps({"branches": [{"name": "VERA", "path": str(vera_dir), "email": "@vera", "status": "active"}]}), + encoding="utf-8", + ) empty_aipass = tmp_path / "AIPASS_REGISTRY.json" empty_aipass.write_text('{"branches": []}', encoding="utf-8") diff --git a/src/aipass/ai_mail/tests/test_user_paths.py b/src/aipass/ai_mail/tests/test_user_paths.py index 2aa31343..bf222dfe 100644 --- a/src/aipass/ai_mail/tests/test_user_paths.py +++ b/src/aipass/ai_mail/tests/test_user_paths.py @@ -46,29 +46,31 @@ def relative_path_registry(tmp_path): Returns (registry_path, expected_repo_root). """ - registry = {"branches": [ - { - "name": "AI_MAIL", - "path": "src/aipass/ai_mail", - "email": "@ai_mail", - "status": "active", - "description": "Agent-to-agent messaging system", - }, - { - "name": "SPAWN", - "path": "src/aipass/spawn", - "email": "@spawn", - "status": "active", - "description": "Branch spawner", - }, - { - "name": "TRIGGER", - "path": "src/aipass/trigger", - "email": "@trigger", - "status": "active", - "description": "Event trigger system", - }, - ]} + registry = { + "branches": [ + { + "name": "AI_MAIL", + "path": "src/aipass/ai_mail", + "email": "@ai_mail", + "status": "active", + "description": "Agent-to-agent messaging system", + }, + { + "name": "SPAWN", + "path": "src/aipass/spawn", + "email": "@spawn", + "status": "active", + "description": "Branch spawner", + }, + { + "name": "TRIGGER", + "path": "src/aipass/trigger", + "email": "@trigger", + "status": "active", + "description": "Event trigger system", + }, + ] + } registry_path = tmp_path / "AIPASS_REGISTRY.json" registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8") return registry_path, tmp_path @@ -82,15 +84,17 @@ def absolute_path_registry(tmp_path): Returns (registry_path, branch_dir). """ branch_dir = tmp_path / "src" / "aipass" / "solo_branch" - registry = {"branches": [ - { - "name": "SOLO", - "path": str(branch_dir), - "email": "@solo", - "status": "active", - "description": "Branch with absolute path", - }, - ]} + registry = { + "branches": [ + { + "name": "SOLO", + "path": str(branch_dir), + "email": "@solo", + "status": "active", + "description": "Branch with absolute path", + }, + ] + } registry_path = tmp_path / "AIPASS_REGISTRY.json" registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8") return registry_path, branch_dir @@ -103,22 +107,24 @@ def dict_format_registry(tmp_path): Tests that the dict->list normalization via _get_branches_list still produces absolute paths. """ - registry = {"branches": { - "devpulse": { - "name": "DEVPULSE", - "path": "src/aipass/devpulse", - "email": "@devpulse", - "status": "active", - "description": "DevPulse branch", - }, - "backup": { - "name": "BACKUP", - "path": "src/aipass/backup", - "email": "@backup", - "status": "active", - "description": "Backup branch", - }, - }} + registry = { + "branches": { + "devpulse": { + "name": "DEVPULSE", + "path": "src/aipass/devpulse", + "email": "@devpulse", + "status": "active", + "description": "DevPulse branch", + }, + "backup": { + "name": "BACKUP", + "path": "src/aipass/backup", + "email": "@backup", + "status": "active", + "description": "Backup branch", + }, + } + } registry_path = tmp_path / "AIPASS_REGISTRY.json" registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8") return registry_path, tmp_path @@ -137,9 +143,7 @@ class TestGetUserByEmailPaths: result = get_user_by_email("@ai_mail") assert result is not None mailbox = Path(result["mailbox_path"]) - assert mailbox.is_absolute(), ( - f"mailbox_path must be absolute, got: {result['mailbox_path']}" - ) + assert mailbox.is_absolute(), f"mailbox_path must be absolute, got: {result['mailbox_path']}" def test_path_rooted_at_repo_root(self, relative_path_registry): """Resolved path should start from the repo root (registry parent).""" @@ -158,9 +162,7 @@ class TestGetUserByEmailPaths: assert result is not None path = result["mailbox_path"] # Count occurrences of the relative segment - assert path.count("src/aipass/trigger") == 1, ( - f"Path contains doubled segment: {path}" - ) + assert path.count("src/aipass/trigger") == 1, f"Path contains doubled segment: {path}" def test_absolute_path_preserved(self, absolute_path_registry): """Registry entries with absolute paths should not be re-rooted.""" @@ -185,9 +187,7 @@ class TestGetUserByEmailPaths: result = get_user_by_email("@devpulse") assert result is not None mailbox = Path(result["mailbox_path"]) - assert mailbox.is_absolute(), ( - f"mailbox_path must be absolute (dict format), got: {result['mailbox_path']}" - ) + assert mailbox.is_absolute(), f"mailbox_path must be absolute (dict format), got: {result['mailbox_path']}" expected = str((repo_root / "src" / "aipass" / "devpulse" / ".ai_mail.local").resolve()) assert result["mailbox_path"] == expected @@ -206,9 +206,7 @@ class TestGetAllUsersPaths: assert len(users) == 3, f"Expected 3 users, got {len(users)}" for email, info in users.items(): mailbox = Path(info["mailbox_path"]) - assert mailbox.is_absolute(), ( - f"mailbox_path for {email} must be absolute, got: {info['mailbox_path']}" - ) + assert mailbox.is_absolute(), f"mailbox_path for {email} must be absolute, got: {info['mailbox_path']}" def test_all_paths_end_with_ai_mail_local(self, relative_path_registry): """Every mailbox_path should end with .ai_mail.local.""" @@ -217,8 +215,7 @@ class TestGetAllUsersPaths: users = get_all_users() for email, info in users.items(): assert info["mailbox_path"].endswith(".ai_mail.local"), ( - f"mailbox_path for {email} should end with .ai_mail.local, " - f"got: {info['mailbox_path']}" + f"mailbox_path for {email} should end with .ai_mail.local, got: {info['mailbox_path']}" ) def test_no_doubled_paths_in_any_entry(self, relative_path_registry): @@ -229,9 +226,7 @@ class TestGetAllUsersPaths: for email, info in users.items(): path = info["mailbox_path"] # The relative prefix "src/aipass" should appear exactly once - assert path.count("src/aipass") == 1, ( - f"Path for {email} contains doubled 'src/aipass': {path}" - ) + assert path.count("src/aipass") == 1, f"Path for {email} contains doubled 'src/aipass': {path}" def test_paths_resolve_against_repo_root(self, relative_path_registry): """Resolved paths should be rooted at the registry's parent dir.""" @@ -240,8 +235,7 @@ class TestGetAllUsersPaths: users = get_all_users() for email, info in users.items(): assert info["mailbox_path"].startswith(str(repo_root)), ( - f"Path for {email} should start with repo root {repo_root}, " - f"got: {info['mailbox_path']}" + f"Path for {email} should start with repo root {repo_root}, got: {info['mailbox_path']}" ) def test_absolute_paths_preserved(self, absolute_path_registry): @@ -262,8 +256,7 @@ class TestGetAllUsersPaths: for email, info in users.items(): mailbox = Path(info["mailbox_path"]) assert mailbox.is_absolute(), ( - f"mailbox_path for {email} must be absolute (dict format), " - f"got: {info['mailbox_path']}" + f"mailbox_path for {email} must be absolute (dict format), got: {info['mailbox_path']}" ) def test_empty_registry_returns_empty_dict(self, tmp_path): diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index 79d7d5d5..c3a0d446 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -371,6 +371,7 @@ def test_resolve_branch_strips_leading_at(repo_root): def test_clean_zombies_finds_zombie(monkeypatch): """Detects zombie claude processes from ps output.""" + class FakeResult: stdout = " 100 Z+ claude\n 200 Ss claude\n 300 Z claude\n" returncode = 0 @@ -385,6 +386,7 @@ def test_clean_zombies_finds_zombie(monkeypatch): def test_clean_zombies_none_found(monkeypatch): """Returns 0 when no zombie processes exist.""" + class FakeResult: stdout = " PID STAT COMM\n 200 Ss claude\n" returncode = 0 @@ -422,16 +424,19 @@ def _raise_permission(pid, sig): def _raise_subprocess_error(*args, **kwargs): import subprocess + raise subprocess.SubprocessError("failed") def _fake_open_factory(real_status_path, mapping): """Return an open() replacement that redirects /proc paths to real files.""" + def _fake_open(path, *args, **kwargs): path_str = str(path) if path_str in mapping: return _real_open(mapping[path_str], *args, **kwargs) return _real_open(path, *args, **kwargs) + return _fake_open @@ -517,11 +522,7 @@ class TestResolveBranchCallerRegistry: (branch_path / ".ai_mail.local").mkdir() # External registry - registry = { - "branches": [ - {"name": "STRATEGY", "email": "@strategy", "path": str(branch_path)} - ] - } + registry = {"branches": [{"name": "STRATEGY", "email": "@strategy", "path": str(branch_path)}]} (tmp_path / "VERA_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8") # AIPass registry has no @strategy @@ -542,9 +543,9 @@ class TestResolveBranchCallerRegistry: branch_path.mkdir(parents=True) aipass_registry = tmp_path / "AIPASS_REGISTRY.json" - aipass_registry.write_text(json.dumps({ - "branches": [{"name": "DRONE", "email": "@drone", "path": str(branch_path)}] - }), encoding="utf-8") + aipass_registry.write_text( + json.dumps({"branches": [{"name": "DRONE", "email": "@drone", "path": str(branch_path)}]}), encoding="utf-8" + ) monkeypatch.setattr(wake_mod, "_REPO_ROOT", tmp_path) monkeypatch.setattr(wake_mod, "BRANCH_REGISTRY", aipass_registry) monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False) @@ -576,9 +577,11 @@ class TestWakeBranchSpawnEnv: (branch_path / ".ai_mail.local").mkdir() registry_file = tmp_path / "AIPASS_REGISTRY.json" import json - registry_file.write_text(json.dumps({ - "branches": [{"name": "TESTBRANCH", "email": "@testbranch", "path": str(branch_path)}] - }), encoding="utf-8") + + registry_file.write_text( + json.dumps({"branches": [{"name": "TESTBRANCH", "email": "@testbranch", "path": str(branch_path)}]}), + encoding="utf-8", + ) monkeypatch.setattr(wake_mod, "_REPO_ROOT", tmp_path) monkeypatch.setattr(wake_mod, "BRANCH_REGISTRY", registry_file) @@ -589,6 +592,7 @@ class TestWakeBranchSpawnEnv: # Strip ~/.local/bin from os.environ to simulate restricted PATH from pathlib import Path as _Path + local_bin = str(_Path.home() / ".local" / "bin") monkeypatch.setenv("PATH", "/usr/bin:/bin") @@ -597,8 +601,10 @@ class TestWakeBranchSpawnEnv: def fake_popen(cmd, **kwargs): """Capture spawn_env without launching a real process.""" captured_envs.append(kwargs.get("env", {})) + class FakeProc: pid = 99999 + return FakeProc() monkeypatch.setattr("subprocess.Popen", fake_popen) @@ -617,6 +623,4 @@ class TestWakeBranchSpawnEnv: assert captured_envs, "Popen was not called" env = captured_envs[0] - assert local_bin in env.get("PATH", ""), ( - f"~/.local/bin not in spawn_env PATH: {env.get('PATH', '')}" - ) + assert local_bin in env.get("PATH", ""), f"~/.local/bin not in spawn_env PATH: {env.get('PATH', '')}" diff --git a/src/aipass/api/apps/api.py b/src/aipass/api/apps/api.py index 53ae11ed..703085a9 100644 --- a/src/aipass/api/apps/api.py +++ b/src/aipass/api/apps/api.py @@ -47,6 +47,7 @@ MODULES_DIR = MODULE_ROOT / "modules" # MODULE DISCOVERY # ============================================================================= + def discover_modules() -> List[Any]: """ Auto-discover modules from modules/ directory @@ -74,7 +75,7 @@ def discover_modules() -> List[Any]: module = importlib.import_module(f"aipass.api.apps.modules.{module_name}") # Check for required interface - if hasattr(module, 'handle_command'): + if hasattr(module, "handle_command"): modules.append(module) logger.info(f" [+] {module_name}") else: @@ -86,10 +87,12 @@ def discover_modules() -> List[Any]: logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules") return modules + # ============================================================================= # INTROSPECTION DISPLAY # ============================================================================= + def print_introspection(): """Display discovered modules and available commands""" console.print() @@ -110,7 +113,7 @@ def print_introspection(): console.print() for module in modules: - module_name = module.__name__.split('.')[-1] + module_name = module.__name__.split(".")[-1] console.print(f" [cyan]•[/cyan] {module_name}") console.print() @@ -122,6 +125,7 @@ def print_introspection(): # DRONE COMPLIANCE - HELP SYSTEM # ============================================================================= + def print_help(): """Display Rich-formatted help""" @@ -178,7 +182,7 @@ def print_help(): usage_examples = [ "[yellow]Quick Commands:[/yellow]\n [dim]drone @api get-key[/dim]\n [dim]drone @api validate[/dim]", "[yellow]Testing:[/yellow]\n [dim]drone @api test[/dim]\n [dim]drone @api models[/dim]", - "[yellow]Analytics:[/yellow]\n [dim]drone @api track[/dim]\n [dim]drone @api stats[/dim]" + "[yellow]Analytics:[/yellow]\n [dim]drone @api track[/dim]\n [dim]drone @api stats[/dim]", ] console.print(Columns(usage_examples, equal=True, expand=True)) @@ -200,7 +204,9 @@ def print_help(): console.print("─" * 70) console.print() - console.print("[dim]Commands: get-key, validate, test, models, status, call, list-providers, init, track, stats, session, caller-usage, cleanup[/dim]") + console.print( + "[dim]Commands: get-key, validate, test, models, status, call, list-providers, init, track, stats, session, caller-usage, cleanup[/dim]" + ) console.print() @@ -208,6 +214,7 @@ def print_help(): # COMMAND ROUTING # ============================================================================= + def route_command(command: str, args: List[str], modules: List[Any]) -> bool: """ Route command to appropriate module @@ -229,10 +236,12 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: return False + # ============================================================================= # MAIN # ============================================================================= + def main(): """Main entry point - routes commands to modules""" try: @@ -246,12 +255,12 @@ def main(): return 0 # Show version - if args[0] in ['--version', '-V']: + if args[0] in ["--version", "-V"]: console.print("API v1.0.0") return 0 # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: + if args[0] in ["--help", "-h", "help"]: print_help() json_handler.log_operation("api_help_displayed", {"trigger": args[0]}) return 0 @@ -269,10 +278,7 @@ def main(): remaining_args = args[1:] if len(args) > 1 else [] # Log api command attempt - json_handler.log_operation( - "api_command_attempted", - {"command": command, "modules_discovered": len(modules)} - ) + json_handler.log_operation("api_command_attempted", {"command": command, "modules_discovered": len(modules)}) # Route command to modules if route_command(command, remaining_args, modules): @@ -286,5 +292,6 @@ def main(): logger.error("[api] Unhandled error in main: %s", exc) return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/src/aipass/api/apps/handlers/__init__.py b/src/aipass/api/apps/handlers/__init__.py index b8582eab..4499b94b 100644 --- a/src/aipass/api/apps/handlers/__init__.py +++ b/src/aipass/api/apps/handlers/__init__.py @@ -41,8 +41,10 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() import os + if os.environ.get("AIPASS_DEBUG_GUARD"): from aipass.prax import logger as _guard_logger + _guard_logger.info(f"[GUARD] caller_file = {caller_file}") _guard_logger.info(f"[GUARD] import_line = {import_line}") @@ -61,16 +63,16 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n\n" f" Handlers are internal to their branch.\n" f" Use the module API instead:\n" f" from {MY_BRANCH}.apps.modules. import \n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/api/apps/handlers/auth/__init__.py b/src/aipass/api/apps/handlers/auth/__init__.py index 9d77ffe2..800294fe 100644 --- a/src/aipass/api/apps/handlers/auth/__init__.py +++ b/src/aipass/api/apps/handlers/auth/__init__.py @@ -4,4 +4,5 @@ Authentication Domain Handlers for API key management, validation, and credential storage. Includes .env file operations and provider authentication. """ + __version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/auth/env.py b/src/aipass/api/apps/handlers/auth/env.py index 75000e86..22843304 100644 --- a/src/aipass/api/apps/handlers/auth/env.py +++ b/src/aipass/api/apps/handlers/auth/env.py @@ -33,6 +33,7 @@ from aipass.api.apps.handlers.json import json_handler # ENV FILE CREATION # ============================================== + def create_env_template(provider: str = "openrouter", target_path: Optional[Path] = None) -> bool: """ Create .env template file with default placeholders. @@ -90,7 +91,7 @@ OPENAI_API_KEY=sk-your-openai-key-here os.chmod(env_path.parent, 0o700) # Write template - with open(env_path, 'w', encoding='utf-8') as f: + with open(env_path, "w", encoding="utf-8") as f: f.write(env_template) # Restrict file permissions to owner-read/write only @@ -105,5 +106,3 @@ OPENAI_API_KEY=sk-your-openai-key-here # Failed to create .env template logger.error(f"Failed to create .env template: {e}") return False - - diff --git a/src/aipass/api/apps/handlers/auth/keys.py b/src/aipass/api/apps/handlers/auth/keys.py index e1264e84..7196d22c 100644 --- a/src/aipass/api/apps/handlers/auth/keys.py +++ b/src/aipass/api/apps/handlers/auth/keys.py @@ -40,22 +40,11 @@ API_JSON_DIR = API_ROOT / "api_json" # Provider validation rules (embedded - no config dependency for core validation) VALIDATION_RULES = { - "openrouter": { - "prefix": "sk-or-v1-", - "min_length": 40 - }, - "openai": { - "prefix": "sk-", - "min_length": 40 - }, - "anthropic": { - "prefix": "sk-ant-", - "min_length": 40 - }, + "openrouter": {"prefix": "sk-or-v1-", "min_length": 40}, + "openai": {"prefix": "sk-", "min_length": 40}, + "anthropic": {"prefix": "sk-ant-", "min_length": 40}, # Generic fallback - "generic": { - "min_length": 10 - } + "generic": {"min_length": 10}, } @@ -63,6 +52,7 @@ VALIDATION_RULES = { # KEY RETRIEVAL # ============================================== + def get_api_key(provider: str = "openrouter") -> Optional[str]: """ Get validated API key for provider. @@ -124,13 +114,13 @@ def _read_key_from_secrets(provider: str) -> Optional[str]: try: env_var = f"{provider.upper()}_API_KEY" - with open(secrets_path, 'r', encoding='utf-8') as f: + with open(secrets_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue - if '=' in line: - key, value = line.split('=', 1) + if "=" in line: + key, value = line.split("=", 1) if key.strip() == env_var: return value.strip() return None @@ -162,7 +152,8 @@ def get_key_from_config(provider: str) -> Optional[str]: return None import json - with open(config_path, 'r', encoding='utf-8') as f: + + with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) # Navigate config structure @@ -182,11 +173,11 @@ def get_key_from_config(provider: str) -> Optional[str]: return None - # ============================================== # KEY VALIDATION # ============================================== + def validate_key(key: str, provider: str = "openrouter") -> bool: """ Validate API key format for provider. @@ -259,6 +250,7 @@ def get_validation_rules(provider: str) -> Dict[str, Any]: # KEY FORMAT CHECKING # ============================================== + def diagnose_key(provider: str = "openrouter") -> str: """ Diagnose why get_api_key() returned None. @@ -293,12 +285,10 @@ def diagnose_key(provider: str = "openrouter") -> str: rules = get_validation_rules(provider) if "prefix" in rules and not key.startswith(rules["prefix"]): - actual_prefix = key[:len(rules["prefix"])] if len(key) >= len(rules["prefix"]) else key[:6] + actual_prefix = key[: len(rules["prefix"])] if len(key) >= len(rules["prefix"]) else key[:6] return f"Key found ({source}) but invalid — expected prefix '{rules['prefix']}', got '{actual_prefix}...'" if "min_length" in rules and len(key) < rules["min_length"]: return f"Key found ({source}) but too short — {len(key)} chars, need {rules['min_length']}+" return f"Key found ({source}) but failed validation" - - diff --git a/src/aipass/api/apps/handlers/config/__init__.py b/src/aipass/api/apps/handlers/config/__init__.py index 6b424d15..5a641c6f 100644 --- a/src/aipass/api/apps/handlers/config/__init__.py +++ b/src/aipass/api/apps/handlers/config/__init__.py @@ -4,4 +4,5 @@ Configuration Domain Handlers for provider configuration management. Load, validate, and update API provider settings. """ + __version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/config/provider.py b/src/aipass/api/apps/handlers/config/provider.py index d26111f9..fcf21e3a 100644 --- a/src/aipass/api/apps/handlers/config/provider.py +++ b/src/aipass/api/apps/handlers/config/provider.py @@ -34,32 +34,22 @@ PROVIDER_DEFAULTS = { "api_key": "", "base_url": "https://openrouter.ai/api/v1", "temperature": 0.7, - "timeout_seconds": 30 + "timeout_seconds": 30, }, - "openai": { - "api_key": "", - "base_url": "https://api.openai.com/v1", - "temperature": 0.7, - "timeout_seconds": 30 - } + "openai": {"api_key": "", "base_url": "https://api.openai.com/v1", "temperature": 0.7, "timeout_seconds": 30}, } # Provider validation rules VALIDATION_RULES = { - "openrouter": { - "prefix": "sk-or-v1-", - "min_length": 40 - }, - "openai": { - "prefix": "sk-", - "min_length": 40 - } + "openrouter": {"prefix": "sk-or-v1-", "min_length": 40}, + "openai": {"prefix": "sk-", "min_length": 40}, } # ============================================= # CONFIGURATION MERGING # ============================================= + def merge_configs(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: """ Deep merge two configuration dictionaries @@ -99,6 +89,7 @@ def merge_configs(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, An # VALIDATION HELPERS # ============================================= + def get_validation_rules(provider: str) -> Optional[Dict[str, Any]]: """ Get validation rules for provider @@ -113,5 +104,3 @@ def get_validation_rules(provider: str) -> Optional[Dict[str, Any]]: if rules is None: logger.info(f"No validation rules found for provider: {provider}") return rules - - diff --git a/src/aipass/api/apps/handlers/google/__init__.py b/src/aipass/api/apps/handlers/google/__init__.py index 17362827..0a3debe4 100644 --- a/src/aipass/api/apps/handlers/google/__init__.py +++ b/src/aipass/api/apps/handlers/google/__init__.py @@ -5,6 +5,7 @@ Handlers for Google service authentication, credential management, and service object factories. Provides authenticated clients for Google APIs (Drive, Calendar, etc.) to consuming branches. """ + __version__ = "1.0.0" from . import auth as auth diff --git a/src/aipass/api/apps/handlers/google/auth.py b/src/aipass/api/apps/handlers/google/auth.py index 745c008f..6f63f9a7 100644 --- a/src/aipass/api/apps/handlers/google/auth.py +++ b/src/aipass/api/apps/handlers/google/auth.py @@ -54,6 +54,7 @@ try: from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request from google_auth_oauthlib.flow import InstalledAppFlow + GOOGLE_AUTH_AVAILABLE = True except ImportError as e: logger.warning(f"Google auth libraries not available: {e}") @@ -125,7 +126,9 @@ def refresh_credentials(creds: "Credentials") -> bool: def run_oauth_flow( - scopes: Optional[list] = None, port: int = 0, open_browser: bool = True, + scopes: Optional[list] = None, + port: int = 0, + open_browser: bool = True, ) -> Optional["Credentials"]: """Run the full OAuth2 consent flow. @@ -149,9 +152,7 @@ def run_oauth_flow( effective_scopes = scopes or DEFAULT_SCOPES["drive"] try: - flow = InstalledAppFlow.from_client_secrets_file( - str(CLIENT_SECRET_PATH), effective_scopes - ) + flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET_PATH), effective_scopes) creds = flow.run_local_server(port=port, open_browser=open_browser) _save_credentials(creds) return creds @@ -193,7 +194,9 @@ def authenticate(scopes: Optional[list] = None) -> Optional["Credentials"]: def reauth( - scopes: Optional[list] = None, port: int = 8085, open_browser: bool = False, + scopes: Optional[list] = None, + port: int = 8085, + open_browser: bool = False, ) -> Optional["Credentials"]: """Force re-authentication via OAuth flow (console mode). diff --git a/src/aipass/api/apps/handlers/google/retry.py b/src/aipass/api/apps/handlers/google/retry.py index 44aca88a..accbdf3c 100644 --- a/src/aipass/api/apps/handlers/google/retry.py +++ b/src/aipass/api/apps/handlers/google/retry.py @@ -58,7 +58,9 @@ def is_ssl_error(exc: Exception) -> bool: def api_call_with_retry( - request: Any, max_retries: int = 3, rebuild_service_fn: Optional[Callable] = None, + request: Any, + max_retries: int = 3, + rebuild_service_fn: Optional[Callable] = None, ) -> Any: """Execute a Google API request with exponential backoff on SSL errors. @@ -81,7 +83,7 @@ def api_call_with_retry( return request.execute() except Exception as e: if attempt < max_retries and is_ssl_error(e): - wait = 2 ** attempt + wait = 2**attempt json_handler.log_operation("api_retry_attempted", {"attempt": attempt + 1, "wait_seconds": wait}) time.sleep(wait) if rebuild_service_fn: diff --git a/src/aipass/api/apps/handlers/google/service_factory.py b/src/aipass/api/apps/handlers/google/service_factory.py index a2fbd94a..c72c9431 100644 --- a/src/aipass/api/apps/handlers/google/service_factory.py +++ b/src/aipass/api/apps/handlers/google/service_factory.py @@ -41,6 +41,7 @@ from aipass.api.apps.handlers.json import json_handler try: from googleapiclient.discovery import build + GOOGLE_BUILD_AVAILABLE = True except ImportError as e: logger.warning(f"Google API client library not available: {e}") @@ -54,7 +55,9 @@ except ImportError as e: def build_service( - service_name: str = "drive", version: str = "v3", scopes: Optional[list] = None, + service_name: str = "drive", + version: str = "v3", + scopes: Optional[list] = None, ) -> Optional[object]: """Build an authenticated Google API service object. @@ -85,7 +88,9 @@ def build_service( def build_thread_safe_service( - service_name: str = "drive", version: str = "v3", scopes: Optional[list] = None, + service_name: str = "drive", + version: str = "v3", + scopes: Optional[list] = None, ) -> Optional[object]: """Build an isolated service instance for use in a worker thread. diff --git a/src/aipass/api/apps/handlers/integrations/call.py b/src/aipass/api/apps/handlers/integrations/call.py index 9acf0d39..2b5f4081 100644 --- a/src/aipass/api/apps/handlers/integrations/call.py +++ b/src/aipass/api/apps/handlers/integrations/call.py @@ -12,6 +12,7 @@ Accepts a resolved driver callable and args from the calling module layer. No module imports — the module layer owns bridge access and passes the driver in. Returns a result dict; display is handled by the module layer. """ + from typing import Callable from aipass.prax import logger diff --git a/src/aipass/api/apps/handlers/integrations/list.py b/src/aipass/api/apps/handlers/integrations/list.py index 955c7aff..a26f042b 100644 --- a/src/aipass/api/apps/handlers/integrations/list.py +++ b/src/aipass/api/apps/handlers/integrations/list.py @@ -11,6 +11,7 @@ List registered integration contracts. Accepts a pre-fetched list of contract names from the calling module layer. No module imports — the module layer owns bridge access and passes data in. """ + from aipass.prax import logger from aipass.api.apps.handlers.json import json_handler diff --git a/src/aipass/api/apps/handlers/json/json_handler.py b/src/aipass/api/apps/handlers/json/json_handler.py index c158575c..b0387bd8 100755 --- a/src/aipass/api/apps/handlers/json/json_handler.py +++ b/src/aipass/api/apps/handlers/json/json_handler.py @@ -39,7 +39,7 @@ def _get_caller_module_name() -> str: module_name = caller_path.stem # Validate module name - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name # Fallback @@ -87,16 +87,16 @@ def validate_json_structure(data: Any, json_type: str) -> bool: return False required = ["module_name", "version", "config"] return all(key in data for key in required) - + elif json_type == "data": if not isinstance(data, dict): return False required = ["created", "last_updated"] return all(key in data for key in required) - + elif json_type == "log": return isinstance(data, list) - + return False @@ -109,14 +109,14 @@ def get_json_path(module_name: str, json_type: str) -> Path: def ensure_json_exists(module_name: str, json_type: str) -> bool: """Ensure JSON file exists, create from template if missing""" API_JSON_DIR.mkdir(parents=True, exist_ok=True) - + json_path = get_json_path(module_name, json_type) - + if json_path.exists(): try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) - + if validate_json_structure(data, json_type): return True else: @@ -126,7 +126,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: template = _create_default(json_type, module_name) - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(template, f, indent=2, ensure_ascii=False) return True @@ -135,11 +135,11 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: """Load JSON file, auto-create if missing""" if not ensure_json_exists(module_name, json_type): return None - + json_path = get_json_path(module_name, json_type) - + try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"Failed to load JSON from {json_path}: {e}") @@ -152,12 +152,12 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: if not validate_json_structure(data, json_type): return False - + if json_type == "data" and isinstance(data, dict): data["last_updated"] = datetime.now().date().isoformat() - + try: - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -207,10 +207,7 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam log = [] # Create new entry - entry = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } + entry = {"timestamp": datetime.now().isoformat(), "operation": operation} if data: entry["data"] = data # type: ignore[assignment] @@ -232,10 +229,7 @@ if __name__ == "__main__": console = Console() console.print() - console.print(Panel.fit( - "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", - border_style="bright_blue" - )) + console.print(Panel.fit("[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", border_style="bright_blue")) console.print() console.print("[yellow]TESTING:[/yellow] Creating API JSONs...") diff --git a/src/aipass/api/apps/handlers/openrouter/__init__.py b/src/aipass/api/apps/handlers/openrouter/__init__.py index 0b5696c2..51861390 100644 --- a/src/aipass/api/apps/handlers/openrouter/__init__.py +++ b/src/aipass/api/apps/handlers/openrouter/__init__.py @@ -4,4 +4,5 @@ OpenRouter Domain Handlers for OpenRouter LLM API client operations. Client creation, caller detection, model fetching, and config provisioning. """ + __version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/openrouter/caller.py b/src/aipass/api/apps/handlers/openrouter/caller.py index b93ed5c1..47f80c43 100644 --- a/src/aipass/api/apps/handlers/openrouter/caller.py +++ b/src/aipass/api/apps/handlers/openrouter/caller.py @@ -48,6 +48,7 @@ CALLER_PATTERNS = { # CALLER DETECTION FUNCTIONS # ============================================= + def get_caller_info() -> Optional[Dict[str, Any]]: """ Detect calling module via stack inspection. @@ -86,7 +87,7 @@ def detect_caller_from_stack() -> Tuple[Optional[str], Optional[Path]]: """ caller_info = get_caller_info() if caller_info: - return caller_info.get('caller_name'), caller_info.get('json_folder') + return caller_info.get("caller_name"), caller_info.get("json_folder") return None, None @@ -111,11 +112,12 @@ def detect_caller_category(caller_path: Path) -> str: # INTERNAL DETECTION HELPERS # ============================================= + def _detect_flow_caller(frame_path: Path) -> Dict[str, Any]: """Detect flow module caller from stack frame path.""" try: flow_index = frame_path.parts.index("flow") - flow_path = Path(*frame_path.parts[:flow_index + 1]) + flow_path = Path(*frame_path.parts[: flow_index + 1]) json_folder_path = flow_path / "flow_json" caller_name = frame_path.stem @@ -126,7 +128,7 @@ def _detect_flow_caller(frame_path: Path) -> Dict[str, Any]: "caller_path": frame_path, "json_folder": json_folder_path, "category": "flow", - "detection_method": "stack" + "detection_method": "stack", } except Exception as e: @@ -138,7 +140,7 @@ def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]: """Detect prax module caller from stack frame path.""" try: prax_index = frame_path.parts.index("prax") - prax_path = Path(*frame_path.parts[:prax_index + 1]) + prax_path = Path(*frame_path.parts[: prax_index + 1]) json_folder_path = prax_path / "prax_json" caller_name = frame_path.stem @@ -149,7 +151,7 @@ def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]: "caller_path": frame_path, "json_folder": json_folder_path, "category": "prax", - "detection_method": "stack" + "detection_method": "stack", } except Exception as e: @@ -169,7 +171,7 @@ def _create_fallback_info(frame_path: Path) -> Dict[str, Any]: "caller_path": frame_path, "json_folder": None, "category": category, - "detection_method": "fallback" + "detection_method": "fallback", } @@ -177,8 +179,10 @@ def _create_fallback_info(frame_path: Path) -> Dict[str, Any]: # MODULE INITIALIZATION # ============================================= + def _initialize(): """Initialize caller detection module.""" logger.info(f"[{MODULE_NAME}] Caller detection handler loaded (v{MODULE_VERSION})") + _initialize() diff --git a/src/aipass/api/apps/handlers/openrouter/client.py b/src/aipass/api/apps/handlers/openrouter/client.py index 639abcbf..ce44b2d5 100644 --- a/src/aipass/api/apps/handlers/openrouter/client.py +++ b/src/aipass/api/apps/handlers/openrouter/client.py @@ -45,6 +45,7 @@ from aipass.prax import logger # OpenAI SDK for OpenRouter compatibility try: from openai import OpenAI + OPENAI_AVAILABLE = True except ImportError as e: logger.error(f"OpenAI SDK not available. Install with: pip install openai: {e}") @@ -69,10 +70,7 @@ DEFAULT_TIMEOUT = 30 # NOTE: No default model - callers must specify their own model from their branch config # HTTP headers for OpenRouter -OPENROUTER_HEADERS = { - "HTTP-Referer": "https://aipass.local", - "X-Title": "AIPass API Client" -} +OPENROUTER_HEADERS = {"HTTP-Referer": "https://aipass.local", "X-Title": "AIPass API Client"} # Client cache for connection pooling _client_cache: Dict[str, OpenAI] = {} @@ -82,7 +80,10 @@ MAX_CACHED_CLIENTS = 5 # CLIENT CREATION # ============================================= -def create_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]: + +def create_client( + api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT +) -> Optional[OpenAI]: """ Create OpenAI SDK client configured for OpenRouter. @@ -110,12 +111,7 @@ def create_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: in try: # Create OpenAI client with OpenRouter configuration - client = OpenAI( - base_url=base_url, - api_key=api_key, - timeout=timeout, - default_headers=OPENROUTER_HEADERS - ) + client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout, default_headers=OPENROUTER_HEADERS) logger.info(f"Created OpenRouter client - base_url: {base_url}, timeout: {timeout}s") json_handler.log_operation("client_initialized", {"base_url": base_url, "timeout": timeout}) @@ -126,7 +122,9 @@ def create_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: in return None -def get_cached_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]: +def get_cached_client( + api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT +) -> Optional[OpenAI]: """ Get cached OpenAI client or create new one if not cached. Implements connection pooling for better performance. @@ -176,6 +174,7 @@ def get_cached_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout # API REQUEST EXECUTION # ============================================= + def make_api_request(client: OpenAI, messages: List[Dict], model: str, retries: int = 1, **kwargs) -> Optional[Any]: """ Execute API request via OpenRouter with retry logic. @@ -191,14 +190,12 @@ def make_api_request(client: OpenAI, messages: List[Dict], model: str, retries: OpenAI response object or None on failure """ if not client or not messages or not model: - logger.warning(f"make_api_request() called with missing params — client={bool(client)}, messages={bool(messages)}, model={bool(model)}") + logger.warning( + f"make_api_request() called with missing params — client={bool(client)}, messages={bool(messages)}, model={bool(model)}" + ) return None - api_params = { - "model": model, - "messages": messages, - **kwargs - } + api_params = {"model": model, "messages": messages, **kwargs} last_error = None for attempt in range(1 + retries): @@ -211,7 +208,9 @@ def make_api_request(client: OpenAI, messages: List[Dict], model: str, retries: last_error = e if attempt < retries: delay = 1.0 * (attempt + 1) # 1s, 2s, ... - logger.info(f"API request failed for {model} (attempt {attempt + 1}/{1 + retries}): {e} — retrying in {delay:.0f}s") + logger.info( + f"API request failed for {model} (attempt {attempt + 1}/{1 + retries}): {e} — retrying in {delay:.0f}s" + ) time.sleep(delay) logger.error(f"API request failed for {model} after {1 + retries} attempts: {last_error}") @@ -240,11 +239,11 @@ def extract_response(response: Any) -> Optional[Dict[str, Any]]: try: # Validate response structure - if not hasattr(response, 'choices') or not response.choices: + if not hasattr(response, "choices") or not response.choices: logger.warning("Response missing 'choices' or choices is empty") return None - if not hasattr(response.choices[0], 'message'): + if not hasattr(response.choices[0], "message"): logger.warning("Response choice missing 'message' attribute") return None @@ -256,12 +255,12 @@ 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 + 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": finish_reason + "id": response.id if hasattr(response, "id") else None, + "model": response.model if hasattr(response, "model") else None, + "finish_reason": finish_reason, } if finish_reason == "content_filter": @@ -279,7 +278,10 @@ def extract_response(response: Any) -> Optional[Dict[str, Any]]: # MAIN API CALL # ============================================= -def get_response(prompt: str, caller: Optional[str] = None, model: Optional[str] = None, **kwargs) -> Optional[Dict[str, Any]]: + +def get_response( + prompt: str, caller: Optional[str] = None, model: Optional[str] = None, **kwargs +) -> Optional[Dict[str, Any]]: """ Main API call - get response from OpenRouter with full tracking integration. @@ -324,7 +326,9 @@ def get_response(prompt: str, caller: Optional[str] = None, model: Optional[str] # Step 2: Require model from caller - no defaults if not model: logger.error("No model specified.") - logger.warning("Callers must provide their own model via branch config (e.g., flow_json/openrouter_config.json)") + logger.warning( + "Callers must provide their own model via branch config (e.g., flow_json/openrouter_config.json)" + ) return None # Step 3: Get API key @@ -366,6 +370,7 @@ def get_response(prompt: str, caller: Optional[str] = None, model: Optional[str] # CLEANUP # ============================================= + def get_cache_stats() -> Dict[str, Any]: """ Get statistics about the client cache. diff --git a/src/aipass/api/apps/handlers/openrouter/models.py b/src/aipass/api/apps/handlers/openrouter/models.py index ad75939a..0ad01b2e 100644 --- a/src/aipass/api/apps/handlers/openrouter/models.py +++ b/src/aipass/api/apps/handlers/openrouter/models.py @@ -40,6 +40,7 @@ MODULE_NAME = "openrouter.models" # CORE FUNCTIONS # ============================================= + def fetch_models_from_api(api_key: str) -> List[Dict]: """ Query OpenRouter models endpoint and parse response @@ -58,17 +59,12 @@ def fetch_models_from_api(api_key: str) -> List[Dict]: """ try: # Prepare request headers - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} # Make API request logger.info(f"[{MODULE_NAME}] Requesting models from OpenRouter API") response = requests.get( # type: ignore[attr-defined] - OPENROUTER_API_URL, - headers=headers, - timeout=DEFAULT_TIMEOUT + OPENROUTER_API_URL, headers=headers, timeout=DEFAULT_TIMEOUT ) # Check response status @@ -109,7 +105,3 @@ def fetch_models_from_api(api_key: str) -> List[Dict]: logger.info(f"[{MODULE_NAME}] Unexpected error fetching models: {e}") logger.error(f"Error: {e}") return [] - - - - diff --git a/src/aipass/api/apps/handlers/openrouter/provision.py b/src/aipass/api/apps/handlers/openrouter/provision.py index e3cfa99c..24c9e2b3 100644 --- a/src/aipass/api/apps/handlers/openrouter/provision.py +++ b/src/aipass/api/apps/handlers/openrouter/provision.py @@ -37,6 +37,7 @@ from aipass.api.apps.handlers.json import json_handler # JSON UTILITIES # =========================================== + def read_json(file_path: Path) -> Optional[Dict[str, Any]]: """ Read JSON file safely @@ -51,7 +52,7 @@ def read_json(file_path: Path) -> Optional[Dict[str, Any]]: if not file_path.exists(): return None - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"Failed to read {file_path}: {e}") @@ -72,7 +73,7 @@ def write_json(file_path: Path, data: Dict[str, Any]) -> bool: try: file_path.parent.mkdir(parents=True, exist_ok=True) - with open(file_path, 'w', encoding='utf-8') as f: + with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True @@ -85,6 +86,7 @@ def write_json(file_path: Path, data: Dict[str, Any]) -> bool: # DEFAULT CONFIGURATION # =========================================== + def get_default_caller_config() -> Dict[str, Any]: """ Get default config template for new callers @@ -101,8 +103,8 @@ def get_default_caller_config() -> Dict[str, Any]: "ai_model": "", # Caller must set their own model "ai_temperature": 0.7, "ai_max_tokens": 4000, - "enabled": True - } + "enabled": True, + }, } @@ -121,8 +123,8 @@ def get_default_caller_data() -> Dict[str, Any]: "successful_requests": 0, "failed_requests": 0, "models_used": {}, - "last_request": None - } + "last_request": None, + }, } @@ -133,17 +135,14 @@ def get_default_caller_log() -> Dict[str, Any]: Returns: Dict with empty log structure """ - return { - "module_name": "openrouter", - "timestamp": datetime.now().isoformat(), - "logs": [] - } + return {"module_name": "openrouter", "timestamp": datetime.now().isoformat(), "logs": []} # =========================================== # PROVISIONING FUNCTIONS # =========================================== + def provision_json_folder(json_folder: Path) -> bool: """ Create JSON folder structure if missing @@ -274,5 +273,3 @@ def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]: except Exception as e: logger.error(f"Config provisioning failed: {e}") return {} - - diff --git a/src/aipass/api/apps/handlers/usage/__init__.py b/src/aipass/api/apps/handlers/usage/__init__.py index dfb934ec..58e649be 100644 --- a/src/aipass/api/apps/handlers/usage/__init__.py +++ b/src/aipass/api/apps/handlers/usage/__init__.py @@ -4,4 +4,5 @@ Usage Tracking Domain Handlers for API usage monitoring and cost tracking. Query generation metrics, aggregate statistics, and data cleanup. """ + __version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/usage/aggregation.py b/src/aipass/api/apps/handlers/usage/aggregation.py index 2f9bca31..7b95f700 100644 --- a/src/aipass/api/apps/handlers/usage/aggregation.py +++ b/src/aipass/api/apps/handlers/usage/aggregation.py @@ -47,6 +47,7 @@ API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" # AGGREGATION FUNCTIONS # ============================================= + def get_overall_stats() -> Dict[str, Any]: """ Aggregate usage statistics across all callers. @@ -62,7 +63,7 @@ def get_overall_stats() -> Dict[str, Any]: logger.info(f"[{MODULE_NAME}] No usage data file found") return {} - with open(data_path, 'r', encoding='utf-8') as f: + with open(data_path, "r", encoding="utf-8") as f: data = json.load(f) if not data or "data" not in data: @@ -122,7 +123,7 @@ def get_caller_usage(caller: str) -> Dict[str, Any]: logger.info(f"[{MODULE_NAME}] No usage data file found") return {} - with open(data_path, 'r', encoding='utf-8') as f: + with open(data_path, "r", encoding="utf-8") as f: data = json.load(f) if not data or "data" not in data: @@ -164,7 +165,7 @@ def get_session_summary(session_id: Optional[str] = None) -> Dict[str, Any]: logger.info(f"[{MODULE_NAME}] No session data file found") return {} - with open(data_path, 'r', encoding='utf-8') as f: + with open(data_path, "r", encoding="utf-8") as f: data = json.load(f) if not data or "data" not in data: @@ -184,5 +185,3 @@ def get_session_summary(session_id: Optional[str] = None) -> Dict[str, Any]: except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to get session summary: {e}") return {} - - diff --git a/src/aipass/api/apps/handlers/usage/cleanup.py b/src/aipass/api/apps/handlers/usage/cleanup.py index 9efbbb5c..8eee3711 100644 --- a/src/aipass/api/apps/handlers/usage/cleanup.py +++ b/src/aipass/api/apps/handlers/usage/cleanup.py @@ -36,7 +36,7 @@ def _read_json(file_path: Path) -> Optional[Dict]: try: if not file_path.exists(): return None - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"Failed to read JSON from {file_path}: {e}") @@ -47,7 +47,7 @@ def _write_json(file_path: Path, data: Dict) -> bool: """Write JSON file with error handling.""" try: file_path.parent.mkdir(parents=True, exist_ok=True) - with open(file_path, 'w', encoding='utf-8') as f: + with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -76,10 +76,7 @@ def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTI data_content = data.get("data", data) # Identify and remove old generation tracking entries - old_generations = _identify_old_generations( - data_content.get("generation_tracking", {}), - cutoff_date - ) + old_generations = _identify_old_generations(data_content.get("generation_tracking", {}), cutoff_date) if not old_generations: return 0 @@ -95,7 +92,9 @@ def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTI _write_json(data_file_path, data) logger.info(f"Cleaned up {len(old_generations)} generation entries") logger.info(f"Cleaned up {len(old_generations)} generation entries older than {retention_days} days") - json_handler.log_operation("usage_cleanup", {"generations_removed": len(old_generations), "retention_days": retention_days}) + json_handler.log_operation( + "usage_cleanup", {"generations_removed": len(old_generations), "retention_days": retention_days} + ) return len(old_generations) diff --git a/src/aipass/api/apps/handlers/usage/tracking.py b/src/aipass/api/apps/handlers/usage/tracking.py index ffb16d43..5e16bc40 100644 --- a/src/aipass/api/apps/handlers/usage/tracking.py +++ b/src/aipass/api/apps/handlers/usage/tracking.py @@ -56,7 +56,10 @@ MAX_GENERATION_TRACKING = 500 # Maximum entries in generation_tracking before t # CORE TRACKING FUNCTIONS # ============================================= -def track_usage(generation_id: str, caller: str, model: str = "unknown", api_key: Optional[str] = None) -> Dict[str, Any]: + +def track_usage( + generation_id: str, caller: str, model: str = "unknown", api_key: Optional[str] = None +) -> Dict[str, Any]: """ Track API usage for generation ID by querying OpenRouter metrics @@ -83,6 +86,7 @@ def track_usage(generation_id: str, caller: str, model: str = "unknown", api_key # Import here to avoid circular dependencies try: from aipass.api.apps.handlers.auth.keys import get_api_key + api_key = get_api_key("openrouter") except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to load API key: {e}") @@ -105,7 +109,9 @@ def track_usage(generation_id: str, caller: str, model: str = "unknown", api_key # Store the usage data if store_usage_data(caller, model, generation_id, metrics): # Successfully tracked usage - json_handler.log_operation("usage_tracked", {"caller": caller, "model": model, "generation_id": generation_id}) + json_handler.log_operation( + "usage_tracked", {"caller": caller, "model": model, "generation_id": generation_id} + ) return {"success": True, "metrics": metrics} else: # Failed to store usage data @@ -140,17 +146,11 @@ def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[st """ try: # Set up request headers - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} # Query the generation endpoint response = requests.get( # type: ignore[attr-defined] - GENERATION_ENDPOINT, - params={"id": generation_id}, - headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT + GENERATION_ENDPOINT, params={"id": generation_id}, headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT ) # Check response status @@ -159,7 +159,9 @@ def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[st # Validate response structure if not data or "data" not in data: - logger.warning(f"[{MODULE_NAME}] Invalid response structure from OpenRouter for generation {generation_id}") + logger.warning( + f"[{MODULE_NAME}] Invalid response structure from OpenRouter for generation {generation_id}" + ) return None # Extract metrics from response (use `or 0` to handle explicit None values) @@ -170,14 +172,16 @@ def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[st "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" + "provider_name": metrics.get("provider_name") or "unknown", } # Retrieved metrics for generation_id return result else: - logger.warning(f"[{MODULE_NAME}] OpenRouter API returned status {response.status_code} for generation {generation_id}") + logger.warning( + f"[{MODULE_NAME}] OpenRouter API returned status {response.status_code} for generation {generation_id}" + ) return None except requests.exceptions.Timeout as e: @@ -223,7 +227,7 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ # Load current data or create initial structure if data_path.exists(): - with open(data_path, 'r', encoding='utf-8') as f: + with open(data_path, "r", encoding="utf-8") as f: data_wrapper = json.load(f) current_data = data_wrapper.get("data", {}) else: @@ -232,12 +236,12 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ "start_time": datetime.now().isoformat(), "total_requests": 0, "total_cost": 0.0, - "total_tokens": 0 + "total_tokens": 0, }, "usage_by_caller": {}, "daily_totals": {}, "monthly_totals": {}, - "generation_tracking": {} + "generation_tracking": {}, } # Calculate total tokens @@ -255,7 +259,7 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ "total_cost": 0.0, "total_tokens": 0, "models_used": {}, - "last_request": None + "last_request": None, } caller_data = current_data["usage_by_caller"][caller] @@ -272,11 +276,7 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ # Update daily totals today = datetime.now().date().isoformat() if today not in current_data["daily_totals"]: - current_data["daily_totals"][today] = { - "requests": 0, - "cost": 0.0, - "tokens": 0 - } + current_data["daily_totals"][today] = {"requests": 0, "cost": 0.0, "tokens": 0} current_data["daily_totals"][today]["requests"] += 1 current_data["daily_totals"][today]["cost"] += metrics["total_cost"] @@ -287,22 +287,13 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ 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": 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(), - "caller": caller, - "model": model, - "usage_data": metrics - } + new_entry = {"timestamp": datetime.now().isoformat(), "caller": caller, "model": model, "usage_data": metrics} # Create new dict with new entry first, then existing entries current_tracking = current_data["generation_tracking"] @@ -315,13 +306,9 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ del current_data["generation_tracking"][old_key] # Save updated data with proper wrapper structure - data_wrapper = { - "module_name": "api_usage", - "timestamp": datetime.now().isoformat(), - "data": current_data - } + data_wrapper = {"module_name": "api_usage", "timestamp": datetime.now().isoformat(), "data": current_data} - with open(data_path, 'w', encoding='utf-8') as f: + with open(data_path, "w", encoding="utf-8") as f: json.dump(data_wrapper, f, indent=2, ensure_ascii=False) # Stored usage data for caller @@ -330,5 +317,3 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to store usage data: {e}") return False - - diff --git a/src/aipass/api/apps/modules/api_key.py b/src/aipass/api/apps/modules/api_key.py index c98742c7..c203c53e 100644 --- a/src/aipass/api/apps/modules/api_key.py +++ b/src/aipass/api/apps/modules/api_key.py @@ -174,7 +174,7 @@ def print_help(): parser = argparse.ArgumentParser( prog="drone @api", - description='API Key Management Module - Manage API keys and credentials', + description="API Key Management Module - Manage API keys and credentials", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" COMMANDS: @@ -199,7 +199,7 @@ EXAMPLES: # Initialize environment drone @api init - """ + """, ) console.print(parser.format_help()) @@ -214,7 +214,7 @@ if __name__ == "__main__": sys.exit(0) # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: + if args[0] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/api/apps/modules/bridge.py b/src/aipass/api/apps/modules/bridge.py index 777a98e3..d7a3fbbd 100644 --- a/src/aipass/api/apps/modules/bridge.py +++ b/src/aipass/api/apps/modules/bridge.py @@ -12,6 +12,7 @@ Contracts are string names (e.g. "memory", "search") that map to driver function Drivers register themselves; callers resolve by name. Bridge itself is stateless beyond the registry dict — no threading, no startup side effects. """ + from typing import Callable from aipass.api.apps.handlers.json import json_handler @@ -35,6 +36,7 @@ def print_introspection() -> None: console.print() json_handler.log_operation("bridge_introspection", {"contracts": contracts}) + _registry: dict[str, Callable] = {} diff --git a/src/aipass/api/apps/modules/google_client.py b/src/aipass/api/apps/modules/google_client.py index 176dcf7a..0fb1ce00 100644 --- a/src/aipass/api/apps/modules/google_client.py +++ b/src/aipass/api/apps/modules/google_client.py @@ -103,7 +103,7 @@ CREDENTIAL SETUP: 3. Run: drone @api reauth google 4. Complete OAuth consent in browser 5. Credentials saved to: ~/.secrets/aipass/google_creds.json - """ + """, ) console.print(parser.format_help()) @@ -268,9 +268,7 @@ def get_google_service( ) if thread_safe: - service = google_factory.build_thread_safe_service( - service_name, version, scopes - ) + service = google_factory.build_thread_safe_service(service_name, version, scopes) else: service = google_factory.build_service(service_name, version, scopes) diff --git a/src/aipass/api/apps/modules/integrations_manager.py b/src/aipass/api/apps/modules/integrations_manager.py index e5ae8c8f..3fa27f17 100644 --- a/src/aipass/api/apps/modules/integrations_manager.py +++ b/src/aipass/api/apps/modules/integrations_manager.py @@ -12,6 +12,7 @@ Handles `drone @api integrations` subcommands: integrations list — list all registered contracts integrations call ... — call a registered contract """ + import sys from typing import List diff --git a/src/aipass/api/apps/modules/openrouter_client.py b/src/aipass/api/apps/modules/openrouter_client.py index b323436f..246a7446 100644 --- a/src/aipass/api/apps/modules/openrouter_client.py +++ b/src/aipass/api/apps/modules/openrouter_client.py @@ -87,7 +87,7 @@ EXAMPLES: # Check connection status drone @api status - """ + """, ) subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -187,7 +187,7 @@ def make_call(args: List[str]): console.print() if not args: - error("Prompt required", suggestion="drone @api call \"your prompt\" --model MODEL") + error("Prompt required", suggestion='drone @api call "your prompt" --model MODEL') return # Parse args: first non-flag arg is prompt, --model MODEL is optional @@ -205,11 +205,11 @@ def make_call(args: List[str]): i += 1 if not prompt: - error("Prompt required", suggestion="drone @api call \"your prompt\" --model MODEL") + error("Prompt required", suggestion='drone @api call "your prompt" --model MODEL') return if not model: - error("Model required", suggestion="drone @api call \"your prompt\" --model anthropic/claude-3.5-sonnet") + error("Model required", suggestion='drone @api call "your prompt" --model anthropic/claude-3.5-sonnet') return console.print(f"[dim]Calling {model}...[/dim]") @@ -310,6 +310,7 @@ def check_status(): # OpenAI SDK availability try: import openai # noqa: F401 + console.print(" [cyan]OpenAI SDK:[/cyan] [green]available[/green]") except ImportError: logger.warning("OpenAI SDK not installed") @@ -325,6 +326,7 @@ def check_status(): # PUBLIC API - Re-export handler functions # ============================================= + def get_response(prompt: str, caller: str | None = None, model: str | None = None, **kwargs): """ Public API: Get response from OpenRouter @@ -361,7 +363,7 @@ if __name__ == "__main__": sys.exit(0) # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: + if args[0] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/api/apps/modules/registry.py b/src/aipass/api/apps/modules/registry.py index d238353a..40288755 100644 --- a/src/aipass/api/apps/modules/registry.py +++ b/src/aipass/api/apps/modules/registry.py @@ -20,6 +20,7 @@ Failure modes — all non-fatal: - Folder without driver.py → skipped silently - Import error → logged as WARNING, driver skipped, no crash """ + import importlib.util import sys from pathlib import Path diff --git a/src/aipass/api/apps/modules/usage_tracker.py b/src/aipass/api/apps/modules/usage_tracker.py index 56b174de..23d4e09b 100644 --- a/src/aipass/api/apps/modules/usage_tracker.py +++ b/src/aipass/api/apps/modules/usage_tracker.py @@ -94,7 +94,7 @@ EXAMPLES: # Cleanup data older than 60 days drone @api cleanup 60 - """ + """, ) subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -115,7 +115,12 @@ EXAMPLES: # cleanup command cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data") - cleanup_parser.add_argument("days", nargs="?", default=str(DEFAULT_RETENTION_DAYS), help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})") + cleanup_parser.add_argument( + "days", + nargs="?", + default=str(DEFAULT_RETENTION_DAYS), + help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})", + ) console.print(parser.format_help()) @@ -189,7 +194,9 @@ def track_usage(args: List[str]): if result.get("success"): metrics = result.get("metrics", {}) - success(f"Tracked: {metrics.get('tokens_prompt', 0)} prompt + {metrics.get('tokens_completion', 0)} completion tokens, ${metrics.get('total_cost', 0):.6f}") + success( + f"Tracked: {metrics.get('tokens_prompt', 0)} prompt + {metrics.get('tokens_completion', 0)} completion tokens, ${metrics.get('total_cost', 0):.6f}" + ) else: error(f"Tracking failed: {result.get('error', 'unknown')}") @@ -206,7 +213,7 @@ def show_stats(): console.print(f" Total Cost: ${stats.get('total_cost', 0.0):.6f}") console.print(f" Total Tokens: {stats.get('total_tokens', 0)}") console.print(f" Callers: {stats.get('callers', 0)}") - models = stats.get('models_used', []) + models = stats.get("models_used", []) if models: console.print(f" Models Used: {', '.join(models)}") else: @@ -275,7 +282,8 @@ def cleanup_data(args: List[str]): # Fire trigger event try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('usage_data_cleaned', days=days, data_path=str(data_path)) + + trigger.fire("usage_data_cleaned", days=days, data_path=str(data_path)) except ImportError: logger.warning("Trigger module not available — skipping event fire") else: @@ -292,7 +300,7 @@ if __name__ == "__main__": sys.exit(0) # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: + if args[0] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/api/tests/conftest.py b/src/aipass/api/tests/conftest.py index 1668730f..4299cf2d 100644 --- a/src/aipass/api/tests/conftest.py +++ b/src/aipass/api/tests/conftest.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: tests/conftest.py @@ -16,6 +15,7 @@ # ============================================= """Shared pytest fixtures for api tests""" + import os import tempfile @@ -48,9 +48,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -79,6 +77,7 @@ for _candidate in _JSON_DIR_CANDIDATES: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture() def temp_test_dir(tmp_path: Path) -> Generator[Path, None, None]: """Creates temporary directory for testing, cleans up after""" diff --git a/src/aipass/api/tests/test_api_key.py b/src/aipass/api/tests/test_api_key.py index c425fa89..bfce0a14 100644 --- a/src/aipass/api/tests/test_api_key.py +++ b/src/aipass/api/tests/test_api_key.py @@ -461,7 +461,9 @@ def test_handle_command_logs_operation(mock_jh, mock_header, mock_console): @patch(PATCH_ERROR) @patch(PATCH_JSON_HANDLER) @patch(PATCH_KEYS) -def test_handle_command_logs_operation_for_get_key(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): +def test_handle_command_logs_operation_for_get_key( + mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console +): """get-key command should log api_key_get-key operation.""" mock_keys.get_api_key.return_value = "FAKE-sk-test1234567890" @@ -536,13 +538,10 @@ class TestGetKeyFromConfig: config_dir = tmp_path / "api_json" config_dir.mkdir() config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openrouter": {"api_key": "FAKE-sk-or-testkey-abc123"} - } - } - }), encoding="utf-8") + config_file.write_text( + json.dumps({"config": {"providers": {"openrouter": {"api_key": "FAKE-sk-or-testkey-abc123"}}}}), + encoding="utf-8", + ) monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) @@ -565,13 +564,9 @@ class TestGetKeyFromConfig: config_dir = tmp_path / "api_json" config_dir.mkdir() config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openai": {"api_key": "FAKE-sk-openai-key-123"} - } - } - }), encoding="utf-8") + config_file.write_text( + json.dumps({"config": {"providers": {"openai": {"api_key": "FAKE-sk-openai-key-123"}}}}), encoding="utf-8" + ) monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) @@ -585,13 +580,7 @@ class TestGetKeyFromConfig: config_dir = tmp_path / "api_json" config_dir.mkdir() config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openrouter": {"api_key": ""} - } - } - }), encoding="utf-8") + config_file.write_text(json.dumps({"config": {"providers": {"openrouter": {"api_key": ""}}}}), encoding="utf-8") monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) @@ -633,15 +622,20 @@ class TestGetKeyFromConfig: config_dir = tmp_path / "api_json" config_dir.mkdir() config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openrouter": {"api_key": "FAKE-sk-or-key"}, - "openai": {"api_key": "FAKE-sk-openai-key"}, - "anthropic": {"api_key": "FAKE-sk-ant-key"}, + config_file.write_text( + json.dumps( + { + "config": { + "providers": { + "openrouter": {"api_key": "FAKE-sk-or-key"}, + "openai": {"api_key": "FAKE-sk-openai-key"}, + "anthropic": {"api_key": "FAKE-sk-ant-key"}, + } + } } - } - }), encoding="utf-8") + ), + encoding="utf-8", + ) monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) diff --git a/src/aipass/api/tests/test_cli_routing.py b/src/aipass/api/tests/test_cli_routing.py index 8721a84f..5c3f30fb 100644 --- a/src/aipass/api/tests/test_cli_routing.py +++ b/src/aipass/api/tests/test_cli_routing.py @@ -27,6 +27,7 @@ from aipass.api.apps.modules import api_key # handle_command routing tests # --------------------------------------------------------------------------- + @patch("aipass.api.apps.modules.api_key.console") @patch("aipass.api.apps.modules.api_key.header") @patch("aipass.api.apps.modules.api_key.json_handler") @@ -89,6 +90,7 @@ def test_handle_command_return_bool(mock_jh, mock_header, mock_console): # Output capture tests # --------------------------------------------------------------------------- + def test_output_capture_help(capsys): """--help flag triggers console output (output capture with capsys).""" # capsys captures stdout/stderr — satisfies output_capture pattern diff --git a/src/aipass/api/tests/test_config_provider.py b/src/aipass/api/tests/test_config_provider.py index 560092b1..5e72e50f 100644 --- a/src/aipass/api/tests/test_config_provider.py +++ b/src/aipass/api/tests/test_config_provider.py @@ -137,9 +137,7 @@ class TestMergeConfigs: config_provider.merge_configs(base, updates) - mock_jh.log_operation.assert_called_once_with( - "config_merged", {"keys_updated": 2} - ) + mock_jh.log_operation.assert_called_once_with("config_merged", {"keys_updated": 2}) # ============================================= diff --git a/src/aipass/api/tests/test_contracts.py b/src/aipass/api/tests/test_contracts.py index 01f6f4e6..7b3ccf52 100644 --- a/src/aipass/api/tests/test_contracts.py +++ b/src/aipass/api/tests/test_contracts.py @@ -34,9 +34,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -76,6 +74,7 @@ def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: # Default factory helpers # --------------------------------------------------------------------------- + def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: for fn_name in ("_create_default", "_get_default_template", "_get_default"): fn = getattr(_mod, fn_name, None) @@ -102,9 +101,11 @@ def _default_factory_raises_on_unknown() -> bool: # Group 1 — Return type contracts # ============================================================================ + def test_handle_command_returns_bool() -> None: """handle_command must return a bool.""" from aipass.api.apps.modules import api_key + result = api_key.handle_command("get-key", ["--help"]) assert isinstance(result, bool) @@ -132,6 +133,7 @@ def test_load_json_returns_dict_for_config(tmp_path: Path) -> None: # Group 2 — Exception contracts # ============================================================================ + def test_create_default_unknown_raises_value_error() -> None: """_create_default must raise ValueError for unknown type.""" if not _default_factory_raises_on_unknown(): @@ -159,6 +161,7 @@ def test_validate_rejects_invalid_mode() -> None: # Group 3 — Data structure contracts # ============================================================================ + def test_config_has_required_keys(tmp_path: Path) -> None: """Config must contain module_name and version.""" json_handler.ensure_json_exists("struct_mod", "config") @@ -180,6 +183,7 @@ def test_data_has_date_keys(tmp_path: Path) -> None: def test_reimport_after_mock(tmp_path: Path) -> None: """Module can be reloaded after mocking (reimport_after_mock contract).""" import importlib + # Reload the json_handler module to verify it survives reimport reloaded = importlib.reload(_mod) assert hasattr(reloaded, "load_json") diff --git a/src/aipass/api/tests/test_critical_paths.py b/src/aipass/api/tests/test_critical_paths.py index 2220d998..c7c8a792 100644 --- a/src/aipass/api/tests/test_critical_paths.py +++ b/src/aipass/api/tests/test_critical_paths.py @@ -23,7 +23,6 @@ import json from unittest.mock import patch, MagicMock - # ============================================= # 1. get_api_key() tests # ============================================= @@ -41,11 +40,7 @@ class TestGetApiKey: config_path = tmp_path / "api_connect_config.json" config_data = { "config": { - "providers": { - "openrouter": { - "api_key": "sk-or-v1-NOTREAL-test-000000000000000000000000000000000000" - } - } + "providers": {"openrouter": {"api_key": "sk-or-v1-NOTREAL-test-000000000000000000000000000000000000"}} } } config_path.write_text(json.dumps(config_data), encoding="utf-8") @@ -53,7 +48,7 @@ class TestGetApiKey: mock_api_json_dir.__truediv__ = lambda self, name: tmp_path / name result = get_api_key("openrouter") - #do not add real api kets here + # do not add real api kets here assert result == "sk-or-v1-NOTREAL-test-000000000000000000000000000000000000" mock_jh.log_operation.assert_called_once() @@ -113,15 +108,7 @@ class TestGetApiKey: from aipass.api.apps.handlers.auth.keys import get_api_key config_path = tmp_path / "api_connect_config.json" - config_data = { - "config": { - "providers": { - "openrouter": { - "api_key": "INVALID-PREFIX-key-that-is-long-enough" - } - } - } - } + config_data = {"config": {"providers": {"openrouter": {"api_key": "INVALID-PREFIX-key-that-is-long-enough"}}}} config_path.write_text(json.dumps(config_data), encoding="utf-8") mock_api_json_dir.__truediv__ = lambda self, name: tmp_path / name @@ -230,9 +217,7 @@ class TestGetResponse: "model": "anthropic/claude-3.5-sonnet", } - result = get_response( - "What is Python?", model="anthropic/claude-3.5-sonnet" - ) + result = get_response("What is Python?", model="anthropic/claude-3.5-sonnet") assert result is not None assert result["content"] == "Hello, world!" @@ -242,9 +227,7 @@ class TestGetResponse: @patch(f"{MODULE}.get_api_key") @patch(f"{MODULE}.ensure_caller_config") @patch(f"{MODULE}.get_caller_info") - def test_no_model_returns_none( - self, mock_caller_info, mock_ensure, mock_get_key - ): + def test_no_model_returns_none(self, mock_caller_info, mock_ensure, mock_get_key): """Missing model parameter returns None without making API call.""" from aipass.api.apps.handlers.openrouter.client import get_response @@ -258,18 +241,14 @@ class TestGetResponse: @patch(f"{MODULE}.get_api_key") @patch(f"{MODULE}.ensure_caller_config") @patch(f"{MODULE}.get_caller_info") - def test_no_api_key_returns_none( - self, mock_caller_info, mock_ensure, mock_get_key - ): + def test_no_api_key_returns_none(self, mock_caller_info, mock_ensure, mock_get_key): """No API key available returns None.""" from aipass.api.apps.handlers.openrouter.client import get_response mock_caller_info.return_value = {"caller_name": "test"} mock_get_key.return_value = None - result = get_response( - "What is Python?", model="anthropic/claude-3.5-sonnet" - ) + result = get_response("What is Python?", model="anthropic/claude-3.5-sonnet") assert result is None diff --git a/src/aipass/api/tests/test_error_resilience.py b/src/aipass/api/tests/test_error_resilience.py index 87356fed..a6f6344d 100644 --- a/src/aipass/api/tests/test_error_resilience.py +++ b/src/aipass/api/tests/test_error_resilience.py @@ -29,9 +29,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -77,6 +75,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Error Resilience Tests # ============================================================================ + def test_missing_file(tmp_path: Path) -> None: """Loading a non-existent file returns a graceful default, not a crash.""" json_dir = _json_dir_as_path(tmp_path) diff --git a/src/aipass/api/tests/test_google_client.py b/src/aipass/api/tests/test_google_client.py index df58463d..4573b205 100644 --- a/src/aipass/api/tests/test_google_client.py +++ b/src/aipass/api/tests/test_google_client.py @@ -46,9 +46,7 @@ _MOD = "aipass.api.apps.modules.google_client" @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_handle_command_returns_false_no_args( - _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console -): +def test_handle_command_returns_false_no_args(_warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console): """handle_command returns False when args=[] and command != 'google'.""" from aipass.api.apps.modules import google_client @@ -134,9 +132,7 @@ def test_handle_command_routes_reauth_google( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_handle_command_help_gate( - _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, mock_console -): +def test_handle_command_help_gate(_warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, mock_console): """handle_command prints help when args=['google', '--help'] and returns True.""" from aipass.api.apps.modules import google_client @@ -205,9 +201,7 @@ def test_handle_command_unknown_command_returns_false( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_get_drive_service_delegates( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): +def test_get_drive_service_delegates(_warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console): """get_drive_service() delegates to get_google_service('drive', 'v3').""" from aipass.api.apps.modules import google_client @@ -235,9 +229,7 @@ def test_get_drive_service_delegates( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_get_google_service_standard( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): +def test_get_google_service_standard(_warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console): """get_google_service() returns service via build_service when is_available=True.""" from aipass.api.apps.modules import google_client @@ -260,9 +252,7 @@ def test_get_google_service_standard( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_get_google_service_thread_safe( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): +def test_get_google_service_thread_safe(_warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console): """get_google_service(thread_safe=True) calls build_thread_safe_service.""" from aipass.api.apps.modules import google_client @@ -285,9 +275,7 @@ def test_get_google_service_thread_safe( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_get_google_service_libs_not_available( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_get_google_service_libs_not_available(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """get_google_service() raises RuntimeError when libraries are not installed.""" from aipass.api.apps.modules import google_client @@ -306,9 +294,7 @@ def test_get_google_service_libs_not_available( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_get_google_service_auth_failure( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): +def test_get_google_service_auth_failure(_warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console): """get_google_service() raises RuntimeError when build_service returns None.""" from aipass.api.apps.modules import google_client @@ -359,9 +345,7 @@ def test_get_google_service_with_custom_scopes( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_validate_google_true( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_validate_google_true(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """validate_google() returns True when credentials are valid.""" from aipass.api.apps.modules import google_client @@ -382,9 +366,7 @@ def test_validate_google_true( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_validate_google_false( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_validate_google_false(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """validate_google() returns False when no valid credentials exist.""" from aipass.api.apps.modules import google_client @@ -404,9 +386,7 @@ def test_validate_google_false( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_validate_google_with_scopes( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_validate_google_with_scopes(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """validate_google() passes scopes to validate_credentials.""" from aipass.api.apps.modules import google_client @@ -433,9 +413,7 @@ def test_validate_google_with_scopes( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_authenticate_google_success( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_authenticate_google_success(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """authenticate_google() returns True when authenticate returns credentials.""" from aipass.api.apps.modules import google_client @@ -456,9 +434,7 @@ def test_authenticate_google_success( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_authenticate_google_failure( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_authenticate_google_failure(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """authenticate_google() returns False when authenticate returns None.""" from aipass.api.apps.modules import google_client @@ -483,9 +459,7 @@ def test_authenticate_google_failure( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_reauth_google_success( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_reauth_google_success(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """reauth_google() returns True when reauth returns credentials.""" from aipass.api.apps.modules import google_client @@ -506,9 +480,7 @@ def test_reauth_google_success( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_reauth_google_failure( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_reauth_google_failure(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """reauth_google() returns False when reauth returns None.""" from aipass.api.apps.modules import google_client @@ -533,9 +505,7 @@ def test_reauth_google_failure( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_api_call_with_retry_delegates( - _warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console -): +def test_api_call_with_retry_delegates(_warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console): """api_call_with_retry() delegates to google_retry.api_call_with_retry.""" from aipass.api.apps.modules import google_client @@ -562,9 +532,7 @@ def test_api_call_with_retry_delegates( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_is_ssl_error_delegates( - _warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console -): +def test_is_ssl_error_delegates(_warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console): """is_ssl_error() delegates to google_retry.is_ssl_error.""" from aipass.api.apps.modules import google_client @@ -591,9 +559,7 @@ def test_is_ssl_error_delegates( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_validate_libs_not_available( - _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_cmd_validate_libs_not_available(_warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """_cmd_validate shows error when Google libs are not installed.""" from aipass.api.apps.modules import google_client @@ -614,9 +580,7 @@ def test_cmd_validate_libs_not_available( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_validate_no_client_secret( - _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_cmd_validate_no_client_secret(_warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """_cmd_validate shows error when client secret file is missing.""" from aipass.api.apps.modules import google_client @@ -638,9 +602,7 @@ def test_cmd_validate_no_client_secret( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_validate_valid_creds( - _warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): +def test_cmd_validate_valid_creds(_warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console): """_cmd_validate shows success when credentials are valid.""" from aipass.api.apps.modules import google_client @@ -651,9 +613,7 @@ def test_cmd_validate_valid_creds( google_client._cmd_validate() mock_succ.assert_called_once_with("Google credentials are valid") - mock_json.log_operation.assert_called_once_with( - "google_validate", {"status": "valid"} - ) + mock_json.log_operation.assert_called_once_with("google_validate", {"status": "valid"}) _err.assert_not_called() @@ -666,9 +626,7 @@ def test_cmd_validate_valid_creds( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_validate_invalid_creds( - mock_warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, mock_console -): +def test_cmd_validate_invalid_creds(mock_warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, mock_console): """_cmd_validate shows warning when credentials are invalid.""" from aipass.api.apps.modules import google_client @@ -679,9 +637,7 @@ def test_cmd_validate_invalid_creds( google_client._cmd_validate() mock_warn.assert_called_once_with("No valid Google credentials found") - mock_json.log_operation.assert_called_once_with( - "google_validate", {"status": "invalid"} - ) + mock_json.log_operation.assert_called_once_with("google_validate", {"status": "invalid"}) _succ.assert_not_called() @@ -699,9 +655,7 @@ def test_cmd_validate_invalid_creds( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_reauth_libs_not_available( - _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_cmd_reauth_libs_not_available(_warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """_cmd_reauth shows error when Google libs are not installed.""" from aipass.api.apps.modules import google_client @@ -722,9 +676,7 @@ def test_cmd_reauth_libs_not_available( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_reauth_success( - _warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): +def test_cmd_reauth_success(_warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console): """_cmd_reauth shows success when reauth returns credentials.""" from aipass.api.apps.modules import google_client @@ -735,9 +687,7 @@ def test_cmd_reauth_success( google_client._cmd_reauth() mock_succ.assert_called_once_with("Google re-authentication successful") - mock_json.log_operation.assert_called_once_with( - "google_reauth", {"status": "success"} - ) + mock_json.log_operation.assert_called_once_with("google_reauth", {"status": "success"}) _err.assert_not_called() @@ -750,9 +700,7 @@ def test_cmd_reauth_success( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_cmd_reauth_failure( - _warn, mock_err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): +def test_cmd_reauth_failure(_warn, mock_err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console): """_cmd_reauth shows error when reauth returns None.""" from aipass.api.apps.modules import google_client @@ -763,9 +711,7 @@ def test_cmd_reauth_failure( google_client._cmd_reauth() mock_err.assert_called_once_with("Google re-authentication failed") - mock_json.log_operation.assert_called_once_with( - "google_reauth", {"status": "failed"} - ) + mock_json.log_operation.assert_called_once_with("google_reauth", {"status": "failed"}) _succ.assert_not_called() @@ -783,9 +729,7 @@ def test_cmd_reauth_failure( @patch(f"{_MOD}.success") @patch(f"{_MOD}.error") @patch(f"{_MOD}.warning") -def test_handle_command_propagates_exception( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): +def test_handle_command_propagates_exception(_warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console): """handle_command re-raises exceptions from downstream handlers.""" from aipass.api.apps.modules import google_client @@ -822,9 +766,7 @@ def test_load_credentials_success(mock_creds_path, mock_creds_cls, mock_json): str(mock_creds_path), ["https://www.googleapis.com/auth/drive.file"], ) - mock_json.log_operation.assert_called_once_with( - "credentials_loaded", {"source": str(mock_creds_path)} - ) + mock_json.log_operation.assert_called_once_with("credentials_loaded", {"source": str(mock_creds_path)}) @patch(f"{_AUTH}.json_handler") diff --git a/src/aipass/api/tests/test_init_provisioning.py b/src/aipass/api/tests/test_init_provisioning.py index 331a082d..749bc482 100644 --- a/src/aipass/api/tests/test_init_provisioning.py +++ b/src/aipass/api/tests/test_init_provisioning.py @@ -29,9 +29,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -77,6 +75,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Init/Provisioning Tests # ============================================================================ + def test_creates_expected_files(tmp_path: Path) -> None: """ensure_json_exists creates expected files on disk.""" json_dir = _json_dir_as_path(tmp_path) diff --git a/src/aipass/api/tests/test_integrations.py b/src/aipass/api/tests/test_integrations.py index ad55af97..cf98a0ce 100644 --- a/src/aipass/api/tests/test_integrations.py +++ b/src/aipass/api/tests/test_integrations.py @@ -14,6 +14,7 @@ Groups: TestFetchContracts — fetch_contracts() happy path and empty TestCallContract — call_contract() happy path, unregistered, args forwarding, exception """ + import pytest from aipass.api.apps.modules import bridge, registry @@ -24,6 +25,7 @@ from aipass.api.apps.modules.integrations_manager import fetch_contracts, call_c # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def clean_bridge(): """Reset bridge state before and after each test.""" @@ -38,12 +40,15 @@ def clean_bridge(): # TestBridge # --------------------------------------------------------------------------- + class TestBridge: def test_register_and_resolve(self): """register() then resolve() returns the same callable.""" + def fn(): """Test fixture.""" return "result" + bridge.register("my_contract", fn) assert bridge.resolve("my_contract") is fn @@ -64,12 +69,15 @@ class TestBridge: def test_register_overwrites(self): """Registering same name twice replaces the driver.""" + def fn1(): """First test fixture.""" return "first" + def fn2(): """Second test fixture.""" return "second" + bridge.register("dup", fn1) bridge.register("dup", fn2) assert bridge.resolve("dup") is fn2 @@ -79,6 +87,7 @@ class TestBridge: # TestRegistry # --------------------------------------------------------------------------- + class TestRegistry: def test_load_drivers_empty_dir(self, tmp_path): """Empty integrations dir → 0 drivers, no error.""" @@ -144,6 +153,7 @@ class TestRegistry: # TestFetchContracts # --------------------------------------------------------------------------- + class TestFetchContracts: def test_empty_returns_success(self): """fetch_contracts() returns success with empty list when bridge is clear.""" @@ -166,6 +176,7 @@ class TestFetchContracts: # TestCallContract # --------------------------------------------------------------------------- + class TestCallContract: def test_call_registered_contract(self): """call_contract() resolves and invokes registered driver.""" diff --git a/src/aipass/api/tests/test_json_handler.py b/src/aipass/api/tests/test_json_handler.py index dec65971..5e17ff7d 100644 --- a/src/aipass/api/tests/test_json_handler.py +++ b/src/aipass/api/tests/test_json_handler.py @@ -40,9 +40,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -69,8 +67,7 @@ for _candidate in _JSON_DIR_CANDIDATES: if _JSON_DIR_ATTR is None: pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — " - f"tried: {_JSON_DIR_CANDIDATES}", + f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — tried: {_JSON_DIR_CANDIDATES}", allow_module_level=True, ) @@ -79,6 +76,7 @@ if _JSON_DIR_ATTR is None: # Default factory discovery # --------------------------------------------------------------------------- + def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: """Call whichever default factory the branch exposes.""" for fn_name in ("_create_default", "_get_default_template", "_get_default"): @@ -113,6 +111,7 @@ def _default_factory_raises_on_unknown() -> bool: # Isolation fixture # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect JSON operations to tmp_path for test isolation.""" @@ -131,6 +130,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Group 1 — _create_default / default templates # ============================================================================ + def test_default_config_returns_dict_with_required_keys() -> None: if not _has_default_factory(): pytest.skip("Branch has no default factory function") @@ -169,6 +169,7 @@ def test_default_unknown_type_raises_value_error() -> None: # Group 2 — validate_json_structure # ============================================================================ + def test_validate_valid_config() -> None: data = {"module_name": "x", "version": "1.0.0", "config": {}} assert json_handler.validate_json_structure(data, "config") is True @@ -220,6 +221,7 @@ def test_validate_none_input_returns_false() -> None: # Group 3 — get_json_path # ============================================================================ + def test_get_json_path_returns_path_type(tmp_path: Path) -> None: result = json_handler.get_json_path("mymod", "config") assert isinstance(result, (Path, str)) @@ -241,6 +243,7 @@ def test_get_json_path_different_combos_differ(tmp_path: Path) -> None: # Group 4 — ensure_json_exists # ============================================================================ + def test_ensure_creates_file_when_missing(tmp_path: Path) -> None: result = json_handler.ensure_json_exists("ens_mod", "config") assert result is True @@ -298,6 +301,7 @@ def test_ensure_returns_bool(tmp_path: Path) -> None: # Group 5 — load_json # ============================================================================ + def test_load_creates_default_when_missing(tmp_path: Path) -> None: result = json_handler.load_json("fresh_mod", "log") assert result is not None @@ -330,6 +334,7 @@ def test_load_returns_list_for_log(tmp_path: Path) -> None: # Group 6 — save_json # ============================================================================ + def test_save_roundtrip(tmp_path: Path) -> None: json_dir = _json_dir_as_path(tmp_path) json_dir.mkdir(parents=True, exist_ok=True) @@ -384,6 +389,7 @@ def test_save_writes_valid_json_to_disk(tmp_path: Path) -> None: # Group 7 — log_operation # ============================================================================ + def test_log_operation_appends_entry(tmp_path: Path) -> None: json_handler.log_operation("deploy", module_name="logmod") json_dir = _json_dir_as_path(tmp_path) @@ -428,6 +434,7 @@ def test_log_operation_multiple_calls_accumulate(tmp_path: Path) -> None: # Group 8 — ensure_module_jsons # ============================================================================ + def test_ensure_module_jsons_creates_all_three(tmp_path: Path) -> None: json_handler.ensure_module_jsons("triple") json_dir = _json_dir_as_path(tmp_path) diff --git a/src/aipass/api/tests/test_openrouter_client.py b/src/aipass/api/tests/test_openrouter_client.py index 795354cb..3da3ac58 100644 --- a/src/aipass/api/tests/test_openrouter_client.py +++ b/src/aipass/api/tests/test_openrouter_client.py @@ -154,9 +154,7 @@ def test_handle_command_logs_operation(mock_console, mock_header, mock_jh, mock_ openrouter_client.handle_command("test", []) - mock_jh.log_operation.assert_called_once_with( - "openrouter_test", {"command": "test"} - ) + mock_jh.log_operation.assert_called_once_with("openrouter_test", {"command": "test"}) # ============================================= @@ -241,7 +239,9 @@ def test_test_connection_no_key_no_success(mock_console, mock_header, mock_keys, @patch(f"{_MOD}.keys") @patch(f"{_MOD}.header") @patch(f"{_MOD}.console") -def test_test_connection_api_failure_no_success(mock_console, mock_header, mock_keys, mock_models, mock_error, mock_success): +def test_test_connection_api_failure_no_success( + mock_console, mock_header, mock_keys, mock_models, mock_error, mock_success +): """API failure path must not call success().""" from aipass.api.apps.modules import openrouter_client @@ -381,15 +381,14 @@ def test_list_models_limits_to_10(mock_console, mock_header, mock_keys, mock_mod # Count data rows: calls that contain a model ID pattern data_row_calls = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0] ] assert len(data_row_calls) == 10 # Should show "Showing 10 of 25" truncation notice - all_output = " ".join( - str(c) for c in mock_console.print.call_args_list - ) + all_output = " ".join(str(c) for c in mock_console.print.call_args_list) assert "10 of 25" in all_output @@ -416,7 +415,8 @@ def test_list_models_all_flag_shows_everything(mock_console, mock_header, mock_k openrouter_client.list_models(["--all"]) data_row_calls = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0] ] assert len(data_row_calls) == 25 @@ -537,7 +537,8 @@ def test_list_models_formats_million_context(mock_console, mock_header, mock_key openrouter_client.list_models([]) data_rows = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "big/model" in c.args[0] ] assert len(data_rows) == 1 @@ -561,7 +562,8 @@ def test_list_models_formats_thousand_context(mock_console, mock_header, mock_ke openrouter_client.list_models([]) data_rows = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "med/model" in c.args[0] ] assert len(data_rows) == 1 @@ -585,7 +587,8 @@ def test_list_models_formats_free_pricing(mock_console, mock_header, mock_keys, openrouter_client.list_models([]) data_rows = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "free/model" in c.args[0] ] assert len(data_rows) == 1 diff --git a/src/aipass/api/tests/test_usage_tracker.py b/src/aipass/api/tests/test_usage_tracker.py index 65e9aa30..638653eb 100644 --- a/src/aipass/api/tests/test_usage_tracker.py +++ b/src/aipass/api/tests/test_usage_tracker.py @@ -177,9 +177,7 @@ def test_handle_command_logs_operation(mock_jh, mock_header, mock_console): usage_tracker.handle_command("stats", []) - mock_jh.log_operation.assert_called_once_with( - "usage_stats", {"command": "stats"} - ) + mock_jh.log_operation.assert_called_once_with("usage_stats", {"command": "stats"}) # ============================================= @@ -229,7 +227,8 @@ def test_show_stats_no_data(mock_agg, mock_header, mock_console, mock_warning): mock_warning.assert_called_once_with("No usage data available") # Verify no stat data was printed to console stat_calls = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "Total Requests" in c.args[0] ] assert len(stat_calls) == 0, "No stat rows should be printed when data is empty" @@ -279,7 +278,8 @@ def test_show_session_no_data(mock_agg, mock_header, mock_console, mock_warning) mock_warning.assert_called_once_with("No session data available") # Verify no session stat data was printed to console stat_calls = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "Session Requests" in c.args[0] ] assert len(stat_calls) == 0, "No session rows should be printed when data is empty" @@ -330,7 +330,8 @@ def test_show_caller_usage_no_data(mock_agg, mock_header, mock_console, mock_war mock_warning.assert_called_once_with("No usage data found for caller: ghost_caller") # Verify no usage data rows were printed to console usage_calls = [ - c for c in mock_console.print.call_args_list + c + for c in mock_console.print.call_args_list if c.args and isinstance(c.args[0], str) and "Requests" in c.args[0] ] assert len(usage_calls) == 0, "No usage rows should be printed when data is empty" diff --git a/src/aipass/cli/__init__.py b/src/aipass/cli/__init__.py index 1852b737..63525824 100644 --- a/src/aipass/cli/__init__.py +++ b/src/aipass/cli/__init__.py @@ -1,4 +1,5 @@ """CLI - Display formatting for AIPass.""" + import sys from aipass.cli.apps.modules.display import console, header, success, error, warning, section diff --git a/src/aipass/cli/apps/__init__.py b/src/aipass/cli/apps/__init__.py index 1d09dbea..7d29d333 100644 --- a/src/aipass/cli/apps/__init__.py +++ b/src/aipass/cli/apps/__init__.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: apps/__init__.py diff --git a/src/aipass/cli/apps/cli.py b/src/aipass/cli/apps/cli.py index 50377c15..0135ef0a 100755 --- a/src/aipass/cli/apps/cli.py +++ b/src/aipass/cli/apps/cli.py @@ -52,6 +52,7 @@ SERVICE_MODULES = {"display", "templates"} # MODULE DISCOVERY # ============================================================================= + def discover_modules() -> List[Any]: """Auto-discover CLI modules in modules/ directory. @@ -97,6 +98,7 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: # DISPLAY # ============================================================================= + def print_introspection() -> None: """Display auto-discovered modules — seedgo-compliant introspection. @@ -106,10 +108,8 @@ def print_introspection() -> None: modules = discover_modules() # Separate command modules from service modules - command_modules = [m for m in modules - if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES] - service_modules = [m for m in modules - if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES] + command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES] + service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES] CONSOLE.print() CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]") @@ -199,15 +199,9 @@ def print_help() -> None: services_table.add_column("Purpose", style="dim") services_table.add_row( - "display", - "header(), success(), error(), warning(), section()", - "Terminal output formatting" - ) - services_table.add_row( - "templates", - "operation_start(), operation_complete()", - "Standard operation patterns" + "display", "header(), success(), error(), warning(), section()", "Terminal output formatting" ) + services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns") CONSOLE.print(services_table) CONSOLE.print() @@ -271,6 +265,7 @@ def show_version(): # MAIN ENTRY POINT # ============================================================================= + def main() -> int: """Main entry point - routes to modules.""" modules = discover_modules() diff --git a/src/aipass/cli/apps/handlers/__init__.py b/src/aipass/cli/apps/handlers/__init__.py index 228443ab..bb8eee9a 100755 --- a/src/aipass/cli/apps/handlers/__init__.py +++ b/src/aipass/cli/apps/handlers/__init__.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: handlers/__init__.py @@ -22,7 +21,7 @@ Usage: import inspect from pathlib import Path -__version__ = '1.0.0' +__version__ = "1.0.0" MY_BRANCH = "cli" @@ -83,8 +82,10 @@ def _guard_branch_access(): # DEBUG: Print what we found import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) @@ -108,9 +109,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -124,7 +125,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py index 2bfb1939..1c59cc30 100644 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -298,11 +298,11 @@ def _global_prompt_md(name: str) -> str: "\n" "### Dispatch — Send Task + Wake an Agent (DEFAULT)\n" "```\n" - "drone @ai_mail dispatch @ \"Subject\" \"Body\" # Send + wake (default)\n" - "drone @ai_mail dispatch @ \"Subject\" \"Body\" --fresh # Send + wake fresh session\n" + 'drone @ai_mail dispatch @ "Subject" "Body" # Send + wake (default)\n' + 'drone @ai_mail dispatch @ "Subject" "Body" --fresh # Send + wake fresh session\n' "drone @ai_mail dispatch wake @ # Wake without sending\n" "drone @ai_mail dispatch wake --fresh @ # Wake fresh\n" - "drone @ai_mail email @ \"Subject\" \"Body\" # FYI only (no wake)\n" + 'drone @ai_mail email @ "Subject" "Body" # FYI only (no wake)\n' "```\n" "\n" "Use `dispatch` by default. Use `email` only when you don't need the agent to act now.\n" @@ -316,14 +316,14 @@ def _global_prompt_md(name: str) -> str: "\n" "### Feedback\n" "```\n" - "drone @devpulse feedback send \"Subject\" \"Body\" # Send feedback (cross-project)\n" + 'drone @devpulse feedback send "Subject" "Body" # Send feedback (cross-project)\n' "```\n" "\n" "### Plans (flow)\n" "```\n" - "drone @flow create . \"Subject\" dplan # Create DPLAN (design/thinking)\n" - "drone @flow create . \"Subject\" master # Create FPLAN master (execution)\n" - "drone @flow create . \"Subject\" aplan # Create APLAN (agent-level task)\n" + 'drone @flow create . "Subject" dplan # Create DPLAN (design/thinking)\n' + 'drone @flow create . "Subject" master # Create FPLAN master (execution)\n' + 'drone @flow create . "Subject" aplan # Create APLAN (agent-level task)\n' "drone @flow list open # List active plans\n" "drone @flow list # List all plans\n" "drone @flow close # Close a plan\n" @@ -422,9 +422,9 @@ def _claude_settings(aipass_home: str | None = None) -> str: """ _local_prompt_cmd = ( 'python3 -c "' - 'from pathlib import Path; ' + "from pathlib import Path; " "p=next((x/'.aipass'/'aipass_local_prompt.md' " - 'for x in [Path.cwd(),*Path.cwd().parents] ' + "for x in [Path.cwd(),*Path.cwd().parents] " "if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); " "p and print(p.read_text(encoding='utf-8'),end='')" '"' @@ -545,16 +545,19 @@ def _prep_md() -> str: def _inbox_json() -> str: """Generate .ai_mail.local/inbox.json — empty project mailbox structure.""" - return json.dumps( - { - "mailbox": "inbox", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }, - indent=2, - ensure_ascii=False, - ) + "\n" + return ( + json.dumps( + { + "mailbox": "inbox", + "total_messages": 0, + "unread_count": 0, + "messages": [], + }, + indent=2, + ensure_ascii=False, + ) + + "\n" + ) def _with_source(content: str, file_path: Path) -> str: @@ -590,10 +593,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict: raw_name = project_name or target.name name = _sanitize_name(raw_name) if not name: - raise ValueError( - f"Cannot derive project name from '{raw_name}'. " - "Pass a project name explicitly." - ) + raise ValueError(f"Cannot derive project name from '{raw_name}'. Pass a project name explicitly.") registry_id = str(uuid.uuid4()) today = date.today().isoformat() @@ -675,12 +675,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict: 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" - "## Current Work\n\n" - "## Known Issues\n" - "- None\n", + f"# {name}\n\n**State:** New\n**Last update:** {today}\n\n## Current Work\n\n## Known Issues\n- None\n", encoding="utf-8", ) created.append(str(status_md_path)) @@ -761,9 +756,7 @@ def update_project(target: Path) -> dict: # derive the project name without parsing JSON (filename encodes the name). registry_files = list(target.glob("*_REGISTRY.json")) if not registry_files: - raise ValueError( - "No AIPass project found — run 'aipass init' first" - ) + raise ValueError("No AIPass project found — run 'aipass init' first") registry_path = registry_files[0] name = registry_path.stem.replace("_REGISTRY", "") diff --git a/src/aipass/cli/apps/handlers/json/__init__.py b/src/aipass/cli/apps/handlers/json/__init__.py index 72fa95d0..6550c68a 100644 --- a/src/aipass/cli/apps/handlers/json/__init__.py +++ b/src/aipass/cli/apps/handlers/json/__init__.py @@ -5,4 +5,4 @@ This handler manages JSON operations for the CLI branch. It provides utilities for reading, writing, and managing JSON files. """ -__all__ = [] \ No newline at end of file +__all__ = [] diff --git a/src/aipass/cli/apps/handlers/json/json_handler.py b/src/aipass/cli/apps/handlers/json/json_handler.py index 4fa568b7..b583bd13 100755 --- a/src/aipass/cli/apps/handlers/json/json_handler.py +++ b/src/aipass/cli/apps/handlers/json/json_handler.py @@ -15,7 +15,7 @@ from typing import Dict, Any, Optional import inspect # Constants — resolved via __file__ (portable across any machine) -_BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> cli/ +_BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> cli/ _BRANCH_NAME = _BRANCH_ROOT.name JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" @@ -35,7 +35,7 @@ def _get_caller_module_name() -> str: module_name = caller_path.stem # Validate module name - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name # Fallback @@ -74,16 +74,16 @@ def validate_json_structure(data: Any, json_type: str) -> bool: return False required = ["module_name", "version", "config"] return all(key in data for key in required) - + elif json_type == "data": if not isinstance(data, dict): return False required = ["created", "last_updated"] return all(key in data for key in required) - + elif json_type == "log": return isinstance(data, list) - + return False @@ -101,7 +101,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: if json_path.exists(): try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if validate_json_structure(data, json_type): @@ -112,7 +112,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: template = _create_default(json_type, module_name) - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(template, f, indent=2, ensure_ascii=False) return True @@ -124,7 +124,7 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: json_path = get_json_path(module_name, json_type) - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: return json.load(f) @@ -138,7 +138,7 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: if json_type == "data" and isinstance(data, dict): data["last_updated"] = datetime.now().date().isoformat() - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True @@ -185,10 +185,7 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam log = [] # Create new entry - entry = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } + entry = {"timestamp": datetime.now().isoformat(), "operation": operation} if data: entry["data"] = data # type: ignore[assignment] @@ -210,10 +207,7 @@ if __name__ == "__main__": console = Console() console.print() - console.print(Panel.fit( - "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", - border_style="bright_blue" - )) + console.print(Panel.fit("[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", border_style="bright_blue")) console.print() console.print("[yellow]TESTING:[/yellow] Creating CLI JSONs...") diff --git a/src/aipass/cli/apps/handlers/templates/__init__.py b/src/aipass/cli/apps/handlers/templates/__init__.py index 8ffc023e..8e96e267 100644 --- a/src/aipass/cli/apps/handlers/templates/__init__.py +++ b/src/aipass/cli/apps/handlers/templates/__init__.py @@ -33,4 +33,4 @@ Examples of Future Handlers: # META: HANDLER_TEMPLATES # STATUS: Ready for handlers -# PATTERN: Service provider handler layer \ No newline at end of file +# PATTERN: Service provider handler layer diff --git a/src/aipass/cli/apps/modules/__init__.py b/src/aipass/cli/apps/modules/__init__.py index 68b3e9df..566d4749 100644 --- a/src/aipass/cli/apps/modules/__init__.py +++ b/src/aipass/cli/apps/modules/__init__.py @@ -24,35 +24,23 @@ PATTERN (from Prax): from aipass.cli.apps.modules.display import console, err_console # Display functions -from aipass.cli.apps.modules.display import ( - header, - success, - error, - warning, - fatal, - section -) +from aipass.cli.apps.modules.display import header, success, error, warning, fatal, section # Operation templates -from aipass.cli.apps.modules.templates import ( - operation_start, - operation_complete -) +from aipass.cli.apps.modules.templates import operation_start, operation_complete __all__ = [ # Rich console (primary service) - 'console', - 'err_console', - + "console", + "err_console", # Display - 'header', - 'success', - 'error', - 'warning', - 'fatal', - 'section', - + "header", + "success", + "error", + "warning", + "fatal", + "section", # Templates - 'operation_start', - 'operation_complete', + "operation_start", + "operation_complete", ] diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py index c9c930db..6dd92a00 100755 --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -53,6 +53,7 @@ _TRIGGER_LOADED = False # MODULE PATTERN FUNCTIONS (SEEDGO compliant) # ============================================================================ + def print_introspection(): """Display module info and connected handlers""" CONSOLE.print() @@ -102,7 +103,9 @@ 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] Six core display functions ([green]header, success, error, warning, fatal, 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() @@ -143,7 +146,7 @@ def print_help(): usage_examples = [ "[yellow]Module Info:[/yellow]\n [dim]drone @cli display[/dim]", "[yellow]Run Demo:[/yellow]\n [dim]drone @cli display demo[/dim]", - "[yellow]Show Help:[/yellow]\n [dim]drone @cli display --help[/dim]" + "[yellow]Show Help:[/yellow]\n [dim]drone @cli display --help[/dim]", ] # RICH FORMATTING TIP: Columns creates side-by-side layout @@ -188,7 +191,9 @@ def print_help(): CONSOLE.print("[bold cyan]INTEGRATION:[/bold cyan]") CONSOLE.print() CONSOLE.print(" [green]✓[/green] [bold]Rich Formatting:[/bold] Beautiful terminal output with colors and styles") - CONSOLE.print(" [green]✓[/green] [bold]All Branches:[/bold] Import and use display functions for consistent output") + CONSOLE.print( + " [green]✓[/green] [bold]All Branches:[/bold] Import and use display functions for consistent output" + ) CONSOLE.print(" [green]✓[/green] [bold]Rich Library:[/bold] Built on Rich for beautiful terminal formatting") CONSOLE.print() CONSOLE.print("─" * 70) @@ -277,6 +282,7 @@ def run_demo(): # PUBLIC API FUNCTIONS (Keep existing - don't break compatibility) # ============================================================================ + def header(title: str, details: Optional[Dict[str, Any]] = None) -> None: """ Display bordered section header using Rich Panel @@ -299,11 +305,12 @@ def header(title: str, details: Optional[Dict[str, Any]] = None) -> None: _TRIGGER_LOADED = True try: from aipass.trigger.apps.modules.core import trigger as t + _TRIGGER = t except ImportError: pass if _TRIGGER: - _TRIGGER.fire('cli_header_displayed', title=title) + _TRIGGER.fire("cli_header_displayed", title=title) CONSOLE.print() @@ -395,15 +402,15 @@ def section(title: str) -> None: # Note: __all__ uses lowercase by convention (Python standard library pattern) __all__ = [ - 'console', # Primary export (service instance pattern) - 'CONSOLE', # Internal constant (kept for backward compatibility) - 'err_console', # Stderr console for error/warning output - 'header', - 'success', - 'error', - 'warning', - 'fatal', - 'section', + "console", # Primary export (service instance pattern) + "CONSOLE", # Internal constant (kept for backward compatibility) + "err_console", # Stderr console for error/warning output + "header", + "success", + "error", + "warning", + "fatal", + "section", ] # ============================================================================ @@ -418,7 +425,7 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag (drone compliance) - if sys.argv[1] in ['--help', '-h', 'help']: + if sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py index 69f76197..e263e3e1 100644 --- a/src/aipass/cli/apps/modules/init_project.py +++ b/src/aipass/cli/apps/modules/init_project.py @@ -31,6 +31,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger # MODULE PATTERN FUNCTIONS (SEEDGO compliant) # ============================================================================= + def print_introspection(): """Display aipass command info — available subcommands and connected handlers.""" from rich.table import Table @@ -97,7 +98,9 @@ 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 init agent [/green] [dim]Create an agent in current project[/dim]" + ) console.print(" [green]drone @cli aipass init update[/green] [dim]Refresh managed scaffold files[/dim]") console.print(" [green]drone @cli aipass --help[/green] [dim]This help message[/dim]") console.print() @@ -266,11 +269,14 @@ def _handle_init(args: List[str]) -> bool: console.print("[dim]For terminal usage, add to your shell profile:[/dim]") console.print(f" [green]export AIPASS_HOME={result['aipass_home']}[/green]") - json_handler.log_operation("aipass_init", { - "project_name": result["project_name"], - "target": result["target"], - "files_created": len(result["created_files"]), - }) + json_handler.log_operation( + "aipass_init", + { + "project_name": result["project_name"], + "target": result["target"], + "files_created": len(result["created_files"]), + }, + ) # Next steps console.print() @@ -387,9 +393,7 @@ def _handle_init_update(args: List[str]) -> bool: # Updated files table (only show if something changed) if result["updated_files"]: - updated_table = Table( - show_header=True, header_style="bold cyan", border_style="dim", title="Updated" - ) + updated_table = Table(show_header=True, header_style="bold cyan", border_style="dim", title="Updated") updated_table.add_column("#", style="green", width=3) updated_table.add_column("File", style="yellow") for i, f in enumerate(result["updated_files"], 1): @@ -398,9 +402,7 @@ def _handle_init_update(args: List[str]) -> bool: # Already current table if already_current: - current_table = Table( - show_header=True, header_style="bold cyan", border_style="dim", title="Already current" - ) + current_table = Table(show_header=True, header_style="bold cyan", border_style="dim", title="Already current") current_table.add_column("#", style="dim", width=3) current_table.add_column("File", style="dim") for i, f in enumerate(already_current, 1): @@ -408,9 +410,7 @@ def _handle_init_update(args: List[str]) -> bool: console.print(current_table) # Skipped files table - skipped_table = Table( - show_header=True, header_style="bold cyan", border_style="dim", title="User-owned (skipped)" - ) + skipped_table = Table(show_header=True, header_style="bold cyan", border_style="dim", title="User-owned (skipped)") skipped_table.add_column("#", style="dim", width=3) skipped_table.add_column("File", style="dim") for i, f in enumerate(result["skipped_files"], 1): @@ -423,13 +423,16 @@ def _handle_init_update(args: List[str]) -> bool: else: success("All files already up to date") - json_handler.log_operation("aipass_init_update", { - "project_name": result["project_name"], - "target": result["target"], - "files_updated": len(result["updated_files"]), - "files_already_current": len(already_current), - "files_skipped": len(result["skipped_files"]), - }) + json_handler.log_operation( + "aipass_init_update", + { + "project_name": result["project_name"], + "target": result["target"], + "files_updated": len(result["updated_files"]), + "files_already_current": len(already_current), + "files_skipped": len(result["skipped_files"]), + }, + ) return True diff --git a/src/aipass/cli/apps/modules/templates.py b/src/aipass/cli/apps/modules/templates.py index 47bd005c..4343f786 100755 --- a/src/aipass/cli/apps/modules/templates.py +++ b/src/aipass/cli/apps/modules/templates.py @@ -30,6 +30,7 @@ from aipass.cli.apps.handlers.json import json_handler # MODULE PATTERN FUNCTIONS (SEEDGO compliant) # ============================================================================ + def print_introspection(): """Display module info and connected handlers""" CONSOLE.print() @@ -155,6 +156,7 @@ def run_demo(): # PUBLIC API FUNCTIONS (Keep existing - don't break compatibility) # ============================================================================ + def operation_start(operation: str, **details) -> None: """ Display standard operation start template with Rich styling @@ -191,7 +193,7 @@ def operation_complete(**summary) -> None: for key, value in summary.items(): CONSOLE.print(f" {key}: {value}") - if summary.get('time'): + if summary.get("time"): CONSOLE.print(f" [dim]Completed in {summary['time']}[/dim]") CONSOLE.print() @@ -207,7 +209,7 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag (drone compliance) - if sys.argv[1] in ['--help', '-h', 'help']: + if sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/cli/apps/plugins/__init__.py b/src/aipass/cli/apps/plugins/__init__.py index 27c9c6cd..6869836b 100644 --- a/src/aipass/cli/apps/plugins/__init__.py +++ b/src/aipass/cli/apps/plugins/__init__.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: plugins/__init__.py @@ -19,5 +18,4 @@ Usage: from aipass.cli.apps.plugins import PluginManager """ -__version__ = '1.0.0' - +__version__ = "1.0.0" diff --git a/src/aipass/cli/tests/conftest.py b/src/aipass/cli/tests/conftest.py index 07c00599..9685f9e7 100644 --- a/src/aipass/cli/tests/conftest.py +++ b/src/aipass/cli/tests/conftest.py @@ -7,6 +7,7 @@ # ============================================= """Shared pytest fixtures for CLI tests.""" + import os import tempfile diff --git a/src/aipass/cli/tests/test_bootstrap.py b/src/aipass/cli/tests/test_bootstrap.py index fcdf819b..c39dfca1 100644 --- a/src/aipass/cli/tests/test_bootstrap.py +++ b/src/aipass/cli/tests/test_bootstrap.py @@ -371,9 +371,7 @@ def test_init_project_skips_existing_optional_files(tmp_path): # 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_global_prompt.md").write_text("# Custom global\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") @@ -562,6 +560,7 @@ def test_update_project_creates_missing_managed_dirs(tmp_path): # Delete only the managed subdirectories (not root files like CLAUDE.md) import shutil + shutil.rmtree(target / ".aipass") shutil.rmtree(target / ".claude") diff --git a/src/aipass/cli/tests/test_display.py b/src/aipass/cli/tests/test_display.py index b7aedd87..0fd7197a 100644 --- a/src/aipass/cli/tests/test_display.py +++ b/src/aipass/cli/tests/test_display.py @@ -15,6 +15,7 @@ from aipass.cli.apps.modules import display # Helpers # ============================================================================= + def _make_capture_console(): """Return (console, get_output) for capturing Rich output. @@ -33,6 +34,7 @@ def _make_capture_console(): # handle_command routing tests # ============================================================================= + class TestHandleCommandRouting: """Verify handle_command dispatches to the correct function and returns the right bool.""" @@ -102,23 +104,28 @@ class TestHandleCommandRouting: # header() output tests # ============================================================================= + class TestHeader: """Verify header() renders title and optional details.""" def test_header_contains_title(self): cons, get_output = _make_capture_console() - with patch.object(display, "CONSOLE", cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True): + with ( + patch.object(display, "CONSOLE", cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + ): display.header("My Title") output = get_output() assert "My Title" in output def test_header_renders_details(self): cons, get_output = _make_capture_console() - with patch.object(display, "CONSOLE", cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True): + with ( + patch.object(display, "CONSOLE", cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + ): display.header("Build", details={"Branch": "main", "Status": "ok"}) output = get_output() assert "Branch:" in output @@ -128,9 +135,11 @@ class TestHeader: def test_header_without_details_omits_kv(self): cons, get_output = _make_capture_console() - with patch.object(display, "CONSOLE", cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True): + with ( + patch.object(display, "CONSOLE", cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + ): display.header("Solo Title") output = get_output() # Should have the title but not a key-value separator pattern @@ -139,9 +148,11 @@ class TestHeader: def test_header_fires_trigger_when_available(self): cons, _get_output = _make_capture_console() mock_trigger = MagicMock() - with patch.object(display, "CONSOLE", cons), \ - patch.object(display, "_TRIGGER", mock_trigger), \ - patch.object(display, "_TRIGGER_LOADED", True): + with ( + patch.object(display, "CONSOLE", cons), + patch.object(display, "_TRIGGER", mock_trigger), + patch.object(display, "_TRIGGER_LOADED", True), + ): display.header("Triggered Title") mock_trigger.fire.assert_called_once_with("cli_header_displayed", title="Triggered Title") @@ -150,6 +161,7 @@ class TestHeader: # success() output tests # ============================================================================= + class TestSuccess: """Verify success() renders message and kwargs.""" @@ -173,6 +185,7 @@ class TestSuccess: # error() output tests # ============================================================================= + class TestError: """Verify error() renders to stderr console with optional suggestion.""" @@ -203,6 +216,7 @@ class TestError: # warning() output tests # ============================================================================= + class TestWarning: """Verify warning() renders to stderr console with optional details.""" @@ -225,6 +239,7 @@ class TestWarning: # section() output tests # ============================================================================= + class TestSection: """Verify section() renders title and separator.""" @@ -247,6 +262,7 @@ class TestSection: # run_demo() integration test # ============================================================================= + class TestRunDemo: """Verify run_demo logs operation and produces output.""" @@ -254,10 +270,12 @@ class TestRunDemo: def test_run_demo_logs_operation(self, mock_log): cons, _ = _make_capture_console() err_cons, _ = _make_capture_console() - with patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True): + with ( + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + ): display.run_demo() mock_log.assert_called_once_with("display_demo") @@ -265,10 +283,12 @@ class TestRunDemo: def test_run_demo_renders_expected_content(self, mock_log): cons, get_output = _make_capture_console() err_cons, get_err_output = _make_capture_console() - with patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True): + with ( + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + ): display.run_demo() output = get_output() assert "Demo" in output @@ -280,6 +300,7 @@ class TestRunDemo: # fatal() output tests # ============================================================================= + class TestFatal: """Verify fatal() renders error to stderr console and exits with code 1.""" @@ -316,6 +337,7 @@ class TestFatal: # Infrastructure mocking tests # ============================================================================= + class TestInfrastructureMocking: """Verify display module can be safely reloaded after sys.modules mocking.""" diff --git a/src/aipass/cli/tests/test_init_project.py b/src/aipass/cli/tests/test_init_project.py index ebf04c91..592a0b20 100644 --- a/src/aipass/cli/tests/test_init_project.py +++ b/src/aipass/cli/tests/test_init_project.py @@ -14,6 +14,7 @@ from aipass.cli.apps.modules.init_project import handle_command, _handle_init # Helpers # ============================================================================= + def _make_capture_console(): """Return (console, get_output) for capturing Rich output.""" buf = StringIO() @@ -25,6 +26,7 @@ def _make_capture_console(): # handle_command routing tests # ============================================================================= + class TestHandleCommandRouting: """Verify handle_command dispatches correctly and returns the right boolean.""" @@ -80,8 +82,7 @@ class TestHandleCommandRouting: """Unknown aipass subcommand shows error and returns True.""" cons, get_output = _make_capture_console() err_cons, get_err = _make_capture_console() - with patch.object(init_project, "console", cons), \ - patch.object(init_project, "error") as mock_error: + with patch.object(init_project, "console", cons), patch.object(init_project, "error") as mock_error: result = handle_command("aipass", ["bogus"]) assert result is True mock_error.assert_called_once() @@ -93,6 +94,7 @@ class TestHandleCommandRouting: # _handle_init tests # ============================================================================= + class TestHandleInit: """Tests for the init subcommand orchestration.""" @@ -110,13 +112,16 @@ class TestHandleInit: err_cons, get_err = _make_capture_console() from aipass.cli.apps.modules import display - with patch.object(init_project, "console", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True), \ - patch.object(init_project, "json_handler") as mock_json, \ - patch.object(init_project, "logger"): + + with ( + patch.object(init_project, "console", cons), + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + patch.object(init_project, "json_handler") as mock_json, + patch.object(init_project, "logger"), + ): result = _handle_init([str(target)]) assert result is True @@ -128,10 +133,12 @@ class TestHandleInit: def test_init_value_error_exits(self, tmp_path): """ValueError from init_project causes error display and sys.exit(1).""" - with patch.object(init_project, "init_project", side_effect=ValueError("bad name")), \ - patch.object(init_project, "error") as mock_error, \ - patch.object(init_project, "logger"), \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.object(init_project, "init_project", side_effect=ValueError("bad name")), + patch.object(init_project, "error") as mock_error, + patch.object(init_project, "logger"), + pytest.raises(SystemExit) as exc_info, + ): _handle_init([str(tmp_path)]) assert exc_info.value.code == 1 mock_error.assert_called_once() @@ -139,20 +146,24 @@ class TestHandleInit: def test_init_file_exists_error_exits(self, tmp_path): """FileExistsError from init_project causes error display and sys.exit(1).""" - with patch.object(init_project, "init_project", side_effect=FileExistsError("already exists")), \ - patch.object(init_project, "error") as mock_error, \ - patch.object(init_project, "logger"), \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.object(init_project, "init_project", side_effect=FileExistsError("already exists")), + patch.object(init_project, "error") as mock_error, + patch.object(init_project, "logger"), + pytest.raises(SystemExit) as exc_info, + ): _handle_init([str(tmp_path)]) assert exc_info.value.code == 1 mock_error.assert_called_once() def test_init_os_error_exits(self, tmp_path): """OSError from init_project causes error display and sys.exit(1).""" - with patch.object(init_project, "init_project", side_effect=OSError("disk full")), \ - patch.object(init_project, "error") as mock_error, \ - patch.object(init_project, "logger"), \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.object(init_project, "init_project", side_effect=OSError("disk full")), + patch.object(init_project, "error") as mock_error, + patch.object(init_project, "logger"), + pytest.raises(SystemExit) as exc_info, + ): _handle_init([str(tmp_path)]) assert exc_info.value.code == 1 mock_error.assert_called_once() @@ -160,6 +171,7 @@ class TestHandleInit: def test_init_uses_caller_cwd_env(self, tmp_path): """When no target arg, uses AIPASS_CALLER_CWD env var.""" import os + target = tmp_path / "env_project" target.mkdir() @@ -167,14 +179,17 @@ class TestHandleInit: err_cons, get_err = _make_capture_console() from aipass.cli.apps.modules import display - with patch.dict(os.environ, {"AIPASS_CALLER_CWD": str(target)}), \ - patch.object(init_project, "console", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True), \ - patch.object(init_project, "json_handler"), \ - patch.object(init_project, "logger"): + + with ( + patch.dict(os.environ, {"AIPASS_CALLER_CWD": str(target)}), + patch.object(init_project, "console", cons), + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + patch.object(init_project, "json_handler"), + patch.object(init_project, "logger"), + ): result = _handle_init([]) assert result is True diff --git a/src/aipass/cli/tests/test_integration.py b/src/aipass/cli/tests/test_integration.py index bf00484e..9968d906 100644 --- a/src/aipass/cli/tests/test_integration.py +++ b/src/aipass/cli/tests/test_integration.py @@ -27,6 +27,7 @@ from aipass.cli.apps.modules import display # Helpers # ============================================================================= + def _make_capture_console(): """Return (console, get_output) for capturing Rich output. @@ -45,15 +46,18 @@ def _make_capture_console(): # main() flow tests — mock sys.argv to simulate CLI invocation # ============================================================================= + class TestMainFlow: """Integration tests for the main() entry point.""" def test_main_no_args_returns_zero(self): """No args shows introspection and returns 0.""" cons, _get_output = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch("sys.argv", ["cli"]): + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch("sys.argv", ["cli"]), + ): result = main() assert result == 0 @@ -61,21 +65,25 @@ class TestMainFlow: """--help returns 0.""" cons, _get_output = _make_capture_console() err_cons, _get_err = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True), \ - patch("sys.argv", ["cli", "--help"]): + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + patch("sys.argv", ["cli", "--help"]), + ): result = main() assert result == 0 def test_main_version_flag_returns_zero(self): """--version returns 0.""" cons, get_output = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch("sys.argv", ["cli", "--version"]): + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch("sys.argv", ["cli", "--version"]), + ): result = main() assert result == 0 output = get_output() @@ -85,10 +93,12 @@ class TestMainFlow: """Unknown command returns 1.""" cons, _get_output = _make_capture_console() err_cons, get_err = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]): + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]), + ): result = main() assert result == 1 err_output = get_err() @@ -98,12 +108,14 @@ class TestMainFlow: """'aipass init --help' returns 0.""" cons, _get_output = _make_capture_console() err_cons, _get_err = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True), \ - patch("sys.argv", ["cli", "aipass", "init", "--help"]): + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + patch("sys.argv", ["cli", "aipass", "init", "--help"]), + ): result = main() assert result == 0 @@ -111,13 +123,15 @@ class TestMainFlow: """'display demo' returns 0.""" cons, _get_output = _make_capture_console() err_cons, _get_err = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch.object(display, "err_console", err_cons), \ - patch.object(display, "_TRIGGER", None), \ - patch.object(display, "_TRIGGER_LOADED", True), \ - patch("aipass.cli.apps.handlers.json.json_handler.log_operation"), \ - patch("sys.argv", ["cli", "display", "demo"]): + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch.object(display, "err_console", err_cons), + patch.object(display, "_TRIGGER", None), + patch.object(display, "_TRIGGER_LOADED", True), + patch("aipass.cli.apps.handlers.json.json_handler.log_operation"), + patch("sys.argv", ["cli", "display", "demo"]), + ): result = main() assert result == 0 @@ -126,6 +140,7 @@ class TestMainFlow: # __main__.py test — verify module is runnable # ============================================================================= + class TestModuleRunnable: """Verify python -m aipass.cli works as a subprocess.""" @@ -143,9 +158,11 @@ class TestModuleRunnable: def test_cli_entry_callable(self): """cli_entry() is the console_scripts entry point — verify it's callable.""" cons, _get_output = _make_capture_console() - with patch.object(cli_module, "CONSOLE", cons), \ - patch.object(display, "CONSOLE", cons), \ - patch("sys.argv", ["aipass", "--version"]), \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.object(cli_module, "CONSOLE", cons), + patch.object(display, "CONSOLE", cons), + patch("sys.argv", ["aipass", "--version"]), + pytest.raises(SystemExit) as exc_info, + ): cli_entry() assert exc_info.value.code == 0 diff --git a/src/aipass/cli/tests/test_json_handler.py b/src/aipass/cli/tests/test_json_handler.py index fe656a1c..83d694f2 100644 --- a/src/aipass/cli/tests/test_json_handler.py +++ b/src/aipass/cli/tests/test_json_handler.py @@ -322,9 +322,7 @@ class TestLogOperation: with patch.object(json_handler, "JSON_DIR", tmp_path): json_handler.log_operation("deploy", module_name="cli") - log = json.loads( - (tmp_path / "cli_log.json").read_text(encoding="utf-8") - ) + log = json.loads((tmp_path / "cli_log.json").read_text(encoding="utf-8")) assert len(log) == 1 assert log[0]["operation"] == "deploy" assert "timestamp" in log[0] @@ -332,13 +330,9 @@ class TestLogOperation: def test_logs_entry_with_data(self, tmp_path): """Data dict should be nested inside the log entry.""" with patch.object(json_handler, "JSON_DIR", tmp_path): - json_handler.log_operation( - "sync", data={"count": 5}, module_name="cli" - ) + json_handler.log_operation("sync", data={"count": 5}, module_name="cli") - log = json.loads( - (tmp_path / "cli_log.json").read_text(encoding="utf-8") - ) + log = json.loads((tmp_path / "cli_log.json").read_text(encoding="utf-8")) assert log[0]["data"]["count"] == 5 def test_rotation_trims_to_max_entries(self, tmp_path): @@ -350,19 +344,13 @@ class TestLogOperation: "config": {"max_log_entries": 3}, "created": "2026-01-01", } - (tmp_path / "cli_config.json").write_text( - json.dumps(config), encoding="utf-8" - ) + (tmp_path / "cli_config.json").write_text(json.dumps(config), encoding="utf-8") with patch.object(json_handler, "JSON_DIR", tmp_path): for i in range(5): - json_handler.log_operation( - f"op_{i}", module_name="cli" - ) + json_handler.log_operation(f"op_{i}", module_name="cli") - log = json.loads( - (tmp_path / "cli_log.json").read_text(encoding="utf-8") - ) + log = json.loads((tmp_path / "cli_log.json").read_text(encoding="utf-8")) assert len(log) == 3 # Oldest two (op_0, op_1) should be gone; newest three remain operations = [entry["operation"] for entry in log] @@ -374,9 +362,7 @@ class TestLogOperation: json_handler.log_operation("first", module_name="cli") json_handler.log_operation("second", module_name="cli") - log = json.loads( - (tmp_path / "cli_log.json").read_text(encoding="utf-8") - ) + log = json.loads((tmp_path / "cli_log.json").read_text(encoding="utf-8")) assert len(log) == 2 assert log[0]["operation"] == "first" assert log[1]["operation"] == "second" @@ -456,8 +442,6 @@ class TestEdgeCases: with patch.object(json_handler, "JSON_DIR", tmp_path): json_handler.log_operation("op", data={}, module_name="mod") - log = json.loads( - (tmp_path / "mod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((tmp_path / "mod_log.json").read_text(encoding="utf-8")) assert len(log) == 1 assert "data" not in log[0] diff --git a/src/aipass/cli/tests/test_templates.py b/src/aipass/cli/tests/test_templates.py index f99dd8ae..1def7317 100644 --- a/src/aipass/cli/tests/test_templates.py +++ b/src/aipass/cli/tests/test_templates.py @@ -164,9 +164,7 @@ class TestOperationComplete: # No key-value summary lines should appear after "Summary:" lines = output.splitlines() summary_idx = next(i for i, line in enumerate(lines) if "Summary:" in line) - after_summary = [ - line.strip() for line in lines[summary_idx + 1:] if line.strip() - ] + after_summary = [line.strip() for line in lines[summary_idx + 1 :] if line.strip()] kv_lines = [line for line in after_summary if ": " in line] assert len(kv_lines) == 0 diff --git a/src/aipass/devpulse/apps/devpulse.py b/src/aipass/devpulse/apps/devpulse.py index 105e7dbd..63f298db 100644 --- a/src/aipass/devpulse/apps/devpulse.py +++ b/src/aipass/devpulse/apps/devpulse.py @@ -95,12 +95,11 @@ def route_command(command: str, args: list[str], modules: list[Any]) -> bool: # HANDLER SECURITY GUARD # ============================================================================= + def handle_command(command: str, args: list) -> bool: """Entry point for drone routing. Guards against cross-branch misuse.""" caller = Path.cwd().name - if caller != "devpulse" and not any( - p.name == "devpulse" for p in Path.cwd().parents - ): + if caller != "devpulse" and not any(p.name == "devpulse" for p in Path.cwd().parents): logger.warning(f"[DEVPULSE] Cross-branch call from {caller} — use ai_mail instead") return _handle_command(command, args) @@ -125,6 +124,7 @@ def _handle_command(command: str, args: list) -> bool: # MAIN ENTRY POINT # ============================================================================= + def main(): """Main entry point - routes commands or shows help.""" args = sys.argv[1:] diff --git a/src/aipass/devpulse/apps/handlers/feedback/compose.py b/src/aipass/devpulse/apps/handlers/feedback/compose.py index 7ef8e599..9b007eef 100644 --- a/src/aipass/devpulse/apps/handlers/feedback/compose.py +++ b/src/aipass/devpulse/apps/handlers/feedback/compose.py @@ -153,9 +153,7 @@ def reply_to(msg_id: str, body: str) -> bool: return True -def _deliver_to_ai_mail( - to_branch: str, subject: str, body: str, thread_id: str, reply_path: str = "" -) -> None: +def _deliver_to_ai_mail(to_branch: str, subject: str, body: str, thread_id: str, reply_path: str = "") -> None: """Deliver a reply to the sender's ai_mail inbox. Writes directly to the sender's .ai_mail.local/inbox.json. @@ -175,9 +173,7 @@ def _deliver_to_ai_mail( ai_mail_path = _AIPASS_ROOT / to_branch / ".ai_mail.local" / "inbox.json" if not ai_mail_path.exists(): - logger.warning( - f"[FEEDBACK] ai_mail inbox not found for {to_branch} at {ai_mail_path} — skipping delivery" - ) + logger.warning(f"[FEEDBACK] ai_mail inbox not found for {to_branch} at {ai_mail_path} — skipping delivery") return try: diff --git a/src/aipass/devpulse/apps/handlers/watchdog/agent.py b/src/aipass/devpulse/apps/handlers/watchdog/agent.py index c3289ffe..352564b8 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/agent.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/agent.py @@ -60,7 +60,7 @@ def _resolve_branch_path(agent_id: str) -> Path | None: registry_file = repo_root / "AIPASS_REGISTRY.json" try: - registry = json.loads(registry_file.read_text(encoding='utf-8')) + registry = json.loads(registry_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning("[watchdog.agent] failed to read registry: %s", exc) return None @@ -79,7 +79,7 @@ def _resolve_branch_path(agent_id: str) -> Path | None: def _is_zombie_linux(pid: int) -> bool: """Linux-only zombie check via /proc. Returns True if zombie.""" try: - status_text = Path(f"/proc/{pid}/status").read_text(encoding='utf-8') + status_text = Path(f"/proc/{pid}/status").read_text(encoding="utf-8") except OSError as exc: logger.info("[watchdog.agent] /proc/%s/status unreadable: %s", pid, exc) return False @@ -146,7 +146,7 @@ def _read_lock(lock_file: Path) -> dict | None: if not lock_file.exists(): return None try: - return json.loads(lock_file.read_text(encoding='utf-8')) + return json.loads(lock_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning("[watchdog.agent] could not read lock %s: %s", lock_file, exc) return None @@ -160,11 +160,13 @@ def _classify_exit(branch_path: Path, lock_existed: bool) -> tuple[str, str, int bounce_file = branch_path / ".ai_mail.local" / "last_bounce.json" if bounce_file.exists(): try: - data = json.loads(bounce_file.read_text(encoding='utf-8')) + data = json.loads(bounce_file.read_text(encoding="utf-8")) exit_code = data.get("exit_code") - return ("crashed", - f"agent crashed (last_bounce.json exit_code={exit_code})", - exit_code if isinstance(exit_code, int) else None) + return ( + "crashed", + f"agent crashed (last_bounce.json exit_code={exit_code})", + exit_code if isinstance(exit_code, int) else None, + ) except (OSError, json.JSONDecodeError) as exc: logger.warning("[watchdog.agent] bounce file unreadable: %s", exc) return ("crashed", "agent crashed (bounce file present, unreadable)", None) @@ -254,8 +256,7 @@ def watch_agent( _stderr(f"[watchdog.agent] {agent_id}: lock removed — agent done") elapsed_int = int(time.monotonic() - started_at) state, reason, exit_code = _classify_exit(branch_path, lock_existed=True) - logger.info("[watchdog.agent] wake agent_id=%s state=%s elapsed=%s", - agent_id, state, elapsed_int) + logger.info("[watchdog.agent] wake agent_id=%s state=%s elapsed=%s", agent_id, state, elapsed_int) return { "woke": True, "reason": reason, @@ -267,15 +268,16 @@ def watch_agent( } if isinstance(initial_pid, int) and not _pid_alive(initial_pid): - _stderr(f"[watchdog.agent] {agent_id}: monitor PID {initial_pid} dead " - f"but lock still present — treating as crash") + _stderr( + f"[watchdog.agent] {agent_id}: monitor PID {initial_pid} dead " + f"but lock still present — treating as crash" + ) elapsed_int = int(time.monotonic() - started_at) state, reason, exit_code = _classify_exit(branch_path, lock_existed=True) if state == "completed": state = "crashed" reason = f"monitor PID {initial_pid} dead, lock still present" - logger.info("[watchdog.agent] wake agent_id=%s state=%s elapsed=%s", - agent_id, state, elapsed_int) + logger.info("[watchdog.agent] wake agent_id=%s state=%s elapsed=%s", agent_id, state, elapsed_int) return { "woke": True, "reason": reason, diff --git a/src/aipass/devpulse/apps/handlers/watchdog/registry.py b/src/aipass/devpulse/apps/handlers/watchdog/registry.py index 00a19654..9fadad13 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/registry.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/registry.py @@ -91,7 +91,7 @@ def _load_store_unlocked(storage_path: Path) -> dict: if not storage_path.exists(): return _empty_store() try: - data = json.loads(storage_path.read_text(encoding='utf-8')) + data = json.loads(storage_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning("[watchdog.registry] could not load %s: %s", storage_path, exc) return _empty_store() @@ -110,7 +110,7 @@ def _atomic_write_unlocked(storage_path: Path, data: dict) -> None: storage_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = storage_path.with_suffix(storage_path.suffix + ".tmp") try: - tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding='utf-8') + tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") os.replace(tmp_path, storage_path) finally: if tmp_path.exists(): @@ -136,9 +136,10 @@ class _FileLock: if sys.platform == "win32": return self # Windows: skip file locking (single-user typical) import fcntl + self._lock_path.parent.mkdir(parents=True, exist_ok=True) # 'a+' so the file is created if missing and lock survives concurrent opens. - self._fh = open(self._lock_path, "a+", encoding='utf-8') + self._fh = open(self._lock_path, "a+", encoding="utf-8") fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX) return self @@ -146,6 +147,7 @@ class _FileLock: if self._fh is not None: try: import fcntl + fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) finally: self._fh.close() @@ -160,7 +162,7 @@ def _generate_handle(watch_type: str) -> str: def _is_zombie_linux(pid: int) -> bool: """Linux-only zombie check via /proc. Returns True only if state is 'Z'.""" try: - status_text = Path(f"/proc/{pid}/status").read_text(encoding='utf-8') + status_text = Path(f"/proc/{pid}/status").read_text(encoding="utf-8") except OSError as exc: logger.info("[watchdog.registry] /proc/%s/status unreadable: %s", pid, exc) return False @@ -228,8 +230,7 @@ def register( store["watches"].append(entry) _atomic_write_unlocked(path, store) - logger.info("[watchdog.registry] register type=%s handle=%s pid=%s", - watch_type, handle, entry["pid"]) + logger.info("[watchdog.registry] register type=%s handle=%s pid=%s", watch_type, handle, entry["pid"]) return handle diff --git a/src/aipass/devpulse/apps/handlers/watchdog/schedule.py b/src/aipass/devpulse/apps/handlers/watchdog/schedule.py index 84ec379b..9c76c346 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/schedule.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/schedule.py @@ -167,7 +167,9 @@ def wake_at( target = parse_schedule(time_str, now=start) logger.info( "[watchdog.schedule] wake_at time=%s target=%s command=%s", - time_str, target.isoformat(), command, + time_str, + target.isoformat(), + command, ) handle = _registry.register( diff --git a/src/aipass/devpulse/apps/handlers/watchdog/timer.py b/src/aipass/devpulse/apps/handlers/watchdog/timer.py index fa3f1c56..c5f3ce27 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/timer.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/timer.py @@ -84,7 +84,7 @@ def _load_store(storage_path: Path) -> dict: if not storage_path.exists(): return _empty_store() try: - data = json.loads(storage_path.read_text(encoding='utf-8')) + data = json.loads(storage_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning("[watchdog.timer] could not load %s: %s", storage_path, exc) return _empty_store() @@ -107,7 +107,7 @@ def _atomic_write(storage_path: Path, data: dict) -> None: storage_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = storage_path.with_suffix(storage_path.suffix + ".tmp") try: - tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding='utf-8') + tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") os.replace(tmp_path, storage_path) finally: if tmp_path.exists(): @@ -286,22 +286,26 @@ def timer_list(storage_path: Path | None = None) -> dict: for name, entry in sorted(store["active"].items()): started_epoch = float(entry.get("started_epoch", now)) elapsed = max(0, int(now - started_epoch)) - active.append({ - "name": name, - "started_at": entry.get("started_at"), - "elapsed_so_far_seconds": elapsed, - "human": format_human(elapsed), - }) + active.append( + { + "name": name, + "started_at": entry.get("started_at"), + "elapsed_so_far_seconds": elapsed, + "human": format_human(elapsed), + } + ) history = [] for entry in store["history"]: - history.append({ - "name": entry.get("name"), - "started_at": entry.get("started_at"), - "stopped_at": entry.get("stopped_at"), - "elapsed_seconds": entry.get("elapsed_seconds", 0), - "human": format_human(int(entry.get("elapsed_seconds", 0))), - }) + history.append( + { + "name": entry.get("name"), + "started_at": entry.get("started_at"), + "stopped_at": entry.get("stopped_at"), + "elapsed_seconds": entry.get("elapsed_seconds", 0), + "human": format_human(int(entry.get("elapsed_seconds", 0))), + } + ) return {"active": active, "history": history} @@ -315,9 +319,7 @@ def timer_report(storage_path: Path | None = None) -> str: if snapshot["active"]: for item in snapshot["active"]: started = _short_time(item.get("started_at")) - lines.append( - f" - {item['name']:<15} elapsed {item['human']} (started {started})" - ) + lines.append(f" - {item['name']:<15} elapsed {item['human']} (started {started})") else: lines.append(" (none)") @@ -327,9 +329,7 @@ def timer_report(storage_path: Path | None = None) -> str: for item in snapshot["history"]: started = _short_time(item.get("started_at")) stopped = _short_time(item.get("stopped_at")) - lines.append( - f" - {item['name']:<15} {item['human']:<8} ({started} → {stopped})" - ) + lines.append(f" - {item['name']:<15} {item['human']:<8} ({started} → {stopped})") else: lines.append(" (none)") diff --git a/src/aipass/devpulse/apps/modules/feedback.py b/src/aipass/devpulse/apps/modules/feedback.py index 7780ce3f..99b39334 100644 --- a/src/aipass/devpulse/apps/modules/feedback.py +++ b/src/aipass/devpulse/apps/modules/feedback.py @@ -10,7 +10,6 @@ Auto-discovered by devpulse.py via handle_command() convention. Routes feedback subcommands to the appropriate handler functions. """ - from rich.console import Console from aipass.devpulse.apps.handlers.feedback.inbox import ( diff --git a/src/aipass/devpulse/apps/modules/watchdog.py b/src/aipass/devpulse/apps/modules/watchdog.py index 3ad856a3..8b32ab91 100644 --- a/src/aipass/devpulse/apps/modules/watchdog.py +++ b/src/aipass/devpulse/apps/modules/watchdog.py @@ -33,9 +33,7 @@ from aipass.cli.apps.modules import console, error, warning _VALID_SUBCOMMANDS = ["agent", "timer", "schedule", "status", "cancel", "list"] _DEFAULT_AGENT_TIMEOUT = 1800 -_NOT_IMPLEMENTED_MSG = ( - "{sub} is not yet implemented in this phase — see FPLAN-0186 (Phase {phase})" -) +_NOT_IMPLEMENTED_MSG = "{sub} is not yet implemented in this phase — see FPLAN-0186 (Phase {phase})" # Phase 4 wired cancel + list for real. Left the map so future deferrals can reuse the shape. _PHASE_BY_SUB: dict[str, int] = {} @@ -152,8 +150,7 @@ def handle_command(command: str, args: List[str]) -> bool: sub_args = args[1:] if subcommand not in _VALID_SUBCOMMANDS: - error(f"Unknown watchdog subcommand: {subcommand}", - suggestion="Use 'watchdog --help' for usage") + error(f"Unknown watchdog subcommand: {subcommand}", suggestion="Use 'watchdog --help' for usage") return True logger.info("[watchdog] subcommand=%s args=%s", subcommand, sub_args) @@ -194,9 +191,7 @@ def _handle_timer(sub_args: List[str]) -> bool: console.print(_TIMER_HELP_TEXT) return True - timer_mod = importlib.import_module( - "aipass.devpulse.apps.handlers.watchdog.timer" - ) + timer_mod = importlib.import_module("aipass.devpulse.apps.handlers.watchdog.timer") action = sub_args[0] @@ -254,9 +249,7 @@ def _handle_schedule(sub_args: List[str]) -> bool: time_str = sub_args[0] command = sub_args[1] if len(sub_args) >= 2 else None - schedule_mod = importlib.import_module( - "aipass.devpulse.apps.handlers.watchdog.schedule" - ) + schedule_mod = importlib.import_module("aipass.devpulse.apps.handlers.watchdog.schedule") try: result = schedule_mod.wake_at(time_str, command=command) @@ -273,15 +266,10 @@ def _print_schedule_result(result: dict) -> None: """Render a schedule handler return dict as CLI output.""" scheduled_for = result.get("scheduled_for", "?") elapsed = result.get("elapsed", 0) - console.print( - f"[bold]watchdog schedule[/bold] woke after {elapsed}s " - f"(scheduled_for={scheduled_for})" - ) + console.print(f"[bold]watchdog schedule[/bold] woke after {elapsed}s (scheduled_for={scheduled_for})") if result.get("command"): exit_code = result.get("command_exit_code") - console.print( - f" command: {result['command']} -> exit={exit_code}" - ) + console.print(f" command: {result['command']} -> exit={exit_code}") stdout = result.get("command_stdout") or "" stderr = result.get("command_stderr") or "" if stdout: @@ -298,17 +286,13 @@ def _print_timer_result(result: dict) -> None: error(f"timer {name}: {result.get('reason', 'unknown error')}") return if state == "stopped": - console.print( - f"[bold]timer[/bold] {name} stopped -> elapsed={result.get('human', '?')}" - ) + console.print(f"[bold]timer[/bold] {name} stopped -> elapsed={result.get('human', '?')}") return if state == "started": console.print(f"[bold]timer[/bold] {name} started at {result.get('started_at', '?')}") return if state == "woke": - console.print( - f"[bold]timer[/bold] {name} woke after {result.get('elapsed', 0)}s" - ) + console.print(f"[bold]timer[/bold] {name} woke after {result.get('elapsed', 0)}s") return console.print(f"[dim]timer result:[/dim] {result}") @@ -320,18 +304,14 @@ def _print_timer_list(snapshot: dict) -> None: console.print("[bold]Active timers:[/bold]") if active: for item in active: - console.print( - f" - {item['name']} elapsed {item['human']} " - f"(started {item.get('started_at', '?')})" - ) + console.print(f" - {item['name']} elapsed {item['human']} (started {item.get('started_at', '?')})") else: console.print(" (none)") console.print("[bold]History:[/bold]") if history: for item in history: console.print( - f" - {item['name']} {item['human']} " - f"({item.get('started_at', '?')} -> {item.get('stopped_at', '?')})" + f" - {item['name']} {item['human']} ({item.get('started_at', '?')} -> {item.get('stopped_at', '?')})" ) else: console.print(" (none)") @@ -366,33 +346,24 @@ def _handle_agent(sub_args: List[str]) -> bool: agent_id = positional[0] - agent_mod = importlib.import_module( - "aipass.devpulse.apps.handlers.watchdog.agent" - ) + agent_mod = importlib.import_module("aipass.devpulse.apps.handlers.watchdog.agent") result = agent_mod.watch_agent(agent_id, timeout_seconds=timeout) state = result.get("agent_state", "unknown") reason = result.get("reason", "") elapsed = result.get("elapsed", 0) - console.print( - f"[bold]watchdog agent[/bold] {agent_id} -> " - f"state={state} elapsed={elapsed}s reason={reason}" - ) + console.print(f"[bold]watchdog agent[/bold] {agent_id} -> state={state} elapsed={elapsed}s reason={reason}") return True def _load_registry_module(): """Lazy-import the watch registry. Keeps cold startup fast.""" - return importlib.import_module( - "aipass.devpulse.apps.handlers.watchdog.registry" - ) + return importlib.import_module("aipass.devpulse.apps.handlers.watchdog.registry") def _load_timer_module_for_format(): """Lazy-import timer for ``format_human`` (reused in the status output).""" - return importlib.import_module( - "aipass.devpulse.apps.handlers.watchdog.timer" - ) + return importlib.import_module("aipass.devpulse.apps.handlers.watchdog.timer") def _format_status_line(watch: dict, format_human) -> str: @@ -404,10 +375,7 @@ def _format_status_line(watch: dict, format_human) -> str: meta = watch.get("metadata") or {} if wtype == "agent": - tail = ( - f"{meta.get('agent_id', '?')} " - f"(timeout={meta.get('timeout_seconds', '?')}s)" - ) + tail = f"{meta.get('agent_id', '?')} (timeout={meta.get('timeout_seconds', '?')}s)" elif wtype == "timer": tail = f"duration={meta.get('duration', '?')}" elif wtype == "schedule": @@ -418,10 +386,7 @@ def _format_status_line(watch: dict, format_human) -> str: tail = str(meta) # Escape the [ so Rich console doesn't interpret it as a style tag. - return ( - f" \\[{handle}] {wtype:<8} {format_human(elapsed):<10} " - f"pid={pid} {tail}" - ) + return f" \\[{handle}] {wtype:<8} {format_human(elapsed):<10} pid={pid} {tail}" def _handle_status() -> bool: @@ -472,9 +437,7 @@ def _print_kill_result(result: dict) -> None: was_alive = result.get("was_alive", False) reason = result.get("reason", "") status = "KILLED" if killed else "FAILED" - console.print( - f" \\[{handle}] {status} was_alive={was_alive} reason={reason}" - ) + console.print(f" \\[{handle}] {status} was_alive={was_alive} reason={reason}") def _handle_cancel(sub_args: List[str]) -> bool: diff --git a/src/aipass/devpulse/demo/hello_aipass.py b/src/aipass/devpulse/demo/hello_aipass.py index 9210d2fb..59ca5af9 100644 --- a/src/aipass/devpulse/demo/hello_aipass.py +++ b/src/aipass/devpulse/demo/hello_aipass.py @@ -27,17 +27,17 @@ CLEAR = "\033[2J\033[H" # ── Branch Data ────────────────────────────────────────────────────── BRANCHES = [ - ("drone", "Command Router", "Routes commands to branches", CYAN), - ("seedgo", "Standards Engine", "21-standard compliance pack", GREEN), - ("prax", "Logging System", "Stack-aware dual routing", YELLOW), - ("cli", "CLI Framework", "Argument parsing & registry", BLUE), - ("flow", "Plan Manager", "FPLANs + DPLANs", MAGENTA), - ("ai_mail", "Communications", "Inter-branch email & dispatch", CYAN), - ("api", "LLM Access Layer", "OpenRouter, multi-provider", GREEN), - ("trigger", "Event System", "12 events, error registry", YELLOW), - ("spawn", "Branch Lifecycle", "Create, update, delete", BLUE), - ("memory", "Memory Bank", "ChromaDB vector search", MAGENTA), - ("devpulse", "Orchestration Hub", "You are here", CYAN), + ("drone", "Command Router", "Routes commands to branches", CYAN), + ("seedgo", "Standards Engine", "21-standard compliance pack", GREEN), + ("prax", "Logging System", "Stack-aware dual routing", YELLOW), + ("cli", "CLI Framework", "Argument parsing & registry", BLUE), + ("flow", "Plan Manager", "FPLANs + DPLANs", MAGENTA), + ("ai_mail", "Communications", "Inter-branch email & dispatch", CYAN), + ("api", "LLM Access Layer", "OpenRouter, multi-provider", GREEN), + ("trigger", "Event System", "12 events, error registry", YELLOW), + ("spawn", "Branch Lifecycle", "Create, update, delete", BLUE), + ("memory", "Memory Bank", "ChromaDB vector search", MAGENTA), + ("devpulse", "Orchestration Hub", "You are here", CYAN), ] ACTIVITIES = [ @@ -71,7 +71,7 @@ def draw_box(title, content_lines, width=60, color=CYAN): print(f" {color}│{RESET} {BOLD}{WHITE}{title.center(width - 4)}{RESET} {color}│{RESET}") print(f" {color}├{'─' * (width - 2)}┤{RESET}") for line in content_lines: - padded = f"{line:<{width - 4}}"[:width - 4] + padded = f"{line:<{width - 4}}"[: width - 4] print(f" {color}│{RESET} {padded} {color}│{RESET}") print(f" {color}└{'─' * (width - 2)}┘{RESET}") @@ -133,7 +133,9 @@ def show_branch_grid(): is_devpulse = " ◀ YOU" if name == "devpulse" else "" - print(f" {status_color}{status}{RESET} {color}{BOLD}@{name:<10}{RESET} {DIM}{role:<18}{RESET} {CYAN}{bar}{RESET}{YELLOW}{is_devpulse}{RESET}") + print( + f" {status_color}{status}{RESET} {color}{BOLD}@{name:<10}{RESET} {DIM}{role:<18}{RESET} {CYAN}{bar}{RESET}{YELLOW}{is_devpulse}{RESET}" + ) time.sleep(0.08) print() @@ -163,7 +165,7 @@ def show_dispatch_demo(): print() # Simulate sending - print_slow(f" {CYAN}📤 drone @ai_mail send @spawn \"Build greeting module\" --dispatch{RESET}", 0.02) + print_slow(f' {CYAN}📤 drone @ai_mail send @spawn "Build greeting module" --dispatch{RESET}', 0.02) time.sleep(0.5) print(f" {GREEN} ✓ Email dispatched to @spawn{RESET}") time.sleep(0.3) @@ -191,7 +193,7 @@ def show_dispatch_demo(): print() print() - print(f" {GREEN}{BOLD} 📨 Reply from @spawn: \"Greeting module built. 12 tests passing.\"{RESET}") + print(f' {GREEN}{BOLD} 📨 Reply from @spawn: "Greeting module built. 12 tests passing."{RESET}') print() diff --git a/src/aipass/devpulse/tests/conftest.py b/src/aipass/devpulse/tests/conftest.py index ac3aa382..3a5f700f 100644 --- a/src/aipass/devpulse/tests/conftest.py +++ b/src/aipass/devpulse/tests/conftest.py @@ -13,6 +13,7 @@ # ============================================= """Shared pytest fixtures for cortex tests""" + import pytest import shutil import tempfile @@ -35,7 +36,4 @@ def sample_test_data() -> dict: Customize this fixture for your module's needs """ - return { - "test_key": "test_value", - "sample_data": "example" - } + return {"test_key": "test_value", "sample_data": "example"} diff --git a/src/aipass/devpulse/tests/test_feedback_compose.py b/src/aipass/devpulse/tests/test_feedback_compose.py index f14607f7..738478a4 100644 --- a/src/aipass/devpulse/tests/test_feedback_compose.py +++ b/src/aipass/devpulse/tests/test_feedback_compose.py @@ -31,12 +31,14 @@ def mock_aipass_root(tmp_path): @pytest.fixture def empty_inbox(mock_feedback_dir): """Start with an empty feedback inbox.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 0, + "unread_count": 0, + "messages": [], + } + ) class TestSendFeedback: @@ -91,22 +93,24 @@ class TestReplyTo: @pytest.fixture def inbox_with_message(self, mock_feedback_dir): """Create inbox with a single message to reply to.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 1, - "unread_count": 1, - "messages": [ - { - "id": "aaa11111", - "from": "seedgo", - "subject": "Test feedback", - "body": "Original message.", - "timestamp": "2026-04-11T10:00:00", - "read": True, - "thread": [], - }, - ], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 1, + "unread_count": 1, + "messages": [ + { + "id": "aaa11111", + "from": "seedgo", + "subject": "Test feedback", + "body": "Original message.", + "timestamp": "2026-04-11T10:00:00", + "read": True, + "thread": [], + }, + ], + } + ) def test_adds_reply_to_thread(self, inbox_with_message, mock_aipass_root): """Should append reply to the message thread.""" @@ -142,22 +146,24 @@ class TestAiMailDelivery: @pytest.fixture def inbox_with_message(self, mock_feedback_dir): """Create inbox with a message from seedgo.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 1, - "unread_count": 0, - "messages": [ - { - "id": "aaa11111", - "from": "seedgo", - "subject": "Test feedback", - "body": "Original.", - "timestamp": "2026-04-11T10:00:00", - "read": True, - "thread": [], - }, - ], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 1, + "unread_count": 0, + "messages": [ + { + "id": "aaa11111", + "from": "seedgo", + "subject": "Test feedback", + "body": "Original.", + "timestamp": "2026-04-11T10:00:00", + "read": True, + "thread": [], + }, + ], + } + ) def test_delivers_to_ai_mail(self, inbox_with_message, mock_aipass_root): """Should write reply to sender's ai_mail inbox.""" @@ -166,12 +172,15 @@ class TestAiMailDelivery: ai_mail_dir.mkdir(parents=True) ai_mail_inbox = ai_mail_dir / "inbox.json" with open(ai_mail_inbox, "w", encoding="utf-8") as f: - json.dump({ - "mailbox": "inbox", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }, f) + json.dump( + { + "mailbox": "inbox", + "total_messages": 0, + "unread_count": 0, + "messages": [], + }, + f, + ) compose.reply_to("aaa11111", "Thanks for the feedback!") diff --git a/src/aipass/devpulse/tests/test_feedback_inbox.py b/src/aipass/devpulse/tests/test_feedback_inbox.py index f59acc04..c4229759 100644 --- a/src/aipass/devpulse/tests/test_feedback_inbox.py +++ b/src/aipass/devpulse/tests/test_feedback_inbox.py @@ -72,12 +72,14 @@ class TestListMessages: def test_empty_inbox(self, mock_feedback_dir, capsys): """Should print 'no messages' for empty inbox.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 0, + "unread_count": 0, + "messages": [], + } + ) inbox.list_messages() # list_messages prints to stderr via Rich Console # We just verify it doesn't raise @@ -174,15 +176,24 @@ class TestClearAllRead: def test_no_read_messages(self, mock_feedback_dir): """Should handle inbox with no read messages.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 1, - "unread_count": 1, - "messages": [ - {"id": "x", "from": "a", "subject": "b", "body": "c", - "timestamp": "2026-04-11T10:00:00", "read": False, "thread": []}, - ], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 1, + "unread_count": 1, + "messages": [ + { + "id": "x", + "from": "a", + "subject": "b", + "body": "c", + "timestamp": "2026-04-11T10:00:00", + "read": False, + "thread": [], + }, + ], + } + ) inbox.clear_all_read() data = storage.load_inbox() @@ -190,12 +201,14 @@ class TestClearAllRead: def test_empty_inbox_clear_all(self, mock_feedback_dir): """Should handle empty inbox gracefully.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 0, + "unread_count": 0, + "messages": [], + } + ) inbox.clear_all_read() @@ -204,12 +217,14 @@ class TestGetSummary: def test_empty_inbox_summary(self, mock_feedback_dir): """Should return 'No feedback messages.' for empty inbox.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 0, + "unread_count": 0, + "messages": [], + } + ) result = inbox.get_summary() assert result == "No feedback messages." @@ -220,14 +235,23 @@ class TestGetSummary: def test_single_message_summary(self, mock_feedback_dir): """Should use singular 'message' for count of 1.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 1, - "unread_count": 0, - "messages": [ - {"id": "x", "from": "a", "subject": "b", "body": "c", - "timestamp": "2026-04-11T10:00:00", "read": True, "thread": []}, - ], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 1, + "unread_count": 0, + "messages": [ + { + "id": "x", + "from": "a", + "subject": "b", + "body": "c", + "timestamp": "2026-04-11T10:00:00", + "read": True, + "thread": [], + }, + ], + } + ) result = inbox.get_summary() assert result == "1 message, 0 unread" diff --git a/src/aipass/devpulse/tests/test_feedback_module.py b/src/aipass/devpulse/tests/test_feedback_module.py index d758ca1d..607803b1 100644 --- a/src/aipass/devpulse/tests/test_feedback_module.py +++ b/src/aipass/devpulse/tests/test_feedback_module.py @@ -24,42 +24,46 @@ def mock_feedback_dir(tmp_path): @pytest.fixture def empty_inbox(mock_feedback_dir): """Start with an empty feedback inbox.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 0, - "unread_count": 0, - "messages": [], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 0, + "unread_count": 0, + "messages": [], + } + ) @pytest.fixture def populated_inbox(mock_feedback_dir): """Create an inbox with sample messages.""" - storage.save_inbox({ - "mailbox": "feedback", - "total_messages": 2, - "unread_count": 1, - "messages": [ - { - "id": "aaa11111", - "from": "seedgo", - "subject": "Test", - "body": "Body text.", - "timestamp": "2026-04-11T10:00:00", - "read": False, - "thread": [], - }, - { - "id": "bbb22222", - "from": "prax", - "subject": "Already read", - "body": "Old message.", - "timestamp": "2026-04-11T09:00:00", - "read": True, - "thread": [], - }, - ], - }) + storage.save_inbox( + { + "mailbox": "feedback", + "total_messages": 2, + "unread_count": 1, + "messages": [ + { + "id": "aaa11111", + "from": "seedgo", + "subject": "Test", + "body": "Body text.", + "timestamp": "2026-04-11T10:00:00", + "read": False, + "thread": [], + }, + { + "id": "bbb22222", + "from": "prax", + "subject": "Already read", + "body": "Old message.", + "timestamp": "2026-04-11T09:00:00", + "read": True, + "thread": [], + }, + ], + } + ) class TestCommandRouting: @@ -130,9 +134,7 @@ class TestCommandRouting: def test_feedback_send(self, empty_inbox): """Should accept feedback from an agent.""" - result = feedback_module.handle_command( - "feedback", ["send", "seedgo", "Bug report", "Found an issue"] - ) + result = feedback_module.handle_command("feedback", ["send", "seedgo", "Bug report", "Found an issue"]) assert result is True data = storage.load_inbox() @@ -143,9 +145,7 @@ class TestCommandRouting: def test_feedback_send_without_from(self, empty_inbox): """Should handle send with just subject and body.""" # When args start with a quoted-looking string, from defaults to 'unknown' - result = feedback_module.handle_command( - "feedback", ["send", "Subject here", "Body text"] - ) + result = feedback_module.handle_command("feedback", ["send", "Subject here", "Body text"]) assert result is True data = storage.load_inbox() @@ -163,9 +163,7 @@ class TestCommandRouting: def test_feedback_reply(self, mock_reply, populated_inbox): """Should route reply command correctly.""" mock_reply.return_value = True - result = feedback_module.handle_command( - "feedback", ["reply", "aaa11111", "Good point!"] - ) + result = feedback_module.handle_command("feedback", ["reply", "aaa11111", "Good point!"]) assert result is True mock_reply.assert_called_once_with("aaa11111", "Good point!") @@ -196,5 +194,6 @@ class TestHandleCommandHasCorrectSignature: def test_handle_command_takes_two_args(self): """handle_command must accept (command, args) signature.""" import inspect + sig = inspect.signature(feedback_module.handle_command) assert len(sig.parameters) == 2 diff --git a/src/aipass/devpulse/tests/test_json_handler_template.py b/src/aipass/devpulse/tests/test_json_handler_template.py index 5d7a88ee..de3b7b0f 100644 --- a/src/aipass/devpulse/tests/test_json_handler_template.py +++ b/src/aipass/devpulse/tests/test_json_handler_template.py @@ -64,13 +64,9 @@ if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) # Resolve the real filesystem path for the handlers package if BRANCH_MODULE in ("commons", "skills"): - _handlers_dir = ( - Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" else: - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -87,11 +83,11 @@ json_handler = _mod _JSON_DIR_ATTR: str | None = None _JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", # SEEDGO_JSON_DIR, BACKUP_JSON_DIR, etc. - "JSON_DIR", # seedgo, daemon, memory, cli, drone - "BRANCH_JSON_DIR", # commons - f"{BRANCH_MODULE}_json", # unlikely but covered - "_JSON_DIR", # spawn + f"{BRANCH_MODULE.upper()}_JSON_DIR", # SEEDGO_JSON_DIR, BACKUP_JSON_DIR, etc. + "JSON_DIR", # seedgo, daemon, memory, cli, drone + "BRANCH_JSON_DIR", # commons + f"{BRANCH_MODULE}_json", # unlikely but covered + "_JSON_DIR", # spawn ] for _candidate in _JSON_DIR_CANDIDATES: @@ -101,8 +97,7 @@ for _candidate in _JSON_DIR_CANDIDATES: if _JSON_DIR_ATTR is None: pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — " - f"tried: {_JSON_DIR_CANDIDATES}", + f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — tried: {_JSON_DIR_CANDIDATES}", allow_module_level=True, ) @@ -113,6 +108,7 @@ if _JSON_DIR_ATTR is None: # Branches use: _create_default, _get_default_template, _get_default, # _default_template, load_template, or per-type _default_config/_default_data/_default_log. + def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: """Call whichever default factory the branch exposes.""" # Single-function factories (most branches) @@ -179,6 +175,7 @@ def _default_factory_raises_on_unknown() -> bool: # Isolation fixture # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect JSON operations to tmp_path for test isolation.""" @@ -196,6 +193,7 @@ def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: # Helper: resolve JSON dir as Path regardless of branch type # --------------------------------------------------------------------------- + def _json_dir_as_path(tmp_path: Path) -> Path: """Return the patched JSON dir as a Path (handles str-typed branches).""" assert _JSON_DIR_ATTR is not None @@ -209,6 +207,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Group 1 — _create_default / default templates (4 tests) # ============================================================================ + def test_default_config_returns_dict_with_required_keys() -> None: # JH-001 if not _has_default_factory(): pytest.skip("Branch has no default factory function") @@ -247,6 +246,7 @@ def test_default_unknown_type_raises_value_error() -> None: # JH-004 # Group 2 — validate_json_structure (10 tests) # ============================================================================ + def test_validate_valid_config() -> None: # JH-005 data = {"module_name": "x", "version": "1.0.0", "config": {}} assert json_handler.validate_json_structure(data, "config") is True @@ -298,6 +298,7 @@ def test_validate_none_input_returns_false() -> None: # JH-014 # Group 3 — get_json_path (3 tests) # ============================================================================ + def test_get_json_path_returns_path_type(tmp_path: Path) -> None: # JH-015 result = json_handler.get_json_path("mymod", "config") # Some branches return str (commons), most return Path @@ -320,6 +321,7 @@ def test_get_json_path_different_combos_differ(tmp_path: Path) -> None: # JH-01 # Group 4 — ensure_json_exists (5 tests) # ============================================================================ + def test_ensure_creates_file_when_missing(tmp_path: Path) -> None: # JH-018 result = json_handler.ensure_json_exists("ens_mod", "config") assert result is True @@ -381,6 +383,7 @@ def test_ensure_returns_bool(tmp_path: Path) -> None: # JH-022 # Group 5 — load_json (4 tests) # ============================================================================ + def test_load_creates_default_when_missing(tmp_path: Path) -> None: # JH-023 result = json_handler.load_json("fresh_mod", "log") assert result is not None, "load_json must auto-create and return content" @@ -413,6 +416,7 @@ def test_load_returns_list_for_log(tmp_path: Path) -> None: # JH-026 # Group 6 — save_json (5 tests) # ============================================================================ + def test_save_roundtrip(tmp_path: Path) -> None: # JH-027 json_dir = _json_dir_as_path(tmp_path) json_dir.mkdir(parents=True, exist_ok=True) @@ -446,9 +450,7 @@ def test_save_data_updates_last_updated(tmp_path: Path) -> None: # JH-030 data = {"created": "2025-01-01", "last_updated": "2025-01-01"} json_handler.save_json("ts", "data", data) - on_disk = json.loads( - (json_dir / "ts_data.json").read_text(encoding="utf-8") - ) + on_disk = json.loads((json_dir / "ts_data.json").read_text(encoding="utf-8")) assert on_disk["last_updated"] == today, "Saving data type must auto-stamp last_updated" @@ -468,12 +470,11 @@ def test_save_writes_valid_json_to_disk(tmp_path: Path) -> None: # JH-031 # Group 7 — log_operation (7 tests) # ============================================================================ + def test_log_operation_appends_entry(tmp_path: Path) -> None: # JH-032 json_handler.log_operation("deploy", module_name="logmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "logmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "logmod_log.json").read_text(encoding="utf-8")) assert len(log) >= 1, "log_operation must append at least one entry" assert log[-1]["operation"] == "deploy" @@ -487,20 +488,14 @@ def test_log_operation_returns_bool(tmp_path: Path) -> None: # JH-033 def test_log_operation_entry_has_timestamp(tmp_path: Path) -> None: # JH-034 json_handler.log_operation("check_ts", module_name="tsmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "tsmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "tsmod_log.json").read_text(encoding="utf-8")) assert "timestamp" in log[-1], "Log entry must have a timestamp field" def test_log_operation_includes_data_when_provided(tmp_path: Path) -> None: # JH-035 - json_handler.log_operation( - "with_data", data={"count": 5}, module_name="datamod" - ) + json_handler.log_operation("with_data", data={"count": 5}, module_name="datamod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "datamod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "datamod_log.json").read_text(encoding="utf-8")) assert "data" in log[-1], "Log entry must include data dict when provided" assert log[-1]["data"]["count"] == 5 @@ -510,9 +505,7 @@ def test_log_operation_multiple_calls_accumulate(tmp_path: Path) -> None: # JH- json_handler.log_operation("second", module_name="accmod") json_handler.log_operation("third", module_name="accmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "accmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "accmod_log.json").read_text(encoding="utf-8")) assert len(log) >= 3, "Multiple log_operation calls must accumulate entries" ops = [e["operation"] for e in log[-3:]] assert ops == ["first", "second", "third"] @@ -535,9 +528,7 @@ def test_log_operation_fifo_rotation(tmp_path: Path) -> None: # JH-040 json_handler.log_operation(f"op_{i}", module_name="fifomod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "fifomod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "fifomod_log.json").read_text(encoding="utf-8")) assert len(log) <= max_entries, f"Log must not exceed {max_entries} entries after rotation" # First entries should have been rotated out assert log[-1]["operation"] == f"op_{max_entries + 4}", "Most recent entry must be last" @@ -546,9 +537,7 @@ def test_log_operation_fifo_rotation(tmp_path: Path) -> None: # JH-040 def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None: # JH-041 json_handler.log_operation("no_data", data={}, module_name="emptymod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "emptymod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "emptymod_log.json").read_text(encoding="utf-8")) entry = log[-1] # Empty dict should either not be attached or be an empty dict # The key test: the entry should not have a non-empty "data" field from an empty input @@ -560,6 +549,7 @@ def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None: # JH-04 # Group 8 — ensure_module_jsons (5 tests) # ============================================================================ + def test_ensure_module_jsons_creates_all_three(tmp_path: Path) -> None: # JH-036 if not hasattr(json_handler, "ensure_module_jsons"): pytest.skip("Branch does not have ensure_module_jsons") @@ -583,19 +573,13 @@ def test_ensure_module_jsons_files_pass_validation(tmp_path: Path) -> None: # J json_handler.ensure_module_jsons("valid_mod") json_dir = _json_dir_as_path(tmp_path) - config = json.loads( - (json_dir / "valid_mod_config.json").read_text(encoding="utf-8") - ) + config = json.loads((json_dir / "valid_mod_config.json").read_text(encoding="utf-8")) assert json_handler.validate_json_structure(config, "config") is True - data = json.loads( - (json_dir / "valid_mod_data.json").read_text(encoding="utf-8") - ) + data = json.loads((json_dir / "valid_mod_data.json").read_text(encoding="utf-8")) assert json_handler.validate_json_structure(data, "data") is True - log = json.loads( - (json_dir / "valid_mod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "valid_mod_log.json").read_text(encoding="utf-8")) assert json_handler.validate_json_structure(log, "log") is True @@ -604,9 +588,7 @@ def test_ensure_module_jsons_data_has_correct_keys(tmp_path: Path) -> None: # J pytest.skip("Branch does not have ensure_module_jsons") json_handler.ensure_module_jsons("keymod") json_dir = _json_dir_as_path(tmp_path) - data = json.loads( - (json_dir / "keymod_data.json").read_text(encoding="utf-8") - ) + data = json.loads((json_dir / "keymod_data.json").read_text(encoding="utf-8")) assert "created" in data, "Data file must have 'created' key" assert "last_updated" in data, "Data file must have 'last_updated' key" @@ -616,8 +598,6 @@ def test_ensure_module_jsons_log_is_empty_list(tmp_path: Path) -> None: # JH-04 pytest.skip("Branch does not have ensure_module_jsons") json_handler.ensure_module_jsons("listmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "listmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "listmod_log.json").read_text(encoding="utf-8")) assert isinstance(log, list), "Log file must be a list" assert len(log) == 0, "Initial log file must be an empty list" diff --git a/src/aipass/devpulse/tests/test_watchdog_agent.py b/src/aipass/devpulse/tests/test_watchdog_agent.py index 5a7a6316..b36ea773 100644 --- a/src/aipass/devpulse/tests/test_watchdog_agent.py +++ b/src/aipass/devpulse/tests/test_watchdog_agent.py @@ -43,9 +43,7 @@ def _build_fake_branch(tmp_path: Path, branch_name: str = "fakebranch") -> Path: {"email": f"@{branch_name}", "path": str(branch_path)}, ] } - (tmp_path / "AIPASS_REGISTRY.json").write_text( - json.dumps(registry), encoding='utf-8' - ) + (tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8") return branch_path @@ -53,7 +51,7 @@ def _write_lock(branch_path: Path, pid: int) -> Path: """Write a fake .dispatch.lock and return its path.""" lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" lock_data = {"pid": pid, "timestamp": "2026-04-14T00:00:00", "branch": str(branch_path)} - lock_file.write_text(json.dumps(lock_data), encoding='utf-8') + lock_file.write_text(json.dumps(lock_data), encoding="utf-8") return lock_file @@ -104,9 +102,7 @@ def test_watch_agent_completed_via_lock_removal(monkeypatch, tmp_path): monkeypatch.setattr(agent_handler.time, "sleep", fake_sleep) - result = agent_handler.watch_agent( - "@fakebranch", timeout_seconds=5, poll_interval=0.01 - ) + result = agent_handler.watch_agent("@fakebranch", timeout_seconds=5, poll_interval=0.01) assert result["woke"] is True assert result["agent_state"] == "completed" @@ -125,15 +121,13 @@ def test_watch_agent_crashed_via_bounce_file(monkeypatch, tmp_path): def fake_sleep(seconds): """Drop a bounce file then remove the lock to simulate crash exit.""" - bounce_file.write_text(json.dumps({"exit_code": 1, "reason": "test"}), encoding='utf-8') + bounce_file.write_text(json.dumps({"exit_code": 1, "reason": "test"}), encoding="utf-8") lock_file.unlink(missing_ok=True) real_sleep(0.01) monkeypatch.setattr(agent_handler.time, "sleep", fake_sleep) - result = agent_handler.watch_agent( - "@fakebranch", timeout_seconds=5, poll_interval=0.01 - ) + result = agent_handler.watch_agent("@fakebranch", timeout_seconds=5, poll_interval=0.01) assert result["woke"] is True assert result["agent_state"] == "crashed" @@ -147,9 +141,7 @@ def test_watch_agent_timeout(monkeypatch, tmp_path): monkeypatch.setattr(agent_handler, "_find_repo_root", lambda *a, **kw: tmp_path) monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True) - result = agent_handler.watch_agent( - "@fakebranch", timeout_seconds=1, poll_interval=0.05 - ) + result = agent_handler.watch_agent("@fakebranch", timeout_seconds=1, poll_interval=0.05) assert result["woke"] is False assert result["agent_state"] == "timeout" @@ -164,9 +156,7 @@ def test_watch_agent_pid_dead_treated_as_crash(monkeypatch, tmp_path): monkeypatch.setattr(agent_handler, "_find_repo_root", lambda *a, **kw: tmp_path) monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: False) - result = agent_handler.watch_agent( - "@fakebranch", timeout_seconds=5, poll_interval=0.01 - ) + result = agent_handler.watch_agent("@fakebranch", timeout_seconds=5, poll_interval=0.01) assert result["woke"] is True assert result["agent_state"] == "crashed" @@ -175,8 +165,13 @@ def test_watch_agent_pid_dead_treated_as_crash(monkeypatch, tmp_path): def test_watch_agent_return_keys(): """Every code path must return all expected keys (Phase 4 adds ``handle``).""" expected = { - "woke", "reason", "elapsed", "agent_state", - "exit_code", "agent_id", "handle", + "woke", + "reason", + "elapsed", + "agent_state", + "exit_code", + "agent_id", + "handle", } # Use the not-found path for a fast invocation result = agent_handler.watch_agent("@__definitely_not_a_branch__", timeout_seconds=1) @@ -202,9 +197,10 @@ def test_watch_agent_live_dispatch_completes(): import subprocess dispatch = subprocess.run( - ["drone", "@ai_mail", "dispatch", "@drone", - "Watchdog ping test", "Reply with OK then exit."], - capture_output=True, text=True, timeout=60, + ["drone", "@ai_mail", "dispatch", "@drone", "Watchdog ping test", "Reply with OK then exit."], + capture_output=True, + text=True, + timeout=60, ) assert dispatch.returncode == 0, f"dispatch failed: {dispatch.stderr}" @@ -223,9 +219,10 @@ def test_watch_agent_live_dispatch_timeout_path(): import subprocess subprocess.run( - ["drone", "@ai_mail", "dispatch", "@drone", - "Long watchdog test", "Wait at least 30 seconds then reply."], - capture_output=True, text=True, timeout=60, + ["drone", "@ai_mail", "dispatch", "@drone", "Long watchdog test", "Wait at least 30 seconds then reply."], + capture_output=True, + text=True, + timeout=60, ) result = agent_handler.watch_agent("@drone", timeout_seconds=2, poll_interval=0.5) diff --git a/src/aipass/devpulse/tests/test_watchdog_module.py b/src/aipass/devpulse/tests/test_watchdog_module.py index 4739bbdc..83bf7809 100644 --- a/src/aipass/devpulse/tests/test_watchdog_module.py +++ b/src/aipass/devpulse/tests/test_watchdog_module.py @@ -119,12 +119,14 @@ def test_cancel_requires_handle(capsys): def test_cancel_handle_routes_to_registry(capsys): """`cancel ` calls registry.kill_watch and prints the result.""" - fake = _fake_registry_module(kill_result={ - "handle": "agent-abc123", - "killed": True, - "was_alive": True, - "reason": "SIGTERM — pid 1234 exited in 0.1s", - }) + fake = _fake_registry_module( + kill_result={ + "handle": "agent-abc123", + "killed": True, + "was_alive": True, + "reason": "SIGTERM — pid 1234 exited in 0.1s", + } + ) with _patch_registry_imports(fake): result = wd_mod.handle_command("watchdog", ["cancel", "agent-abc123"]) assert result is True @@ -136,10 +138,12 @@ def test_cancel_handle_routes_to_registry(capsys): def test_cancel_all_routes_to_registry(capsys): """`cancel --all` calls registry.kill_all and prints every result line.""" - fake = _fake_registry_module(kill_all_result=[ - {"handle": "timer-111111", "killed": True, "was_alive": True, "reason": "ok"}, - {"handle": "schedule-222222", "killed": True, "was_alive": True, "reason": "ok"}, - ]) + fake = _fake_registry_module( + kill_all_result=[ + {"handle": "timer-111111", "killed": True, "was_alive": True, "reason": "ok"}, + {"handle": "schedule-222222", "killed": True, "was_alive": True, "reason": "ok"}, + ] + ) with _patch_registry_imports(fake): result = wd_mod.handle_command("watchdog", ["cancel", "--all"]) assert result is True @@ -270,9 +274,7 @@ def test_agent_subcommand_parses_timeout_flag(): def test_agent_subcommand_invalid_timeout(capsys): """Invalid --timeout value reports a clean error.""" - result = wd_mod.handle_command( - "watchdog", ["agent", "@flow", "--timeout", "notanumber"] - ) + result = wd_mod.handle_command("watchdog", ["agent", "@flow", "--timeout", "notanumber"]) assert result is True captured = capsys.readouterr() combined = captured.out + captured.err diff --git a/src/aipass/devpulse/tests/test_watchdog_registry.py b/src/aipass/devpulse/tests/test_watchdog_registry.py index 213bc632..23105138 100644 --- a/src/aipass/devpulse/tests/test_watchdog_registry.py +++ b/src/aipass/devpulse/tests/test_watchdog_registry.py @@ -40,7 +40,7 @@ def test_register_creates_entry_and_returns_handle(store_path): assert handle.startswith("agent-") assert len(handle) > len("agent-") - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert raw["version"] == 1 assert len(raw["watches"]) == 1 entry = raw["watches"][0] @@ -63,7 +63,7 @@ def test_register_multiple_watches(store_path): assert h2.startswith("timer-") assert h3.startswith("schedule-") - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert len(raw["watches"]) == 3 stored = {w["handle"] for w in raw["watches"]} assert stored == handles @@ -81,7 +81,7 @@ def test_deregister_removes_entry(store_path): removed = watch_registry.deregister(handle, storage_path=store_path) assert removed is True - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert raw["watches"] == [] @@ -99,7 +99,7 @@ def test_deregister_only_removes_target(store_path): h2 = watch_registry.register("timer", {}, storage_path=store_path) watch_registry.deregister(h1, storage_path=store_path) - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert [w["handle"] for w in raw["watches"]] == [h2] @@ -133,7 +133,7 @@ def test_list_active_prunes_stale_by_default(store_path): active = watch_registry.list_active(storage_path=store_path, prune_stale=True) assert active == [] - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert raw["watches"] == [] # pruned from disk too @@ -144,7 +144,7 @@ def test_list_active_keeps_stale_when_prune_false(store_path): active = watch_registry.list_active(storage_path=store_path, prune_stale=False) assert len(active) == 1 - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert len(raw["watches"]) == 1 # still on disk @@ -154,11 +154,11 @@ def test_list_active_selective_prune(store_path): h_dead = watch_registry.register("agent", {"label": "dead"}, storage_path=store_path) # Patch the dead entry's pid on disk to something definitely unused. - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) for watch in raw["watches"]: if watch["handle"] == h_dead: watch["pid"] = 999999 - store_path.write_text(json.dumps(raw, indent=2), encoding='utf-8') + store_path.write_text(json.dumps(raw, indent=2), encoding="utf-8") active = watch_registry.list_active(storage_path=store_path, prune_stale=True) surviving_handles = {a["handle"] for a in active} @@ -206,16 +206,16 @@ def test_kill_watch_already_dead_pid(store_path): """Handle for a dead pid should still be deregistered cleanly.""" handle = watch_registry.register("timer", {}, storage_path=store_path) # Point the entry at a dead pid without touching the current pid of this test. - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) raw["watches"][0]["pid"] = 999999 - store_path.write_text(json.dumps(raw, indent=2), encoding='utf-8') + store_path.write_text(json.dumps(raw, indent=2), encoding="utf-8") result = watch_registry.kill_watch(handle, storage_path=store_path) assert result["killed"] is True assert result["was_alive"] is False # Deregistered - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert raw["watches"] == [] @@ -226,11 +226,11 @@ def test_kill_watch_happy_path(store_path): # Manually install an entry pointing at the subprocess pid so kill_watch # targets it instead of this test's own pid. handle = watch_registry.register("timer", {"duration": "30s"}, storage_path=store_path) - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) for watch in raw["watches"]: if watch["handle"] == handle: watch["pid"] = proc.pid - store_path.write_text(json.dumps(raw, indent=2), encoding='utf-8') + store_path.write_text(json.dumps(raw, indent=2), encoding="utf-8") result = watch_registry.kill_watch(handle, storage_path=store_path) assert result["handle"] == handle @@ -251,17 +251,17 @@ def test_kill_all_multiple_watches(store_path): h1 = watch_registry.register("timer", {}, storage_path=store_path) h2 = watch_registry.register("schedule", {}, storage_path=store_path) # Point both at dead pids so kill_all completes fast and doesn't touch real processes. - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) for watch in raw["watches"]: watch["pid"] = 999999 - store_path.write_text(json.dumps(raw, indent=2), encoding='utf-8') + store_path.write_text(json.dumps(raw, indent=2), encoding="utf-8") results = watch_registry.kill_all(storage_path=store_path) handles = {r["handle"] for r in results} assert handles == {h1, h2} assert all(r["killed"] for r in results) - raw_after = json.loads(store_path.read_text(encoding='utf-8')) + raw_after = json.loads(store_path.read_text(encoding="utf-8")) assert raw_after["watches"] == [] @@ -282,18 +282,15 @@ def test_atomic_write_leaves_no_tmp(store_path): def test_sequential_register_deregister_preserves_entries(store_path): """Sanity test for read-modify-write: many ops in a row don't lose data.""" - handles = [ - watch_registry.register("timer", {"i": i}, storage_path=store_path) - for i in range(10) - ] - raw = json.loads(store_path.read_text(encoding='utf-8')) + handles = [watch_registry.register("timer", {"i": i}, storage_path=store_path) for i in range(10)] + raw = json.loads(store_path.read_text(encoding="utf-8")) assert len(raw["watches"]) == 10 # Remove every other one. for handle in handles[::2]: assert watch_registry.deregister(handle, storage_path=store_path) is True - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert len(raw["watches"]) == 5 surviving = {w["handle"] for w in raw["watches"]} assert surviving == set(handles[1::2]) @@ -323,9 +320,7 @@ def test_agent_handler_registers_and_deregisters(store_path, monkeypatch): # Force the handler's default storage path to our tmp file so register # lands in the right place. - monkeypatch.setattr( - watch_registry, "_default_storage_path", lambda: store_path - ) + monkeypatch.setattr(watch_registry, "_default_storage_path", lambda: store_path) # @__definitely_not_a_branch__ hits the early "not found" return — # exercises the deregister-in-finally path with minimal work. @@ -341,9 +336,7 @@ def test_timer_wake_in_registers_and_deregisters(store_path, monkeypatch): """wake_in with a short duration registers then deregisters.""" from aipass.devpulse.apps.handlers.watchdog import timer as timer_handler - monkeypatch.setattr( - watch_registry, "_default_storage_path", lambda: store_path - ) + monkeypatch.setattr(watch_registry, "_default_storage_path", lambda: store_path) # Take a peek mid-flight by patching time.sleep to snapshot the registry. snapshots: list[list] = [] @@ -351,9 +344,7 @@ def test_timer_wake_in_registers_and_deregisters(store_path, monkeypatch): def spy_sleep(duration): """Capture the registry state while the timer is mid-wait.""" - snapshots.append( - watch_registry.list_active(storage_path=store_path, prune_stale=False) - ) + snapshots.append(watch_registry.list_active(storage_path=store_path, prune_stale=False)) real_sleep(duration) with patch("aipass.devpulse.apps.handlers.watchdog.timer.time.sleep", spy_sleep): @@ -363,10 +354,9 @@ def test_timer_wake_in_registers_and_deregisters(store_path, monkeypatch): assert "handle" in result # Mid-flight snapshot must have seen the entry. - assert any( - any(w["handle"].startswith("timer-") for w in snap) - for snap in snapshots - ), "timer handler never registered mid-wait" + assert any(any(w["handle"].startswith("timer-") for w in snap) for snap in snapshots), ( + "timer handler never registered mid-wait" + ) # After wake_in returns, the registry must be empty. assert watch_registry.list_active(storage_path=store_path, prune_stale=False) == [] @@ -376,12 +366,11 @@ def test_schedule_wake_at_registers_and_deregisters(store_path, monkeypatch): """wake_at with a tiny relative delay registers then deregisters.""" from aipass.devpulse.apps.handlers.watchdog import schedule as schedule_handler - monkeypatch.setattr( - watch_registry, "_default_storage_path", lambda: store_path - ) + monkeypatch.setattr(watch_registry, "_default_storage_path", lambda: store_path) # Fast-forward clock so wake_at returns immediately without real waiting. from datetime import datetime, timedelta + start = datetime(2026, 4, 14, 12, 0, 0) calls = {"n": 0} @@ -405,9 +394,7 @@ def test_handler_deregisters_on_exception(store_path, monkeypatch): """If a handler raises mid-wait, the finally block must still deregister.""" from aipass.devpulse.apps.handlers.watchdog import timer as timer_handler - monkeypatch.setattr( - watch_registry, "_default_storage_path", lambda: store_path - ) + monkeypatch.setattr(watch_registry, "_default_storage_path", lambda: store_path) # Make time.sleep raise after the register call. def exploding_sleep(duration): diff --git a/src/aipass/devpulse/tests/test_watchdog_schedule.py b/src/aipass/devpulse/tests/test_watchdog_schedule.py index 76bf03a5..4bf8cf5d 100644 --- a/src/aipass/devpulse/tests/test_watchdog_schedule.py +++ b/src/aipass/devpulse/tests/test_watchdog_schedule.py @@ -67,13 +67,16 @@ def test_parse_schedule_equal_to_now_rolls_tomorrow(fixed_now): # ───────────────────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("text,delta_seconds", [ - ("+30m", 1800), - ("+1h", 3600), - ("+45s", 45), - ("+1h30m", 5400), - ("+2h", 7200), -]) +@pytest.mark.parametrize( + "text,delta_seconds", + [ + ("+30m", 1800), + ("+1h", 3600), + ("+45s", 45), + ("+1h30m", 5400), + ("+2h", 7200), + ], +) def test_parse_schedule_relative(fixed_now, text, delta_seconds): target = schedule_handler.parse_schedule(text, now=fixed_now) assert target == fixed_now + timedelta(seconds=delta_seconds) @@ -84,17 +87,20 @@ def test_parse_schedule_relative(fixed_now, text, delta_seconds): # ───────────────────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("text", [ - "", - " ", - "abc", - "25:00", - "12:60", - "-5m", - "+xyz", - "+", - "14:30:99", -]) +@pytest.mark.parametrize( + "text", + [ + "", + " ", + "abc", + "25:00", + "12:60", + "-5m", + "+xyz", + "+", + "14:30:99", + ], +) def test_parse_schedule_invalid(text): with pytest.raises(ValueError): schedule_handler.parse_schedule(text, now=datetime(2026, 4, 14, 10, 0, 0)) @@ -285,17 +291,20 @@ def _fake_schedule_module(**overrides): def wake_at(time_str, command=None, now_fn=None): fake.calls.append(("wake_at", time_str, command)) - return overrides.get("wake_at", { - "woke": True, - "reason": "schedule fired", - "elapsed": 0, - "scheduled_for": "2026-04-14T10:00:00", - "state": "woke", - "command": command, - "command_exit_code": 0 if command else None, - "command_stdout": "hi\n" if command else None, - "command_stderr": "" if command else None, - }) + return overrides.get( + "wake_at", + { + "woke": True, + "reason": "schedule fired", + "elapsed": 0, + "scheduled_for": "2026-04-14T10:00:00", + "state": "woke", + "command": command, + "command_exit_code": 0 if command else None, + "command_stdout": "hi\n" if command else None, + "command_stderr": "" if command else None, + }, + ) fake.wake_at = wake_at return fake diff --git a/src/aipass/devpulse/tests/test_watchdog_timer.py b/src/aipass/devpulse/tests/test_watchdog_timer.py index fa9670b0..a5778b3b 100644 --- a/src/aipass/devpulse/tests/test_watchdog_timer.py +++ b/src/aipass/devpulse/tests/test_watchdog_timer.py @@ -24,29 +24,35 @@ from aipass.devpulse.apps.modules import watchdog as wd_mod # ───────────────────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("text,expected", [ - ("30s", 30), - ("5m", 300), - ("2h", 7200), - ("1h30m", 5400), - ("45", 45), - ("0s", 0), - ("120", 120), - ("1h1m1s", 3661), -]) +@pytest.mark.parametrize( + "text,expected", + [ + ("30s", 30), + ("5m", 300), + ("2h", 7200), + ("1h30m", 5400), + ("45", 45), + ("0s", 0), + ("120", 120), + ("1h1m1s", 3661), + ], +) def test_parse_duration_valid(text, expected): assert timer_handler.parse_duration(text) == expected -@pytest.mark.parametrize("text", [ - "abc", - "", - " ", - "-5m", - "5x", - "5m5", - "hm", -]) +@pytest.mark.parametrize( + "text", + [ + "abc", + "", + " ", + "-5m", + "5x", + "5m5", + "hm", + ], +) def test_parse_duration_invalid(text): with pytest.raises(ValueError): timer_handler.parse_duration(text) @@ -62,16 +68,19 @@ def test_parse_duration_none_raises(): # ───────────────────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("seconds,expected", [ - (1, "1s"), - (59, "59s"), - (60, "1m 00s"), - (125, "2m 05s"), - (3600, "1h 0m 00s"), - (5400, "1h 30m 00s"), - (3725, "1h 2m 05s"), - (0, "0s"), -]) +@pytest.mark.parametrize( + "seconds,expected", + [ + (1, "1s"), + (59, "59s"), + (60, "1m 00s"), + (125, "2m 05s"), + (3600, "1h 0m 00s"), + (5400, "1h 30m 00s"), + (3725, "1h 2m 05s"), + (0, "0s"), + ], +) def test_format_human(seconds, expected): assert timer_handler.format_human(seconds) == expected @@ -125,7 +134,7 @@ def test_timer_start_then_stop(store_path): assert stop["human"].endswith("s") assert "stopped_at" in stop - raw = json.loads(store_path.read_text(encoding='utf-8')) + raw = json.loads(store_path.read_text(encoding="utf-8")) assert "phase-a" not in raw["active"] assert len(raw["history"]) == 1 assert raw["history"][0]["name"] == "phase-a" @@ -206,14 +215,14 @@ def test_timer_report_contains_sections(store_path): def test_persistence_across_reloads(store_path): timer_handler.timer_start("persistent", storage_path=store_path) - raw_after_start = json.loads(store_path.read_text(encoding='utf-8')) + raw_after_start = json.loads(store_path.read_text(encoding="utf-8")) assert "persistent" in raw_after_start["active"] time.sleep(1.1) stop_result = timer_handler.timer_stop("persistent", storage_path=store_path) assert stop_result["state"] == "stopped" - raw_after_stop = json.loads(store_path.read_text(encoding='utf-8')) + raw_after_stop = json.loads(store_path.read_text(encoding="utf-8")) assert "persistent" not in raw_after_stop["active"] assert any(h["name"] == "persistent" for h in raw_after_stop["history"]) @@ -256,30 +265,39 @@ def _fake_timer_module(**overrides): def wake_in(duration): fake.calls.append(("wake_in", duration)) - return overrides.get("wake_in", { - "woke": True, - "reason": "timer fired", - "elapsed": 1, - "duration": duration, - "state": "woke", - }) + return overrides.get( + "wake_in", + { + "woke": True, + "reason": "timer fired", + "elapsed": 1, + "duration": duration, + "state": "woke", + }, + ) def timer_start(name, storage_path=None): fake.calls.append(("timer_start", name)) - return overrides.get("timer_start", { - "name": name, - "started_at": "now", - "state": "started", - }) + return overrides.get( + "timer_start", + { + "name": name, + "started_at": "now", + "state": "started", + }, + ) def timer_stop(name, storage_path=None): fake.calls.append(("timer_stop", name)) - return overrides.get("timer_stop", { - "name": name, - "elapsed_seconds": 12, - "human": "12s", - "state": "stopped", - }) + return overrides.get( + "timer_stop", + { + "name": name, + "elapsed_seconds": 12, + "human": "12s", + "state": "stopped", + }, + ) def timer_list(storage_path=None): fake.calls.append(("timer_list",)) diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 4db95ba6..815370ab 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -49,6 +49,7 @@ INTERACTIVE_BRANCHES = ("cli",) # AUTO-DISCOVERY # ============================================================================= + def _discover_modules() -> list[tuple[str, str]]: """Auto-discover modules in apps/modules/ with handle_command().""" discovered = [] @@ -57,9 +58,7 @@ def _discover_modules() -> list[tuple[str, str]]: continue module_name = file_path.stem try: - module = importlib.import_module( - f"aipass.drone.apps.modules.{module_name}" - ) + module = importlib.import_module(f"aipass.drone.apps.modules.{module_name}") if hasattr(module, "handle_command"): doc = (module.__doc__ or "").strip().split("\n")[0] discovered.append((module_name, doc)) @@ -72,6 +71,7 @@ def _discover_modules() -> list[tuple[str, str]]: # HELP & INTROSPECTION # ============================================================================= + def show_help() -> None: """Display drone help with Rich formatting.""" table = Table(show_header=False, box=None, pad_edge=False, show_edge=False) @@ -142,6 +142,7 @@ def show_introspection() -> None: # COMMAND HANDLERS # ============================================================================= + def _cwd_has_registry(max_depth: int = 10) -> bool: """Check if CWD is within a project that has a *_REGISTRY.json.""" cwd = Path.cwd() @@ -332,13 +333,16 @@ def _handle_custom_command(args: list[str]) -> int: try: result = route_command( - target, command, + target, + command, args=cmd_args if cmd_args else None, interactive=interactive, ) except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc: if isinstance(exc, BranchNotFoundError) and is_module(module_name): - logger.info("Falling back to module routing for custom command @%s %s (not in local registry)", module_name, command) + logger.info( + "Falling back to module routing for custom command @%s %s (not in local registry)", module_name, command + ) return _handle_module(module_name, [command] + cmd_args) logger.warning("Custom command failed for target %s: %s", target, exc) err_console.print(f"drone: {exc}") @@ -360,9 +364,7 @@ def _handle_target(args: List[str]) -> int: module_name = target.lstrip("@").lower() first_cmd = rest[0] if rest and rest[0] != "--help" else None - needs_interactive = ( - first_cmd in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES - ) + needs_interactive = first_cmd in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES # Route to internal module — unless command needs interactive terminal, # in which case fall through to branch (subprocess) routing so Rich @@ -417,7 +419,8 @@ def _handle_target(args: List[str]) -> int: try: result = route_command( - target, command, + target, + command, args=cmd_args if cmd_args else None, interactive=interactive, ) @@ -442,6 +445,7 @@ def _handle_target(args: List[str]) -> int: # MAIN ENTRY POINT # ============================================================================= + def main() -> int: """Main entry point - routes commands or shows help.""" args = sys.argv[1:] @@ -487,6 +491,7 @@ def main() -> int: err_console.print("drone: scan requires a target (e.g., drone scan @seedgo)") return 1 from aipass.drone.apps.modules.scan import scan + results = scan(args[1]) return 0 if results is not None else 1 @@ -507,6 +512,7 @@ def main() -> int: # hook-sounds — toggle hook notification sounds if command == "hook-sounds": from aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin import handle_command as hs_handle + cmd = args[1] if len(args) > 1 else None hs_handle(cmd) return 0 @@ -564,11 +570,9 @@ def main() -> int: # Git Bash on Windows is fine — it passes @ through like Linux. try: from aipass.drone.apps.modules.resolver import branch_exists + if branch_exists(command): - err_console.print( - f"drone: branch references require @ prefix. " - f"Use '@{command}' instead of '{command}'." - ) + err_console.print(f"drone: branch references require @ prefix. Use '@{command}' instead of '{command}'.") return 1 except Exception as exc: logger.warning("Branch existence check failed for '%s': %s", command, exc) diff --git a/src/aipass/drone/apps/handlers/__init__.py b/src/aipass/drone/apps/handlers/__init__.py index e6835e4d..c0323ec9 100644 --- a/src/aipass/drone/apps/handlers/__init__.py +++ b/src/aipass/drone/apps/handlers/__init__.py @@ -62,8 +62,10 @@ def _guard_branch_access(): # DEBUG: Print what we found import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) @@ -89,9 +91,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -105,7 +107,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/drone/apps/handlers/command_registry/formatters.py b/src/aipass/drone/apps/handlers/command_registry/formatters.py index 1c9e07f8..6a62fc37 100644 --- a/src/aipass/drone/apps/handlers/command_registry/formatters.py +++ b/src/aipass/drone/apps/handlers/command_registry/formatters.py @@ -21,6 +21,7 @@ try: except ImportError: logger.warning("CLI console not available, falling back to default Rich Console") from rich.console import Console + console = Console() from rich.table import Table diff --git a/src/aipass/drone/apps/handlers/command_registry/lookup.py b/src/aipass/drone/apps/handlers/command_registry/lookup.py index 3f68c57c..09d9ae8f 100644 --- a/src/aipass/drone/apps/handlers/command_registry/lookup.py +++ b/src/aipass/drone/apps/handlers/command_registry/lookup.py @@ -40,6 +40,7 @@ MODULE_NAME = "command_lookup" # Lookup functions # --------------------------------------------------------------------------- + def lookup_command(name: str) -> dict[str, Any] | None: """Look up a custom command by exact name. @@ -126,10 +127,7 @@ def list_commands_by_branch(branch_name: str) -> list[dict[str, Any]]: try: registry = load_registry() commands = registry.get("commands", {}) - filtered = [ - cmd for cmd in commands.values() - if cmd.get("source_branch") == branch_name - ] + filtered = [cmd for cmd in commands.values() if cmd.get("source_branch") == branch_name] return sorted(filtered, key=lambda c: c.get("name", "")) except Exception as exc: logger.error("[%s] Failed to list commands for branch '%s': %s", MODULE_NAME, branch_name, exc) diff --git a/src/aipass/drone/apps/handlers/command_registry/ops.py b/src/aipass/drone/apps/handlers/command_registry/ops.py index 10c90f88..61a84b8f 100644 --- a/src/aipass/drone/apps/handlers/command_registry/ops.py +++ b/src/aipass/drone/apps/handlers/command_registry/ops.py @@ -46,6 +46,7 @@ REGISTRY_FILE: Path = _BRANCH_ROOT / "drone_command_registry.json" # Internal helpers # --------------------------------------------------------------------------- + def _today() -> str: """Return today's date as ISO string.""" return datetime.now().date().isoformat() @@ -76,6 +77,7 @@ def _registry_path() -> Path: # Core CRUD # --------------------------------------------------------------------------- + def load_registry() -> dict[str, Any]: """Load the command registry from disk, auto-creating if missing. diff --git a/src/aipass/drone/apps/handlers/discovery_handler.py b/src/aipass/drone/apps/handlers/discovery_handler.py index 61c65999..e82b344b 100644 --- a/src/aipass/drone/apps/handlers/discovery_handler.py +++ b/src/aipass/drone/apps/handlers/discovery_handler.py @@ -49,11 +49,7 @@ def scan_modules_directory(branch_path: str) -> List[str]: return [] excluded = {"__init__", "__main__"} - return sorted( - f.stem - for f in modules_dir.glob("*.py") - if f.stem not in excluded - ) + return sorted(f.stem for f in modules_dir.glob("*.py") if f.stem not in excluded) def parse_help_for_commands(help_text: str) -> List[str]: @@ -135,8 +131,7 @@ def get_help(branch_path: str, branch_name: str, command: Optional[str] = None) entry_point = get_entry_point(branch_path, branch_name) if entry_point is None: raise CommandExecutionError( - f"Entry point not found for branch '{branch_name}': " - f"{Path(branch_path) / 'apps' / (branch_name + '.py')}" + f"Entry point not found for branch '{branch_name}': {Path(branch_path) / 'apps' / (branch_name + '.py')}" ) relative_entry = str(entry_point.relative_to(branch_path)) @@ -154,13 +149,9 @@ def get_help(branch_path: str, branch_name: str, command: Optional[str] = None) shell=False, ) except subprocess.TimeoutExpired as e: - raise CommandExecutionError( - f"Help command timed out for branch '{branch_name}'" - ) from e + raise CommandExecutionError(f"Help command timed out for branch '{branch_name}'") from e except OSError as e: - raise CommandExecutionError( - f"OS error getting help for branch '{branch_name}': {e}" - ) from e + raise CommandExecutionError(f"OS error getting help for branch '{branch_name}': {e}") from e stdout = result.stdout.decode("utf-8", errors="replace") stderr = result.stderr.decode("utf-8", errors="replace") @@ -203,7 +194,8 @@ def get_system_help(active_branches: List[str]) -> Dict[str, HelpResult]: except Exception as exc: logger.info( "get_system_help: skipping branch '%s': %s", - branch_name, exc, + branch_name, + exc, ) return results diff --git a/src/aipass/drone/apps/handlers/exceptions.py b/src/aipass/drone/apps/handlers/exceptions.py index 3d5fe13e..0486ce8a 100644 --- a/src/aipass/drone/apps/handlers/exceptions.py +++ b/src/aipass/drone/apps/handlers/exceptions.py @@ -19,31 +19,37 @@ json_handler.log_operation("exceptions_loaded", module_name="exceptions") class RoutingError(Exception): """Base exception for all routing-related errors.""" + pass class BranchNotFoundError(RoutingError): """Raised when a branch cannot be found in the registry.""" + pass class BranchAlreadyExistsError(RoutingError): """Raised when attempting to register a branch that already exists.""" + pass class InvalidPathError(RoutingError): """Raised when a path is invalid or doesn't exist.""" + pass class RegistryError(RoutingError): """Base exception for registry-related errors.""" + pass class RegistryNotFoundError(RegistryError): """Raised when the registry file doesn't exist.""" + pass @@ -53,19 +59,23 @@ class RegistryMismatchError(RegistryError): NOT recoverable — unlike RegistryNotFoundError (no file, use fallback), a mismatch means the wrong registry was found. Must error, not fall back. """ + pass class RegistryCorruptError(RegistryError): """Raised when the registry file is corrupted or invalid JSON.""" + pass class RegistryPermissionError(RegistryError): """Raised when there are permission issues accessing the registry.""" + pass class CommandExecutionError(RoutingError): """Raised when command execution fails.""" + pass diff --git a/src/aipass/drone/apps/handlers/executor.py b/src/aipass/drone/apps/handlers/executor.py index 76fce672..cecce70e 100644 --- a/src/aipass/drone/apps/handlers/executor.py +++ b/src/aipass/drone/apps/handlers/executor.py @@ -79,22 +79,14 @@ def execute_command( except KeyboardInterrupt: # Clean exit on Ctrl+C — no traceback if interactive: - return CommandResult( - stdout="", stderr="", exit_code=130, branch="", command="" - ) + return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="") raise except subprocess.TimeoutExpired as e: - raise CommandExecutionError( - f"Command timed out after {timeout}s: {' '.join(full_cmd)}" - ) from e + raise CommandExecutionError(f"Command timed out after {timeout}s: {' '.join(full_cmd)}") from e except FileNotFoundError as e: - raise CommandExecutionError( - f"Executable not found: {executable!r}" - ) from e + raise CommandExecutionError(f"Executable not found: {executable!r}") from e except OSError as e: - raise CommandExecutionError( - f"OS error executing command: {e}" - ) from e + raise CommandExecutionError(f"OS error executing command: {e}") from e if interactive: return CommandResult( diff --git a/src/aipass/drone/apps/handlers/generic_adapter.py b/src/aipass/drone/apps/handlers/generic_adapter.py index eeb0f00e..56ebbc6c 100644 --- a/src/aipass/drone/apps/handlers/generic_adapter.py +++ b/src/aipass/drone/apps/handlers/generic_adapter.py @@ -79,9 +79,7 @@ def capture_main( except Exception as exc: captured_err.write(str(exc)) exit_code = 1 - logger.warning( - "capture_main: exception from '%s': %s", entry_point_module, exc - ) + logger.warning("capture_main: exception from '%s': %s", entry_point_module, exc) finally: sys.argv = original_argv sys.stdout = old_stdout diff --git a/src/aipass/drone/apps/handlers/git/lock_handler.py b/src/aipass/drone/apps/handlers/git/lock_handler.py index 092b3b5b..7ca40b9a 100644 --- a/src/aipass/drone/apps/handlers/git/lock_handler.py +++ b/src/aipass/drone/apps/handlers/git/lock_handler.py @@ -42,7 +42,9 @@ def find_repo_root() -> Path: try: result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], - capture_output=True, text=True, cwd=str(cwd), + capture_output=True, + text=True, + cwd=str(cwd), ) if result.returncode == 0 and result.stdout.strip(): return Path(result.stdout.strip()) diff --git a/src/aipass/drone/apps/handlers/git/pr_handler.py b/src/aipass/drone/apps/handlers/git/pr_handler.py index 219957ac..1e3461e7 100644 --- a/src/aipass/drone/apps/handlers/git/pr_handler.py +++ b/src/aipass/drone/apps/handlers/git/pr_handler.py @@ -71,12 +71,13 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Step 1: Check we're on main current = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if current.stdout.strip() != "main": result["message"] = ( - f"Not on main branch (currently on {current.stdout.strip()}). " - "Checkout main before creating a PR." + f"Not on main branch (currently on {current.stdout.strip()}). Checkout main before creating a PR." ) logger.error(result["message"]) return result @@ -92,12 +93,16 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: try: rel_dir = branch_dir.resolve().relative_to(repo_root.resolve()) except ValueError: - logger.warning("create_pr: branch_dir %s not relative to repo root %s, using absolute", branch_dir, repo_root) + logger.warning( + "create_pr: branch_dir %s not relative to repo root %s, using absolute", branch_dir, repo_root + ) rel_dir = branch_dir add_result = subprocess.run( ["git", "add", str(rel_dir) + "/"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if add_result.returncode != 0: result["message"] = f"Failed to stage files: {add_result.stderr.strip()}" @@ -107,7 +112,9 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Step 4: Check if anything was staged diff_check = subprocess.run( ["git", "diff", "--cached", "--quiet"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if diff_check.returncode == 0: result["message"] = "Nothing to commit: no changes staged under branch directory" @@ -115,13 +122,12 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: return result # Step 5: Commit on main (changes stay local) - commit_msg = ( - f"feat({branch_name}): {description}\n\n" - f"Co-Authored-By: @{branch_name} <{branch_name}@aipass>" - ) + commit_msg = f"feat({branch_name}): {description}\n\nCo-Authored-By: @{branch_name} <{branch_name}@aipass>" commit = subprocess.run( ["git", "commit", "-m", commit_msg], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if commit.returncode != 0: result["message"] = f"Commit failed: {commit.stderr.strip()}" @@ -131,7 +137,9 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Step 6: Create/update feature branch pointing to same commit (no checkout) branch_create = subprocess.run( ["git", "branch", "-f", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if branch_create.returncode != 0: result["message"] = f"Failed to create branch: {branch_create.stderr.strip()}" @@ -141,7 +149,9 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Step 7: Push feature branch (force-with-lease for persistent citizen branches) push = subprocess.run( ["git", "push", "--force-with-lease", "origin", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if push.returncode != 0: result["message"] = f"Push failed: {push.stderr.strip()}" @@ -149,26 +159,30 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: return result # Step 8: Create PR via gh - pr_body = ( - f"## Summary\n\n" - f"- {description}\n\n" - f"## Branch\n\n" - f"Created by @{branch_name} via `drone @git pr`\n" - ) + pr_body = f"## Summary\n\n- {description}\n\n## Branch\n\nCreated by @{branch_name} via `drone @git pr`\n" pr_create = subprocess.run( [ - "gh", "pr", "create", - "--head", feature_branch, - "--title", f"feat({branch_name}): {description}", - "--body", pr_body, + "gh", + "pr", + "create", + "--head", + feature_branch, + "--title", + f"feat({branch_name}): {description}", + "--body", + pr_body, ], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if pr_create.returncode != 0: # Check if PR already exists — force-push already updated it existing = subprocess.run( ["gh", "pr", "list", "--head", feature_branch, "--json", "url", "--limit", "1"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) try: existing_prs = _json.loads(existing.stdout) @@ -184,7 +198,9 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Clean up local feature branch before returning subprocess.run( ["git", "branch", "-D", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) return result else: @@ -193,7 +209,9 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Step 9: Clean up local feature branch (remote copy is what matters) subprocess.run( ["git", "branch", "-D", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) result["success"] = True @@ -208,6 +226,7 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: # Fire pr_created event (non-blocking — never fail the PR workflow) try: from aipass.trigger.apps.modules.core import trigger + trigger.fire("pr_created", branch=branch_name, pr_url=pr_url) except Exception as exc: logger.warning("trigger.fire('pr_created') failed: %s", exc) diff --git a/src/aipass/drone/apps/handlers/git/status_handler.py b/src/aipass/drone/apps/handlers/git/status_handler.py index f2a9726a..13126515 100644 --- a/src/aipass/drone/apps/handlers/git/status_handler.py +++ b/src/aipass/drone/apps/handlers/git/status_handler.py @@ -37,7 +37,9 @@ def get_branch_status(branch_dir: Path) -> dict: try: result = subprocess.run( ["git", "status", "--porcelain"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) except (OSError, subprocess.SubprocessError) as exc: logger.error("git status failed: %s", exc) @@ -54,7 +56,9 @@ def get_branch_status(branch_dir: Path) -> dict: try: rel_dir = branch_dir.resolve().relative_to(repo_root.resolve()) except ValueError: - logger.warning("get_branch_status: branch_dir %s not relative to repo root %s, using absolute", branch_dir, repo_root) + logger.warning( + "get_branch_status: branch_dir %s not relative to repo root %s, using absolute", branch_dir, repo_root + ) rel_dir = branch_dir rel_prefix = str(rel_dir) + "/" diff --git a/src/aipass/drone/apps/handlers/git/sync_handler.py b/src/aipass/drone/apps/handlers/git/sync_handler.py index b5835040..014b6caf 100644 --- a/src/aipass/drone/apps/handlers/git/sync_handler.py +++ b/src/aipass/drone/apps/handlers/git/sync_handler.py @@ -33,7 +33,9 @@ def sync_main() -> dict: try: checkout = subprocess.run( ["git", "checkout", "main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if checkout.returncode != 0: msg = f"Failed to checkout main: {checkout.stderr.strip()}" @@ -43,7 +45,9 @@ def sync_main() -> dict: # Fetch first to get latest remote state fetch = subprocess.run( ["git", "fetch", "origin"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if fetch.returncode != 0: msg = f"Failed to fetch: {fetch.stderr.strip()}" @@ -53,7 +57,9 @@ def sync_main() -> dict: # Check divergence to choose strategy rev_list = subprocess.run( ["git", "rev-list", "--left-right", "--count", "main...origin/main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) ahead, behind = 0, 0 if rev_list.returncode == 0: @@ -65,7 +71,9 @@ def sync_main() -> dict: # Diverged — merge instead of rebase result = subprocess.run( ["git", "merge", "origin/main", "--no-edit"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if result.returncode != 0: msg = f"Merge conflict (ahead={ahead}, behind={behind}): {result.stderr.strip()}" @@ -77,7 +85,9 @@ def sync_main() -> dict: # Normal — pull with rebase result = subprocess.run( ["git", "pull", "--rebase"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if result.returncode != 0: msg = f"Failed to pull: {result.stderr.strip()}" diff --git a/src/aipass/drone/apps/handlers/json/json_handler.py b/src/aipass/drone/apps/handlers/json/json_handler.py index c01599d4..9c61e204 100644 --- a/src/aipass/drone/apps/handlers/json/json_handler.py +++ b/src/aipass/drone/apps/handlers/json/json_handler.py @@ -30,7 +30,7 @@ from aipass.prax import logger # --------------------------------------------------------------------------- _BRANCH_ROOT: Path = Path(__file__).resolve().parents[3] -_BRANCH_NAME: str = _BRANCH_ROOT.name # "drone" +_BRANCH_NAME: str = _BRANCH_ROOT.name # "drone" JSON_DIR: Path = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" _JSON_TYPES: tuple[str, ...] = ("config", "data", "log") @@ -40,6 +40,7 @@ _JSON_TYPES: tuple[str, ...] = ("config", "data", "log") # Internal helpers # --------------------------------------------------------------------------- + def _today() -> str: """Return today's date as ISO string.""" return datetime.now().date().isoformat() @@ -70,9 +71,7 @@ def _atomic_write_json(path: Path, data: Any) -> None: Prevents truncation/corruption during concurrent access. """ path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_path = tempfile.mkstemp( - dir=str(path.parent), suffix=".tmp", prefix=".json_" - ) + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=".json_") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=False) @@ -126,6 +125,7 @@ _DEFAULTS: dict[str, Any] = { # Validation # --------------------------------------------------------------------------- + def validate_json_structure(data: Any, json_type: str) -> bool: """Validate that *data* matches the expected shape for *json_type*. @@ -158,6 +158,7 @@ def validate_json_structure(data: Any, json_type: str) -> bool: # Path helpers # --------------------------------------------------------------------------- + def get_json_path(module_name: str, json_type: str) -> Path: """Return the filesystem path for *module_name*'s JSON of *json_type*. @@ -175,6 +176,7 @@ def get_json_path(module_name: str, json_type: str) -> Path: # CRUD # --------------------------------------------------------------------------- + def ensure_json_exists(module_name: str, json_type: str) -> bool: """Ensure a single JSON file exists; create with inline defaults if missing. @@ -288,6 +290,7 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: # High-level operations # --------------------------------------------------------------------------- + def log_operation( operation: str, data: dict[str, Any] | None = None, @@ -424,10 +427,12 @@ if __name__ == "__main__": console = Console() console.print() - console.print(Panel.fit( - "[bold cyan]JSON HANDLER (drone) — Smoke Test[/bold cyan]", - border_style="bright_blue", - )) + console.print( + Panel.fit( + "[bold cyan]JSON HANDLER (drone) — Smoke Test[/bold cyan]", + border_style="bright_blue", + ) + ) console.print() console.print(f"[dim]Branch root:[/dim] {_BRANCH_ROOT}") console.print(f"[dim]JSON dir:[/dim] {JSON_DIR}") diff --git a/src/aipass/drone/apps/handlers/module_registry_handler.py b/src/aipass/drone/apps/handlers/module_registry_handler.py index 78af9a61..15731df7 100644 --- a/src/aipass/drone/apps/handlers/module_registry_handler.py +++ b/src/aipass/drone/apps/handlers/module_registry_handler.py @@ -55,9 +55,7 @@ class _ExternalModuleConfig: def _load_external_modules() -> dict[str, _ExternalModuleConfig]: """Load external module declarations from routing_config.json.""" if not _ROUTING_CONFIG_PATH.exists(): - logger.warning( - "_load_external_modules: config not found at %s", _ROUTING_CONFIG_PATH - ) + logger.warning("_load_external_modules: config not found at %s", _ROUTING_CONFIG_PATH) return {} try: with open(_ROUTING_CONFIG_PATH, encoding="utf-8") as fh: @@ -73,9 +71,7 @@ def _load_external_modules() -> dict[str, _ExternalModuleConfig]: ) return result except Exception as exc: - logger.warning( - "_load_external_modules: failed to load config: %s", exc - ) + logger.warning("_load_external_modules: failed to load config: %s", exc) return {} @@ -150,9 +146,7 @@ def get_module_info(name: str) -> ModuleInfo | None: return None -def route_module_command( - name: str, command: str, args: list[str] | None = None -) -> dict: +def route_module_command(name: str, command: str, args: list[str] | None = None) -> dict: """Route a command to a module. For external modules: uses generic_adapter.capture_main(). @@ -163,9 +157,7 @@ def route_module_command( ext = _EXTERNAL_MODULES.get(name) if ext is not None: result = capture_main(ext.entry_point, ext.name, command, args) - json_handler.log_operation( - "route_module_command", {"module": name, "command": command} - ) + json_handler.log_operation("route_module_command", {"module": name, "command": command}) return result adapter_path = _INTERNAL_MODULES[name] @@ -175,9 +167,7 @@ def route_module_command( # 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} - ) + json_handler.log_operation("route_module_command", {"module": name, "command": command}) return result @@ -234,9 +224,7 @@ def get_module_introspective(name: str) -> str: return help_fn(None) return "" except (ImportError, AttributeError) as exc: - logger.warning( - "get_module_introspective: failed for module '%s': %s", name, exc - ) + logger.warning("get_module_introspective: failed for module '%s': %s", name, exc) return "" diff --git a/src/aipass/drone/apps/handlers/registry_handler.py b/src/aipass/drone/apps/handlers/registry_handler.py index e78e204f..982bba34 100644 --- a/src/aipass/drone/apps/handlers/registry_handler.py +++ b/src/aipass/drone/apps/handlers/registry_handler.py @@ -170,6 +170,7 @@ def reset_registry_path() -> None: # Registry loading and querying # --------------------------------------------------------------------------- + def _load_registry_data(registry_path: Path) -> Dict[str, Any]: """Read, parse, and normalize a registry file. @@ -184,8 +185,7 @@ def _load_registry_data(registry_path: Path) -> Dict[str, Any]: """ if not registry_path.exists(): raise RegistryNotFoundError( - f"Registry not found at {registry_path}. " - "Create a *_REGISTRY.json file in your project root." + f"Registry not found at {registry_path}. Create a *_REGISTRY.json file in your project root." ) try: diff --git a/src/aipass/drone/apps/handlers/router_handler.py b/src/aipass/drone/apps/handlers/router_handler.py index a7f36e99..b00fff64 100644 --- a/src/aipass/drone/apps/handlers/router_handler.py +++ b/src/aipass/drone/apps/handlers/router_handler.py @@ -35,9 +35,7 @@ def find_entry_point(branch_path: str, branch_name: str) -> Path: """ entry_point = Path(branch_path) / "apps" / f"{branch_name}.py" if not entry_point.exists(): - raise CommandExecutionError( - f"Entry point not found for branch '{branch_name}': {entry_point}" - ) + raise CommandExecutionError(f"Entry point not found for branch '{branch_name}': {entry_point}") return entry_point @@ -136,7 +134,9 @@ def execute_branch_command( caller_tag = f" [CALLER:{caller_branch.upper()}]" if caller_branch else "" logger.info("Executed @%s%s %s → exit %d", branch_name, caller_tag, command or "(introspection)", result.exit_code) - json_handler.log_operation("execute_branch_command", {"branch": branch_name, "command": command or "", "exit_code": result.exit_code}) + json_handler.log_operation( + "execute_branch_command", {"branch": branch_name, "command": command or "", "exit_code": result.exit_code} + ) return CommandResult( stdout=result.stdout, diff --git a/src/aipass/drone/apps/handlers/scanning/formatters.py b/src/aipass/drone/apps/handlers/scanning/formatters.py index a0ef57e5..738a175f 100644 --- a/src/aipass/drone/apps/handlers/scanning/formatters.py +++ b/src/aipass/drone/apps/handlers/scanning/formatters.py @@ -18,8 +18,10 @@ try: from aipass.cli.apps.modules import console except ImportError: from aipass.prax import logger + logger.warning("formatters: aipass.cli.apps.modules.console unavailable, using fallback Rich Console") from rich.console import Console + console = Console() from rich.table import Table diff --git a/src/aipass/drone/apps/handlers/scanning/scanner.py b/src/aipass/drone/apps/handlers/scanning/scanner.py index dbec1763..709d5149 100644 --- a/src/aipass/drone/apps/handlers/scanning/scanner.py +++ b/src/aipass/drone/apps/handlers/scanning/scanner.py @@ -36,6 +36,7 @@ from aipass.drone.apps.handlers.discovery_handler import ( # Help-output scanning # --------------------------------------------------------------------------- + def scan_help_output(branch_path: str, branch_name: str) -> list[dict]: """Run the branch entry point with ``--help`` and parse discovered commands. @@ -104,6 +105,7 @@ def _extract_descriptions(help_text: str, command_names: list[str]) -> dict[str, # Module-file scanning # --------------------------------------------------------------------------- + def scan_module_files(branch_path: str) -> list[dict]: """Scan ``apps/modules/*.py`` for files that define ``handle_command()``. @@ -168,6 +170,7 @@ def _extract_module_description(source: str) -> str: # Full branch scan (merge + deduplicate) # --------------------------------------------------------------------------- + def scan_branch(branch_path: str, branch_name: str) -> list[dict]: """Perform a full scan of a branch to discover available commands. diff --git a/src/aipass/drone/apps/modules/commands.py b/src/aipass/drone/apps/modules/commands.py index eb58b4ed..bd9c5160 100644 --- a/src/aipass/drone/apps/modules/commands.py +++ b/src/aipass/drone/apps/modules/commands.py @@ -54,6 +54,7 @@ __all__ = [ # Standard module interface # --------------------------------------------------------------------------- + def handle_command(command: str | None = None, args: list[str] | None = None) -> bool: """Route commands subcommands to handler functions. @@ -90,9 +91,9 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> cmd_args: list[str] = [] for arg in extra: if arg.startswith("--desc="): - description = arg[len("--desc="):] + description = arg[len("--desc=") :] elif arg.startswith("--branch="): - source_branch = arg[len("--branch="):] + source_branch = arg[len("--branch=") :] else: cmd_args.append(arg) @@ -130,7 +131,10 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> return False result = lookup(args[0]) if result: - console.print(" %s -> %s %s %s" % (result["name"], result["target"], result["command"], " ".join(result.get("args", [])))) + console.print( + " %s -> %s %s %s" + % (result["name"], result["target"], result["command"], " ".join(result.get("args", []))) + ) else: logger.warning(" Command '%s' not found", args[0]) return False @@ -147,6 +151,7 @@ def print_introspection() -> None: except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() @@ -167,6 +172,7 @@ def print_help() -> None: except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print("commands -- Custom command shortcuts") @@ -182,6 +188,7 @@ def print_help() -> None: # Delegated operations # --------------------------------------------------------------------------- + def add( name: str, target: str, diff --git a/src/aipass/drone/apps/modules/config.py b/src/aipass/drone/apps/modules/config.py index e20c295f..ad0bb99a 100644 --- a/src/aipass/drone/apps/modules/config.py +++ b/src/aipass/drone/apps/modules/config.py @@ -33,6 +33,7 @@ def print_introspection(): except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() diff --git a/src/aipass/drone/apps/modules/discovery.py b/src/aipass/drone/apps/modules/discovery.py index 6f7ad3b7..7fc7330d 100644 --- a/src/aipass/drone/apps/modules/discovery.py +++ b/src/aipass/drone/apps/modules/discovery.py @@ -89,6 +89,7 @@ def print_introspection(): except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index 24dbeba1..01f88d63 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -402,23 +402,13 @@ def get_help(command: str | None = None) -> str: " creates a feature branch, commits, pushes, and opens a PR.\n" ) if command == "status": - return ( - "git status — Show git status filtered to your branch directory\n" - ) + return "git status — Show git status filtered to your branch directory\n" if command == "sync": - return ( - "git sync — Checkout main and pull latest changes\n" - ) + return "git sync — Checkout main and pull latest changes\n" if command == "lock": - return ( - "git lock — Check current lock status\n" - " Shows lock holder, age, stale/orphan detection.\n" - ) + return "git lock — Check current lock status\n Shows lock holder, age, stale/orphan detection.\n" if command == "unlock": - return ( - "git unlock --force — Force-release the PR lock\n" - " Removes .git_pr.lock regardless of holder.\n" - ) + return "git unlock --force — Force-release the PR lock\n Removes .git_pr.lock regardless of holder.\n" if command == "system-pr": return ( "git system-pr — Create a system-wide PR (devpulse only)\n" @@ -487,6 +477,7 @@ def print_introspection() -> None: except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print(get_introspective()) @@ -499,6 +490,7 @@ def print_help() -> None: except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print(get_help()) diff --git a/src/aipass/drone/apps/modules/module_registry.py b/src/aipass/drone/apps/modules/module_registry.py index d3e97b22..f78e1329 100644 --- a/src/aipass/drone/apps/modules/module_registry.py +++ b/src/aipass/drone/apps/modules/module_registry.py @@ -50,6 +50,7 @@ def print_introspection(): except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() diff --git a/src/aipass/drone/apps/modules/registry.py b/src/aipass/drone/apps/modules/registry.py index a76236d1..3827e847 100644 --- a/src/aipass/drone/apps/modules/registry.py +++ b/src/aipass/drone/apps/modules/registry.py @@ -33,6 +33,7 @@ def print_introspection(): except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() diff --git a/src/aipass/drone/apps/modules/resolver.py b/src/aipass/drone/apps/modules/resolver.py index e6bed632..71b7f787 100644 --- a/src/aipass/drone/apps/modules/resolver.py +++ b/src/aipass/drone/apps/modules/resolver.py @@ -105,6 +105,7 @@ def print_introspection(): except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() @@ -149,17 +150,13 @@ def resolve_branch(symbolic_name: str) -> str: RegistryNotFoundError: If registry file missing """ if not symbolic_name.startswith("@"): - raise BranchNotFoundError( - f"Branch name must use @ prefix: '@{symbolic_name}' (got '{symbolic_name}')" - ) + raise BranchNotFoundError(f"Branch name must use @ prefix: '@{symbolic_name}' (got '{symbolic_name}')") name = normalize_branch_name(symbolic_name).lower() branch = get_branch_by_name(name) if branch is None: - raise BranchNotFoundError( - f"Branch '{symbolic_name}' not found in registry" - ) + raise BranchNotFoundError(f"Branch '{symbolic_name}' not found in registry") system_logger.info("Resolved @%s → %s", name, branch["path"]) return branch["path"] @@ -184,9 +181,7 @@ def get_branch_info(symbolic_name: str) -> Dict[str, Any]: branch = registry.get("branches", {}).get(name) if branch is None: - raise BranchNotFoundError( - f"Branch '{symbolic_name}' not found in registry" - ) + raise BranchNotFoundError(f"Branch '{symbolic_name}' not found in registry") return branch diff --git a/src/aipass/drone/apps/modules/router.py b/src/aipass/drone/apps/modules/router.py index 57ece6b6..66894278 100644 --- a/src/aipass/drone/apps/modules/router.py +++ b/src/aipass/drone/apps/modules/router.py @@ -122,6 +122,7 @@ def print_introspection(): except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() @@ -159,7 +160,9 @@ def route_all( except Exception as exc: logger.warning( "route_all: branch '%s' failed for command '%s': %s", - branch_name, command, exc, + branch_name, + command, + exc, ) results[branch_name] = CommandResult( stdout="", diff --git a/src/aipass/drone/apps/modules/scan.py b/src/aipass/drone/apps/modules/scan.py index ca4dd723..a7377444 100644 --- a/src/aipass/drone/apps/modules/scan.py +++ b/src/aipass/drone/apps/modules/scan.py @@ -35,6 +35,7 @@ __all__ = [ # Standard module interface # --------------------------------------------------------------------------- + def handle_command(command: str | None = None, args: list[str] | None = None) -> bool: """Route scan subcommands to handler functions. @@ -71,6 +72,7 @@ def print_introspection() -> None: except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print() @@ -95,6 +97,7 @@ def print_help() -> None: except ImportError: logger.warning("CLI console not available, using fallback") from rich.console import Console + console = Console() console.print("scan -- Branch command scanning") @@ -111,6 +114,7 @@ def print_help() -> None: # Core operation # --------------------------------------------------------------------------- + def scan(target: str) -> list[dict] | None: """Resolve ``@target``, scan for commands, display and return results. @@ -128,6 +132,7 @@ def scan(target: str) -> list[dict] | None: logger.warning("scan: could not resolve '%s': %s", target, exc) try: from aipass.cli.apps.modules import err_console + err_console.print(f"scan: could not resolve '{target}': {exc}") except ImportError as exc: logger.warning("CLI err_console not available, skipping user-facing error: %s", exc) diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc b/src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc index 291041fa..b5fbdb6a 100644 Binary files a/src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc and b/src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc differ diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py index 1ca226e3..8c67434e 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py @@ -52,10 +52,7 @@ def verify_caller() -> str: logger.error(msg) raise PermissionError(msg) if name not in ALLOWED_CALLERS: - msg = ( - f"Branch '{name}' is not authorized for system-pr. " - f"Allowed callers: {ALLOWED_CALLERS}" - ) + msg = f"Branch '{name}' is not authorized for system-pr. Allowed callers: {ALLOWED_CALLERS}" logger.error(msg) raise PermissionError(msg) json_handler.log_operation( @@ -68,9 +65,7 @@ def verify_caller() -> str: raise except Exception as exc: logger.error("Failed to read passport at %s: %s", passport_path, exc) - raise PermissionError( - f"Failed to read passport at {passport_path}: {exc}" - ) from exc + raise PermissionError(f"Failed to read passport at {passport_path}: {exc}") from exc parent = current.parent if parent == current: break diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py index 662971d2..7943c4fb 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py @@ -27,7 +27,9 @@ def _fix_divergence(repo_root: Path, actions: list[str]) -> None: """Fetch origin and merge if local main has diverged.""" fetch = subprocess.run( ["git", "fetch", "origin"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if fetch.returncode != 0: actions.append(f"Fetch failed: {fetch.stderr.strip()}") @@ -36,7 +38,9 @@ def _fix_divergence(repo_root: Path, actions: list[str]) -> None: rev_list = subprocess.run( ["git", "rev-list", "--left-right", "--count", "main...origin/main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if rev_list.returncode != 0: return @@ -49,7 +53,9 @@ def _fix_divergence(repo_root: Path, actions: list[str]) -> None: merge = subprocess.run( ["git", "merge", "origin/main", "--no-edit"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if merge.returncode == 0: actions.append(f"Merged origin/main (was ahead={ahead}, behind={behind})") @@ -59,22 +65,23 @@ def _fix_divergence(repo_root: Path, actions: list[str]) -> None: # Merge failed — report conflict files and abort diff = subprocess.run( ["git", "diff", "--name-only", "--diff-filter=U"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else [] subprocess.run( ["git", "merge", "--abort"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if conflict_files: actions.append( - f"Merge conflict (ahead={ahead}, behind={behind}). " - f"Conflicting files: {', '.join(conflict_files)}" + f"Merge conflict (ahead={ahead}, behind={behind}). Conflicting files: {', '.join(conflict_files)}" ) else: - actions.append( - f"Merge failed (ahead={ahead}, behind={behind}): {merge.stderr.strip()}" - ) + actions.append(f"Merge failed (ahead={ahead}, behind={behind}): {merge.stderr.strip()}") logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind) @@ -106,7 +113,9 @@ def fix_git_state(caller: str) -> dict: if rebase_merge.exists() or rebase_apply.exists(): abort = subprocess.run( ["git", "rebase", "--abort"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if abort.returncode == 0: actions.append("Aborted stuck rebase") @@ -118,12 +127,16 @@ def fix_git_state(caller: str) -> dict: # Check 2: Detached HEAD sym_ref = subprocess.run( ["git", "symbolic-ref", "-q", "HEAD"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if sym_ref.returncode != 0: checkout = subprocess.run( ["git", "checkout", "main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if checkout.returncode == 0: actions.append("Checked out main (was detached HEAD)") @@ -138,13 +151,17 @@ def fix_git_state(caller: str) -> dict: # Check 4: Dirty index with no intent cached = subprocess.run( ["git", "diff", "--cached", "--name-only"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if cached.returncode == 0 and cached.stdout.strip(): staged_files = cached.stdout.strip().splitlines() reset = subprocess.run( ["git", "reset", "HEAD"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if reset.returncode == 0: actions.append(f"Unstaged {len(staged_files)} file(s) from index") diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py index 02a47920..0159a0dd 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py @@ -46,7 +46,9 @@ def merge_pr(pr_number: str, caller: str) -> dict: # Step 1: Squash-merge the PR merge = subprocess.run( ["gh", "pr", "merge", pr_number, "--squash", "--delete-branch"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if merge.returncode != 0: merge_stderr = merge.stderr.strip() @@ -56,9 +58,9 @@ def merge_pr(pr_number: str, caller: str) -> dict: # cleanup failed. Treat this as a warning and continue. if "cannot delete branch" in merge_stderr and "worktree" in merge_stderr: logger.warning( - "merge_pr: PR #%s merged but local branch cleanup skipped " - "(branch in use by worktree): %s", - pr_number, merge_stderr, + "merge_pr: PR #%s merged but local branch cleanup skipped (branch in use by worktree): %s", + pr_number, + merge_stderr, ) else: result["message"] = f"Merge failed: {merge_stderr}" @@ -69,19 +71,25 @@ def merge_pr(pr_number: str, caller: str) -> dict: # git pull --rebase doesn't abort on a dirty working tree. stash = subprocess.run( ["git", "stash"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) stashed = "No local changes to save" not in stash.stdout pull = subprocess.run( ["git", "pull", "--rebase"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if pull.returncode != 0: if stashed: subprocess.run( ["git", "stash", "pop"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) result["message"] = f"Pull after merge failed: {pull.stderr.strip()}" logger.error(result["message"]) @@ -90,7 +98,9 @@ def merge_pr(pr_number: str, caller: str) -> dict: if stashed: pop = subprocess.run( ["git", "stash", "pop"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if pop.returncode != 0: logger.warning( @@ -101,14 +111,18 @@ def merge_pr(pr_number: str, caller: str) -> dict: # Step 3: Get the merge commit hash rev = subprocess.run( ["git", "rev-parse", "HEAD"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) merge_commit = rev.stdout.strip() if rev.returncode == 0 else "unknown" # Step 4: Get the PR title title_proc = subprocess.run( ["gh", "pr", "view", pr_number, "--json", "title", "--jq", ".title"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) title = title_proc.stdout.strip() if title_proc.returncode == 0 else "unknown" @@ -130,6 +144,7 @@ def merge_pr(pr_number: str, caller: str) -> dict: # Fire pr_merged event (non-blocking — never fail the merge workflow) try: from aipass.trigger.apps.modules.core import trigger + trigger.fire("pr_merged", pr_number=pr_number, title=title) except Exception as exc: logger.warning("trigger.fire('pr_merged') failed: %s", exc) diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py index 8a405bca..ab6279db 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py @@ -85,7 +85,9 @@ def create_system_pr(description: str, caller: str) -> dict: # Step 1: Check we are on main current = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if current.stdout.strip() != "main": result["message"] = ( @@ -105,7 +107,9 @@ def create_system_pr(description: str, caller: str) -> dict: # Step 3: Sync STATUS.md before staging so the fresh state is committed sync_result = subprocess.run( ["drone", "@prax", "status", "sync"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if sync_result.returncode != 0: logger.warning("create_system_pr: status sync failed (non-fatal): %s", sync_result.stderr.strip()) @@ -113,30 +117,35 @@ def create_system_pr(description: str, caller: str) -> dict: # Stage all changes including untracked new files subprocess.run( ["git", "add", "-A"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) # Unstage .git_pr.lock — it is acquired (created) for this workflow # and must never be committed. Belt-and-suspenders alongside .gitignore. subprocess.run( ["git", "reset", "HEAD", ".git_pr.lock"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) # Step 4: If anything is staged, commit it (normal commit on main) diff_check = subprocess.run( ["git", "diff", "--cached", "--quiet"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if diff_check.returncode != 0: # There are staged changes — commit them - commit_msg = ( - f"feat(system): {description}\n\n" - f"Co-Authored-By: @{caller} <{caller}@aipass>" - ) + commit_msg = f"feat(system): {description}\n\nCo-Authored-By: @{caller} <{caller}@aipass>" commit = subprocess.run( ["git", "commit", "-m", commit_msg], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if commit.returncode != 0: result["message"] = f"Commit failed: {commit.stderr.strip()}" @@ -146,11 +155,15 @@ def create_system_pr(description: str, caller: str) -> dict: # Step 5: Check if main is ahead of origin/main subprocess.run( ["git", "fetch", "origin", "main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) ahead_check = subprocess.run( ["git", "rev-list", "--count", "origin/main..HEAD"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) ahead_count = int(ahead_check.stdout.strip() or "0") if ahead_count == 0: @@ -161,7 +174,9 @@ def create_system_pr(description: str, caller: str) -> dict: # Step 6: Create feature branch at current main tip branch_create = subprocess.run( ["git", "branch", "-f", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if branch_create.returncode != 0: result["message"] = f"Failed to create branch: {branch_create.stderr.strip()}" @@ -171,7 +186,9 @@ def create_system_pr(description: str, caller: str) -> dict: # Step 7: Push feature branch push = subprocess.run( ["git", "push", "--force-with-lease", "origin", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if push.returncode != 0: result["message"] = f"Push failed: {push.stderr.strip()}" @@ -187,19 +204,28 @@ def create_system_pr(description: str, caller: str) -> dict: ) pr_create = subprocess.run( [ - "gh", "pr", "create", - "--head", feature_branch, - "--title", f"feat(system): {description}", - "--body", pr_body, + "gh", + "pr", + "create", + "--head", + feature_branch, + "--title", + f"feat(system): {description}", + "--body", + pr_body, ], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if pr_create.returncode != 0: result["message"] = f"PR creation failed: {pr_create.stderr.strip()}" logger.error(result["message"]) subprocess.run( ["git", "branch", "-D", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) return result @@ -208,7 +234,9 @@ def create_system_pr(description: str, caller: str) -> dict: # Step 9: Clean up local feature branch (main stays untouched) subprocess.run( ["git", "branch", "-D", feature_branch], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) result["success"] = True diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py index cb19b960..0a9aab78 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py @@ -45,7 +45,9 @@ def smart_sync(caller: str) -> dict: # Step 1: Fetch origin fetch = subprocess.run( ["git", "fetch", "origin"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if fetch.returncode != 0: result["message"] = f"Fetch failed: {fetch.stderr.strip()}" @@ -55,7 +57,9 @@ def smart_sync(caller: str) -> dict: # Step 2: Check divergence rev_list = subprocess.run( ["git", "rev-list", "--left-right", "--count", "main...origin/main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if rev_list.returncode != 0: result["message"] = f"Divergence check failed: {rev_list.stderr.strip()}" @@ -74,56 +78,58 @@ def smart_sync(caller: str) -> dict: # Diverged — merge instead of rebase merge = subprocess.run( ["git", "merge", "origin/main", "--no-edit"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if merge.returncode != 0: # Merge conflict — abort and report diff = subprocess.run( ["git", "diff", "--name-only", "--diff-filter=U"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else [] subprocess.run( ["git", "merge", "--abort"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) files_msg = f" Conflicting files: {', '.join(conflict_files)}" if conflict_files else "" - result["message"] = ( - f"Merge conflict (ahead={ahead}, behind={behind}). " - f"Merge aborted.{files_msg}" - ) + result["message"] = f"Merge conflict (ahead={ahead}, behind={behind}). Merge aborted.{files_msg}" logger.error(result["message"]) return result result["rebased"] = False result["merged"] = True result["success"] = True - result["message"] = ( - f"Merged origin/main (was {ahead} ahead, {behind} behind)" - ) + result["message"] = f"Merged origin/main (was {ahead} ahead, {behind} behind)" else: # Only behind — rebase is safe rebase = subprocess.run( ["git", "rebase", "origin/main"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) if rebase.returncode != 0: subprocess.run( ["git", "rebase", "--abort"], - capture_output=True, text=True, cwd=str(repo_root), + capture_output=True, + text=True, + cwd=str(repo_root), ) result["message"] = ( - f"Rebase conflict (ahead={ahead}, behind={behind}). " - "Rebase aborted. Manual resolution required." + f"Rebase conflict (ahead={ahead}, behind={behind}). Rebase aborted. Manual resolution required." ) logger.error(result["message"]) return result result["rebased"] = True result["success"] = True - result["message"] = ( - f"Rebased onto origin/main (was {behind} behind)" - ) + result["message"] = f"Rebased onto origin/main (was {behind} behind)" else: result["success"] = True result["message"] = "Already up to date" diff --git a/src/aipass/drone/tests/conftest.py b/src/aipass/drone/tests/conftest.py index 6cb44d91..39be8b63 100644 --- a/src/aipass/drone/tests/conftest.py +++ b/src/aipass/drone/tests/conftest.py @@ -1,4 +1,5 @@ """Shared pytest fixtures for drone tests.""" + import os import tempfile diff --git a/src/aipass/drone/tests/test_activation.py b/src/aipass/drone/tests/test_activation.py index 680e0ad1..ccaf4e7c 100644 --- a/src/aipass/drone/tests/test_activation.py +++ b/src/aipass/drone/tests/test_activation.py @@ -38,6 +38,7 @@ from aipass.drone.apps.handlers.executor import CommandResult # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolated_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Point the registry at a temp file so tests never touch the real one.""" @@ -63,6 +64,7 @@ def _seed_commands(**commands: dict[str, Any]) -> None: # 1. Formatters # =================================================================== + class TestFormatCommandList: """Tests for format_command_list().""" @@ -171,6 +173,7 @@ class TestFormatRemoval: # 2. _handle_activate # =================================================================== + class TestHandleActivate: """Tests for _handle_activate() in drone.py.""" @@ -178,6 +181,7 @@ class TestHandleActivate: def _get_handler(self): """Import the handler to test.""" from aipass.drone.apps.drone import _handle_activate + return _handle_activate @patch("aipass.drone.apps.modules.commands.format_activation_results") @@ -259,6 +263,7 @@ class TestHandleActivate: # 3. _handle_list # =================================================================== + class TestHandleList: """Tests for _handle_list() in drone.py.""" @@ -292,6 +297,7 @@ class TestHandleList: # 4. _handle_remove # =================================================================== + class TestHandleRemove: """Tests for _handle_remove() in drone.py.""" @@ -323,6 +329,7 @@ class TestHandleRemove: # 5. _handle_custom_command # =================================================================== + class TestHandleCustomCommand: """Tests for _handle_custom_command() in drone.py.""" @@ -334,14 +341,19 @@ class TestHandleCustomCommand: ops.add_command("audit", "@seedgo", "audit", args=["aipass"]) mock_route.return_value = CommandResult( - stdout="ok\n", stderr="", exit_code=0, branch="seedgo", command="audit", + stdout="ok\n", + stderr="", + exit_code=0, + branch="seedgo", + command="audit", ) result = _handle_custom_command(["audit"]) assert result == 0 mock_route.assert_called_once_with( - "@seedgo", "audit", + "@seedgo", + "audit", args=["aipass"], interactive=True, ) @@ -354,14 +366,19 @@ class TestHandleCustomCommand: ops.add_command("audit", "@seedgo", "audit", args=["aipass"]) mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="seedgo", command="audit", + stdout="", + stderr="", + exit_code=0, + branch="seedgo", + command="audit", ) result = _handle_custom_command(["audit", "@drone"]) assert result == 0 mock_route.assert_called_once_with( - "@seedgo", "audit", + "@seedgo", + "audit", args=["aipass", "@drone"], interactive=True, ) @@ -382,7 +399,11 @@ class TestHandleCustomCommand: ops.add_command("mon", "@prax", "monitor") mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="prax", command="monitor", + stdout="", + stderr="", + exit_code=0, + branch="prax", + command="monitor", ) _handle_custom_command(["mon"]) @@ -398,7 +419,11 @@ class TestHandleCustomCommand: ops.add_command("status", "@cli", "status") mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="cli", command="status", + stdout="", + stderr="", + exit_code=0, + branch="cli", + command="status", ) _handle_custom_command(["status"]) @@ -412,7 +437,11 @@ class TestHandleCustomCommand: from aipass.drone.apps.drone import _handle_target mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="devpulse", command="watchdog", + stdout="", + stderr="", + exit_code=0, + branch="devpulse", + command="watchdog", ) _handle_target(["@devpulse", "watchdog", "--help"]) @@ -428,7 +457,11 @@ class TestHandleCustomCommand: ops.add_command("failing", "@test", "fail") mock_route.return_value = CommandResult( - stdout="", stderr="error\n", exit_code=2, branch="test", command="fail", + stdout="", + stderr="error\n", + exit_code=2, + branch="test", + command="fail", ) result = _handle_custom_command(["failing"]) @@ -457,7 +490,11 @@ class TestHandleCustomCommand: ops.add_command("simple", "@test", "simple") mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="test", command="simple", + stdout="", + stderr="", + exit_code=0, + branch="test", + command="simple", ) _handle_custom_command(["simple"]) @@ -470,6 +507,7 @@ class TestHandleCustomCommand: # 6. main() integration # =================================================================== + class TestMainIntegration: """Tests for main() routing of new commands.""" @@ -563,8 +601,11 @@ class TestMainIntegration: ops.add_command("audit", "@seedgo", "audit", args=["aipass"]) mock_route.return_value = CommandResult( - stdout="audit output\n", stderr="", exit_code=0, - branch="seedgo", command="audit", + stdout="audit output\n", + stderr="", + exit_code=0, + branch="seedgo", + command="audit", ) with patch("sys.argv", ["drone", "audit"]): @@ -572,7 +613,8 @@ class TestMainIntegration: assert result == 0 mock_route.assert_called_once_with( - "@seedgo", "audit", + "@seedgo", + "audit", args=["aipass"], interactive=True, ) @@ -585,8 +627,11 @@ class TestMainIntegration: ops.add_command("audit", "@seedgo", "audit", args=["aipass"]) mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, - branch="seedgo", command="audit", + stdout="", + stderr="", + exit_code=0, + branch="seedgo", + command="audit", ) with patch("sys.argv", ["drone", "audit", "@drone"]): @@ -594,7 +639,8 @@ class TestMainIntegration: assert result == 0 mock_route.assert_called_once_with( - "@seedgo", "audit", + "@seedgo", + "audit", args=["aipass", "@drone"], interactive=True, ) @@ -629,6 +675,7 @@ class TestMainIntegration: # 7. match_command integration # =================================================================== + class TestMatchCommandIntegration: """Tests verifying match_command works correctly with registered commands.""" @@ -652,8 +699,11 @@ class TestMatchCommandIntegration: ops.add_command("plan create", "@flow", "create", args=["--type=plan"]) mock_route.return_value = CommandResult( - stdout="created\n", stderr="", exit_code=0, - branch="flow", command="create", + stdout="created\n", + stderr="", + exit_code=0, + branch="flow", + command="create", ) with patch("sys.argv", ["drone", "plan", "create", "my-plan"]): @@ -661,7 +711,8 @@ class TestMatchCommandIntegration: assert result == 0 mock_route.assert_called_once_with( - "@flow", "create", + "@flow", + "create", args=["--type=plan", "my-plan"], interactive=False, ) diff --git a/src/aipass/drone/tests/test_commands.py b/src/aipass/drone/tests/test_commands.py index 09c8f08e..4c278b87 100644 --- a/src/aipass/drone/tests/test_commands.py +++ b/src/aipass/drone/tests/test_commands.py @@ -30,6 +30,7 @@ from aipass.drone.apps.handlers.command_registry import ops, lookup # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolated_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Point the registry at a temp file so tests never touch the real one.""" @@ -57,8 +58,8 @@ def _seed_registry(registry_file: Path, commands: dict[str, Any] | None = None) # 1. Registry auto-creation # =================================================================== -class TestRegistryAutoCreation: +class TestRegistryAutoCreation: def test_load_creates_file_when_missing(self, isolated_registry: Path) -> None: """load_registry() creates a new file when none exists.""" assert not isolated_registry.exists() @@ -103,8 +104,8 @@ class TestRegistryAutoCreation: # 2. CRUD operations # =================================================================== -class TestCRUDOperations: +class TestCRUDOperations: def test_add_command(self, isolated_registry: Path) -> None: """add_command creates a new entry in the registry.""" result = ops.add_command( @@ -216,8 +217,8 @@ class TestCRUDOperations: # 3. Lookup (exact match) # =================================================================== -class TestLookup: +class TestLookup: def test_lookup_found(self, isolated_registry: Path) -> None: """lookup_command returns the command dict for an exact match.""" ops.add_command("audit", "@seedgo", "audit", ["aipass"], "Run audit", "seedgo") @@ -239,8 +240,8 @@ class TestLookup: # 4. Multi-word greedy matching # =================================================================== -class TestMultiWordMatching: +class TestMultiWordMatching: def test_single_word_match(self, isolated_registry: Path) -> None: """match_command matches a single-word command.""" ops.add_command("audit", "@seedgo", "audit") @@ -316,8 +317,8 @@ class TestMultiWordMatching: # 5. List and filter by branch # =================================================================== -class TestListAndFilter: +class TestListAndFilter: def test_list_commands_sorted(self, isolated_registry: Path) -> None: """list_commands returns all commands sorted by name.""" ops.add_command("zebra", "@t", "c1") @@ -362,8 +363,8 @@ class TestListAndFilter: # 6. Save validation # =================================================================== -class TestSaveValidation: +class TestSaveValidation: def test_save_rejects_non_dict(self, isolated_registry: Path) -> None: """save_registry rejects data that is not a dict.""" result = ops.save_registry([1, 2, 3]) # type: ignore[arg-type] @@ -381,8 +382,8 @@ class TestSaveValidation: # 7. Module orchestrator # =================================================================== -class TestModuleOrchestrator: +class TestModuleOrchestrator: def test_handle_command_introspection(self, isolated_registry: Path) -> None: """handle_command with no args triggers introspection.""" from aipass.drone.apps.modules.commands import handle_command diff --git a/src/aipass/drone/tests/test_contracts.py b/src/aipass/drone/tests/test_contracts.py index 50e17738..1e449c11 100644 --- a/src/aipass/drone/tests/test_contracts.py +++ b/src/aipass/drone/tests/test_contracts.py @@ -43,13 +43,9 @@ else: if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) if BRANCH_MODULE in ("commons", "skills"): - _handlers_dir = ( - Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" else: - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -77,8 +73,7 @@ for _candidate in _JSON_DIR_CANDIDATES: if _JSON_DIR_ATTR is None: pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- " - f"tried: {_JSON_DIR_CANDIDATES}", + f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- tried: {_JSON_DIR_CANDIDATES}", allow_module_level=True, ) @@ -87,6 +82,7 @@ if _JSON_DIR_ATTR is None: # Isolation fixture # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect JSON operations to tmp_path for test isolation.""" @@ -103,6 +99,7 @@ def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: # Default factory helpers # --------------------------------------------------------------------------- + def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: """Call whichever default factory the branch exposes.""" for fn_name in ( @@ -150,6 +147,7 @@ def _default_factory_raises_on_unknown() -> bool: # Group 1 -- Return type contracts (4 tests) # ============================================================================ + def test_handle_command_returns_bool() -> None: # CT-001 """handle_command must return a bool (not int, not None, not truthy).""" try: @@ -172,32 +170,27 @@ def test_handle_command_returns_bool() -> None: # CT-001 def test_get_json_path_returns_path() -> None: # CT-002 """get_json_path must return a Path or str (filesystem path type).""" result = json_handler.get_json_path("contract_mod", "config") - assert isinstance(result, (Path, str)), ( - f"get_json_path must return Path or str, got {type(result)}" - ) + assert isinstance(result, (Path, str)), f"get_json_path must return Path or str, got {type(result)}" def test_ensure_json_exists_returns_bool(tmp_path: Path) -> None: # CT-003 """ensure_json_exists must return a bool.""" result = json_handler.ensure_json_exists("contract_mod", "data") - assert isinstance(result, bool), ( - f"ensure_json_exists must return bool, got {type(result)}" - ) + assert isinstance(result, bool), f"ensure_json_exists must return bool, got {type(result)}" assert result is True def test_load_json_returns_dict_for_config(tmp_path: Path) -> None: # CT-004 """load_json for config type must return a dict.""" result = json_handler.load_json("contract_mod", "config") - assert isinstance(result, dict), ( - f"load_json('...', 'config') must return dict, got {type(result)}" - ) + assert isinstance(result, dict), f"load_json('...', 'config') must return dict, got {type(result)}" # ============================================================================ # Group 2 -- Exception contracts (3 tests) # ============================================================================ + def test_create_default_unknown_raises_value_error() -> None: # CT-005 """_create_default (or equivalent) must raise ValueError for unknown type.""" if not _default_factory_raises_on_unknown(): @@ -228,6 +221,7 @@ def test_validate_rejects_invalid_mode() -> None: # CT-007 # Group 3 -- Data structure contracts (3 tests) # ============================================================================ + def test_config_has_required_keys(tmp_path: Path) -> None: # CT-008 """Config data structure must contain module_name and version.""" json_handler.ensure_json_exists("struct_mod", "config") @@ -254,9 +248,7 @@ def test_log_entry_has_operation(tmp_path: Path) -> None: # CT-010 val = getattr(_mod, _JSON_DIR_ATTR) json_dir = Path(val) if isinstance(val, str) else val - log = json.loads( - (json_dir / "struct_mod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "struct_mod_log.json").read_text(encoding="utf-8")) assert len(log) >= 1, "log_operation must append at least one entry" assert "operation" in log[-1], "Log entry must have 'operation' key" assert log[-1]["operation"] == "contract_test" diff --git a/src/aipass/drone/tests/test_devpulse_plugins.py b/src/aipass/drone/tests/test_devpulse_plugins.py index 8ba7194b..7ae2d6c2 100644 --- a/src/aipass/drone/tests/test_devpulse_plugins.py +++ b/src/aipass/drone/tests/test_devpulse_plugins.py @@ -33,10 +33,12 @@ def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: trinity.mkdir() passport = trinity / "passport.json" passport.write_text( - json.dumps({ - "branch_info": {"branch_name": "devpulse"}, - "identity": {"name": "devpulse"}, - }), + json.dumps( + { + "branch_info": {"branch_name": "devpulse"}, + "identity": {"name": "devpulse"}, + } + ), encoding="utf-8", ) monkeypatch.chdir(tmp_path) @@ -50,10 +52,12 @@ def seedgo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: trinity.mkdir() passport = trinity / "passport.json" passport.write_text( - json.dumps({ - "branch_info": {"branch_name": "seedgo"}, - "identity": {"name": "seedgo"}, - }), + json.dumps( + { + "branch_info": {"branch_name": "seedgo"}, + "identity": {"name": "seedgo"}, + } + ), encoding="utf-8", ) monkeypatch.chdir(tmp_path) @@ -119,9 +123,7 @@ class TestMergePrHappyPath: @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run") - def test_merge_pr_success( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_merge_pr_success(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: @@ -156,9 +158,7 @@ class TestMergePrFailure: @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run") - def test_merge_pr_merge_fails( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_merge_pr_merge_fails(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path proc = MagicMock() @@ -184,9 +184,7 @@ class TestSmartSyncUpToDate: @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") - def test_smart_sync_up_to_date( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_smart_sync_up_to_date(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: @@ -215,9 +213,7 @@ class TestSmartSyncBehind: @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") - def test_smart_sync_behind_rebase_success( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_smart_sync_behind_rebase_success(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: @@ -248,9 +244,7 @@ class TestSmartSyncDivergedMergeSuccess: @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") - def test_smart_sync_diverged_merge_ok( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_smart_sync_diverged_merge_ok(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: @@ -283,9 +277,7 @@ class TestSmartSyncMergeConflict: @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") - def test_smart_sync_merge_conflict( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_smart_sync_merge_conflict(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: @@ -331,9 +323,7 @@ class TestFixStuckRebase: @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") - def test_fix_stuck_rebase( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_fix_stuck_rebase(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path # Create .git/rebase-merge to simulate stuck rebase git_dir = tmp_path / ".git" @@ -366,9 +356,7 @@ class TestFixDetachedHead: @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") - def test_fix_detached_head( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_fix_detached_head(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path git_dir = tmp_path / ".git" git_dir.mkdir() @@ -404,9 +392,7 @@ class TestFixDiverged: @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") - def test_fix_diverged_merges( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_fix_diverged_merges(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path git_dir = tmp_path / ".git" git_dir.mkdir() @@ -441,9 +427,7 @@ class TestFixCleanState: @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") - def test_fix_clean_state( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_fix_clean_state(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path git_dir = tmp_path / ".git" git_dir.mkdir() diff --git a/src/aipass/drone/tests/test_discovery.py b/src/aipass/drone/tests/test_discovery.py index 6ce440c2..af71ce70 100644 --- a/src/aipass/drone/tests/test_discovery.py +++ b/src/aipass/drone/tests/test_discovery.py @@ -41,6 +41,7 @@ from aipass.drone.apps.handlers.module_registry_handler import ( # scan_modules_directory tests # ============================================================================= + class TestScanModulesDirectory: """Tests for scan_modules_directory().""" @@ -116,6 +117,7 @@ class TestScanModulesDirectory: # parse_help_for_commands tests # ============================================================================= + class TestParseHelpForCommands: """Tests for parse_help_for_commands().""" @@ -141,14 +143,7 @@ class TestParseHelpForCommands: def test_extracts_from_subcommands_section(self): """Should recognize 'Subcommands:' as a section marker.""" - help_text = ( - "MyTool v1.0\n" - "\n" - "Subcommands:\n" - " status Show status\n" - " config Configure settings\n" - "\n" - ) + help_text = "MyTool v1.0\n\nSubcommands:\n status Show status\n config Configure settings\n\n" result = parse_help_for_commands(help_text) @@ -157,14 +152,7 @@ class TestParseHelpForCommands: def test_extracts_from_available_commands_section(self): """Should recognize 'Available Commands:' as a section marker.""" - help_text = ( - "Usage: app\n" - "\n" - "Available Commands:\n" - " run Run the app\n" - " test Run tests\n" - "\n" - ) + help_text = "Usage: app\n\nAvailable Commands:\n run Run the app\n test Run tests\n\n" result = parse_help_for_commands(help_text) @@ -173,12 +161,7 @@ class TestParseHelpForCommands: def test_skips_options_flags(self): """Should not include lines starting with - as commands.""" - help_text = ( - "Commands:\n" - " start Start the server\n" - " -v Verbose mode\n" - " --debug Debug mode\n" - ) + help_text = "Commands:\n start Start the server\n -v Verbose mode\n --debug Debug mode\n" result = parse_help_for_commands(help_text) @@ -194,13 +177,7 @@ class TestParseHelpForCommands: def test_no_commands_section_returns_empty(self): """Help text without a commands section should return empty list.""" - help_text = ( - "Usage: tool [OPTIONS]\n" - "\n" - "Options:\n" - " --help Show help\n" - " --version Show version\n" - ) + help_text = "Usage: tool [OPTIONS]\n\nOptions:\n --help Show help\n --version Show version\n" result = parse_help_for_commands(help_text) @@ -230,6 +207,7 @@ class TestParseHelpForCommands: # get_entry_point tests # ============================================================================= + class TestGetEntryPoint: """Tests for get_entry_point().""" @@ -256,6 +234,7 @@ class TestGetEntryPoint: # handler get_help tests # ============================================================================= + class TestHandlerGetHelp: """Tests for discovery_handler.get_help().""" @@ -346,6 +325,7 @@ class TestHandlerGetHelp: # handler discover_modules tests # ============================================================================= + class TestHandlerDiscoverModules: """Tests for discovery_handler.discover_modules().""" @@ -424,6 +404,7 @@ class TestHandlerDiscoverModules: # orchestration layer: discovery.discover_modules tests # ============================================================================= + class TestOrchestrationDiscoverModules: """Tests for discovery.discover_modules() orchestration.""" @@ -457,6 +438,7 @@ class TestOrchestrationDiscoverModules: # orchestration layer: discovery.get_help tests # ============================================================================= + class TestOrchestrationGetHelp: """Tests for discovery.get_help() orchestration.""" @@ -495,6 +477,7 @@ class TestOrchestrationGetHelp: # orchestration layer: discovery.get_system_help tests # ============================================================================= + class TestOrchestrationGetSystemHelp: """Tests for discovery.get_system_help() orchestration.""" @@ -521,6 +504,7 @@ class TestOrchestrationGetSystemHelp: # get_module_introspective tests # ============================================================================= + class TestGetModuleIntrospective: """Tests for module_registry_handler.get_module_introspective().""" @@ -560,6 +544,7 @@ class TestGetModuleIntrospective: # handle_command routing tests # ============================================================================= + class TestHandleCommand: """Tests for discovery.handle_command() routing.""" @@ -597,7 +582,10 @@ class TestHandleCommand: from aipass.drone.apps.modules.discovery import handle_command mock_get_help.return_value = HelpResult( - branch="branch", command=None, text="help text", commands_found=[], + branch="branch", + command=None, + text="help text", + commands_found=[], ) result = handle_command("help", ["@branch"]) @@ -611,7 +599,10 @@ class TestHandleCommand: from aipass.drone.apps.modules.discovery import handle_command mock_get_help.return_value = HelpResult( - branch="branch", command="subcmd", text="subcmd help", commands_found=[], + branch="branch", + command="subcmd", + text="subcmd help", + commands_found=[], ) result = handle_command("help", ["@branch", "subcmd"]) diff --git a/src/aipass/drone/tests/test_error_resilience.py b/src/aipass/drone/tests/test_error_resilience.py index 1377031e..8a25b4e5 100644 --- a/src/aipass/drone/tests/test_error_resilience.py +++ b/src/aipass/drone/tests/test_error_resilience.py @@ -43,13 +43,9 @@ else: if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) if BRANCH_MODULE in ("commons", "skills"): - _handlers_dir = ( - Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" else: - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -77,8 +73,7 @@ for _candidate in _JSON_DIR_CANDIDATES: if _JSON_DIR_ATTR is None: pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- " - f"tried: {_JSON_DIR_CANDIDATES}", + f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- tried: {_JSON_DIR_CANDIDATES}", allow_module_level=True, ) @@ -87,6 +82,7 @@ if _JSON_DIR_ATTR is None: # Isolation fixture # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect JSON operations to tmp_path for test isolation.""" @@ -112,6 +108,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Error Resilience Tests (4 tests) # ============================================================================ + def test_missing_file(tmp_path: Path) -> None: # ER-001 """Loading a non-existent file returns a graceful default, not a crash.""" json_dir = _json_dir_as_path(tmp_path) diff --git a/src/aipass/drone/tests/test_executor.py b/src/aipass/drone/tests/test_executor.py index 2fc889b8..1fe3b824 100644 --- a/src/aipass/drone/tests/test_executor.py +++ b/src/aipass/drone/tests/test_executor.py @@ -41,14 +41,8 @@ class TestCapturedMode: def test_captured_both_streams(self, temp_test_dir: Path): """Both stdout and stderr are captured simultaneously.""" - code = ( - "import sys; " - "print('out'); " - "sys.stderr.write('err\\n')" - ) - result = execute_command( - sys.executable, ["-c", code], cwd=str(temp_test_dir) - ) + code = "import sys; print('out'); sys.stderr.write('err\\n')" + result = execute_command(sys.executable, ["-c", code], cwd=str(temp_test_dir)) assert "out" in result.stdout assert "err" in result.stderr @@ -133,9 +127,7 @@ class TestInteractiveNoTimeout: def test_no_timeout_kwarg(self, temp_test_dir: Path): """subprocess.run is called without a timeout arg in interactive mode.""" with patch("aipass.drone.apps.handlers.executor.subprocess.run") as mock_run: - mock_run.return_value = subprocess.CompletedProcess( - args=[], returncode=0 - ) + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0) execute_command( sys.executable, ["-c", "pass"], @@ -362,9 +354,7 @@ class TestShellSecurity: def test_captured_mode_shell_false(self, temp_test_dir: Path): """Captured mode calls subprocess.run with shell=False.""" with patch("aipass.drone.apps.handlers.executor.subprocess.run") as mock_run: - mock_run.return_value = subprocess.CompletedProcess( - args=[], returncode=0, stdout=b"", stderr=b"" - ) + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") execute_command( sys.executable, ["-c", "pass"], @@ -376,9 +366,7 @@ class TestShellSecurity: def test_interactive_mode_shell_false(self, temp_test_dir: Path): """Interactive mode calls subprocess.run with shell=False.""" with patch("aipass.drone.apps.handlers.executor.subprocess.run") as mock_run: - mock_run.return_value = subprocess.CompletedProcess( - args=[], returncode=0 - ) + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0) execute_command( sys.executable, ["-c", "pass"], diff --git a/src/aipass/drone/tests/test_generic_adapter.py b/src/aipass/drone/tests/test_generic_adapter.py index 361a22ab..aed0a0c3 100644 --- a/src/aipass/drone/tests/test_generic_adapter.py +++ b/src/aipass/drone/tests/test_generic_adapter.py @@ -38,9 +38,7 @@ from aipass.drone.apps.handlers.generic_adapter import capture_main @pytest.fixture(autouse=True) def _mock_log_operation(): """Prevent json_handler.log_operation from touching disk.""" - with patch( - "aipass.drone.apps.handlers.generic_adapter.json_handler" - ) as mock_jh: + with patch("aipass.drone.apps.handlers.generic_adapter.json_handler") as mock_jh: mock_jh.log_operation = MagicMock() yield @@ -301,9 +299,7 @@ class TestLogOperation: def test_log_operation_called(self, _fake_module_factory): _fake_module_factory("log_mod", lambda: 0) - with patch( - "aipass.drone.apps.handlers.generic_adapter.json_handler" - ) as mock_jh: + with patch("aipass.drone.apps.handlers.generic_adapter.json_handler") as mock_jh: capture_main("log_mod", "myprog", command="status") mock_jh.log_operation.assert_called_once() call_args = mock_jh.log_operation.call_args diff --git a/src/aipass/drone/tests/test_git_module.py b/src/aipass/drone/tests/test_git_module.py index f65a8364..17151baf 100644 --- a/src/aipass/drone/tests/test_git_module.py +++ b/src/aipass/drone/tests/test_git_module.py @@ -431,7 +431,10 @@ class TestPRHandler: return result with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_subprocess_run): - with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}): + with patch( + "aipass.drone.apps.handlers.git.pr_handler.acquire_lock", + return_value={"success": True, "message": "ok"}, + ): with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"): result = create_pr("api", "test desc", tmp_path / "src" / "aipass" / "api") @@ -577,9 +580,13 @@ class TestDetectBranchDir: trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "branch_info": {"branch_name": "mybranch"}, - })) + passport.write_text( + json.dumps( + { + "branch_info": {"branch_name": "mybranch"}, + } + ) + ) # CWD is inside a subdirectory of the branch sub_dir = branch_dir / "apps" / "modules" @@ -604,9 +611,13 @@ class TestDetectBranchDir: trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "branch_info": {"branch_name": "commons"}, - })) + passport.write_text( + json.dumps( + { + "branch_info": {"branch_name": "commons"}, + } + ) + ) monkeypatch.chdir(branch_dir) @@ -655,12 +666,14 @@ class TestModuleRegistration: def test_git_in_registry(self) -> None: """git module is registered in _INTERNAL_MODULES.""" from aipass.drone.apps.handlers.module_registry_handler import _INTERNAL_MODULES + assert "git" in _INTERNAL_MODULES assert _INTERNAL_MODULES["git"] == "aipass.drone.apps.modules.git_module" def test_module_importable(self) -> None: """The registered module path is importable.""" import importlib + mod = importlib.import_module("aipass.drone.apps.modules.git_module") assert hasattr(mod, "DRONE_MODULE") assert hasattr(mod, "handle_command") @@ -718,7 +731,10 @@ class TestTriggerFireIntegration: mock_trigger = MagicMock() with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run): - with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}): + with patch( + "aipass.drone.apps.handlers.git.pr_handler.acquire_lock", + return_value={"success": True, "message": "ok"}, + ): with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"): with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger): result = create_pr("api", "test trigger", tmp_path / "src" / "aipass" / "api") @@ -753,7 +769,10 @@ class TestTriggerFireIntegration: mock_trigger.fire.side_effect = RuntimeError("trigger broken") with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run): - with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}): + with patch( + "aipass.drone.apps.handlers.git.pr_handler.acquire_lock", + return_value={"success": True, "message": "ok"}, + ): with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"): with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger): result = create_pr("api", "test resilience", tmp_path / "src" / "aipass" / "api") diff --git a/src/aipass/drone/tests/test_hook_sounds.py b/src/aipass/drone/tests/test_hook_sounds.py index 2cc961bb..25677556 100644 --- a/src/aipass/drone/tests/test_hook_sounds.py +++ b/src/aipass/drone/tests/test_hook_sounds.py @@ -27,9 +27,7 @@ def _isolate(tmp_path, monkeypatch): "aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.MUTE_FLAG", flag, ) - with patch( - "aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.json_handler" - ): + with patch("aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.json_handler"): yield flag diff --git a/src/aipass/drone/tests/test_init_provisioning.py b/src/aipass/drone/tests/test_init_provisioning.py index f7fd0d1f..5e3cb6ec 100644 --- a/src/aipass/drone/tests/test_init_provisioning.py +++ b/src/aipass/drone/tests/test_init_provisioning.py @@ -43,13 +43,9 @@ else: if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) if BRANCH_MODULE in ("commons", "skills"): - _handlers_dir = ( - Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" else: - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -77,8 +73,7 @@ for _candidate in _JSON_DIR_CANDIDATES: if _JSON_DIR_ATTR is None: pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- " - f"tried: {_JSON_DIR_CANDIDATES}", + f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- tried: {_JSON_DIR_CANDIDATES}", allow_module_level=True, ) @@ -87,6 +82,7 @@ if _JSON_DIR_ATTR is None: # Isolation fixture # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect JSON operations to tmp_path for test isolation.""" @@ -112,6 +108,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Init/Provisioning Tests (4 tests) # ============================================================================ + def test_creates_expected_files(tmp_path: Path) -> None: # IP-001 """ensure_json_exists creates the expected file on disk.""" json_dir = _json_dir_as_path(tmp_path) @@ -121,9 +118,7 @@ def test_creates_expected_files(tmp_path: Path) -> None: # IP-001 assert result is True, f"ensure_json_exists must return True for {json_type}" expected = json_dir / f"prov_mod_{json_type}.json" - assert expected.exists(), ( - f"ensure_json_exists must create {expected.name} on disk" - ) + assert expected.exists(), f"ensure_json_exists must create {expected.name} on disk" raw = expected.read_text(encoding="utf-8") parsed = json.loads(raw) @@ -144,9 +139,7 @@ def test_auto_creates_directory(tmp_path: Path) -> None: # IP-002 try: result = json_handler.ensure_json_exists("autodir", "config") - assert nested_dir.exists(), ( - "ensure_json_exists must auto-create missing directories" - ) + assert nested_dir.exists(), "ensure_json_exists must auto-create missing directories" assert result is True assert (nested_dir / "autodir_config.json").exists() except (FileNotFoundError, OSError): diff --git a/src/aipass/drone/tests/test_json_handler.py b/src/aipass/drone/tests/test_json_handler.py index c76bb444..2f135664 100644 --- a/src/aipass/drone/tests/test_json_handler.py +++ b/src/aipass/drone/tests/test_json_handler.py @@ -64,13 +64,9 @@ if _handler_pkg not in sys.modules: _stub = types.ModuleType(_handler_pkg) # Resolve the real filesystem path for the handlers package if BRANCH_MODULE in ("commons", "skills"): - _handlers_dir = ( - Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / BRANCH_MODULE / "apps" / "handlers" else: - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) + _handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" _stub.__path__ = [str(_handlers_dir)] sys.modules[_handler_pkg] = _stub @@ -87,11 +83,11 @@ json_handler = _mod _JSON_DIR_ATTR: str | None = None _JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", # SEEDGO_JSON_DIR, BACKUP_JSON_DIR, etc. - "JSON_DIR", # seedgo, daemon, memory, cli, drone - "BRANCH_JSON_DIR", # commons - f"{BRANCH_MODULE}_json", # unlikely but covered - "_JSON_DIR", # spawn + f"{BRANCH_MODULE.upper()}_JSON_DIR", # SEEDGO_JSON_DIR, BACKUP_JSON_DIR, etc. + "JSON_DIR", # seedgo, daemon, memory, cli, drone + "BRANCH_JSON_DIR", # commons + f"{BRANCH_MODULE}_json", # unlikely but covered + "_JSON_DIR", # spawn ] for _candidate in _JSON_DIR_CANDIDATES: @@ -101,8 +97,7 @@ for _candidate in _JSON_DIR_CANDIDATES: if _JSON_DIR_ATTR is None: pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — " - f"tried: {_JSON_DIR_CANDIDATES}", + f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — tried: {_JSON_DIR_CANDIDATES}", allow_module_level=True, ) @@ -113,6 +108,7 @@ if _JSON_DIR_ATTR is None: # Branches use: _create_default, _get_default_template, _get_default, # _default_template, load_template, or per-type _default_config/_default_data/_default_log. + def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: """Call whichever default factory the branch exposes.""" # Single-function factories (most branches) @@ -179,6 +175,7 @@ def _default_factory_raises_on_unknown() -> bool: # Isolation fixture # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect JSON operations to tmp_path for test isolation.""" @@ -196,6 +193,7 @@ def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: # Helper: resolve JSON dir as Path regardless of branch type # --------------------------------------------------------------------------- + def _json_dir_as_path(tmp_path: Path) -> Path: """Return the patched JSON dir as a Path (handles str-typed branches).""" assert _JSON_DIR_ATTR is not None @@ -209,6 +207,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path: # Group 1 — _create_default / default templates (4 tests) # ============================================================================ + def test_default_config_returns_dict_with_required_keys() -> None: # JH-001 if not _has_default_factory(): pytest.skip("Branch has no default factory function") @@ -247,6 +246,7 @@ def test_default_unknown_type_raises_value_error() -> None: # JH-004 # Group 2 — validate_json_structure (10 tests) # ============================================================================ + def test_validate_valid_config() -> None: # JH-005 data = {"module_name": "x", "version": "1.0.0", "config": {}} assert json_handler.validate_json_structure(data, "config") is True @@ -298,6 +298,7 @@ def test_validate_none_input_returns_false() -> None: # JH-014 # Group 3 — get_json_path (3 tests) # ============================================================================ + def test_get_json_path_returns_path_type(tmp_path: Path) -> None: # JH-015 result = json_handler.get_json_path("mymod", "config") # Some branches return str (commons), most return Path @@ -320,6 +321,7 @@ def test_get_json_path_different_combos_differ(tmp_path: Path) -> None: # JH-01 # Group 4 — ensure_json_exists (5 tests) # ============================================================================ + def test_ensure_creates_file_when_missing(tmp_path: Path) -> None: # JH-018 result = json_handler.ensure_json_exists("ens_mod", "config") assert result is True @@ -381,6 +383,7 @@ def test_ensure_returns_bool(tmp_path: Path) -> None: # JH-022 # Group 5 — load_json (4 tests) # ============================================================================ + def test_load_creates_default_when_missing(tmp_path: Path) -> None: # JH-023 result = json_handler.load_json("fresh_mod", "log") assert result is not None, "load_json must auto-create and return content" @@ -413,6 +416,7 @@ def test_load_returns_list_for_log(tmp_path: Path) -> None: # JH-026 # Group 6 — save_json (5 tests) # ============================================================================ + def test_save_roundtrip(tmp_path: Path) -> None: # JH-027 json_dir = _json_dir_as_path(tmp_path) json_dir.mkdir(parents=True, exist_ok=True) @@ -446,9 +450,7 @@ def test_save_data_updates_last_updated(tmp_path: Path) -> None: # JH-030 data = {"created": "2025-01-01", "last_updated": "2025-01-01"} json_handler.save_json("ts", "data", data) - on_disk = json.loads( - (json_dir / "ts_data.json").read_text(encoding="utf-8") - ) + on_disk = json.loads((json_dir / "ts_data.json").read_text(encoding="utf-8")) assert on_disk["last_updated"] == today, "Saving data type must auto-stamp last_updated" @@ -468,12 +470,11 @@ def test_save_writes_valid_json_to_disk(tmp_path: Path) -> None: # JH-031 # Group 7 — log_operation (7 tests) # ============================================================================ + def test_log_operation_appends_entry(tmp_path: Path) -> None: # JH-032 json_handler.log_operation("deploy", module_name="logmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "logmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "logmod_log.json").read_text(encoding="utf-8")) assert len(log) >= 1, "log_operation must append at least one entry" assert log[-1]["operation"] == "deploy" @@ -487,20 +488,14 @@ def test_log_operation_returns_bool(tmp_path: Path) -> None: # JH-033 def test_log_operation_entry_has_timestamp(tmp_path: Path) -> None: # JH-034 json_handler.log_operation("check_ts", module_name="tsmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "tsmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "tsmod_log.json").read_text(encoding="utf-8")) assert "timestamp" in log[-1], "Log entry must have a timestamp field" def test_log_operation_includes_data_when_provided(tmp_path: Path) -> None: # JH-035 - json_handler.log_operation( - "with_data", data={"count": 5}, module_name="datamod" - ) + json_handler.log_operation("with_data", data={"count": 5}, module_name="datamod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "datamod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "datamod_log.json").read_text(encoding="utf-8")) assert "data" in log[-1], "Log entry must include data dict when provided" assert log[-1]["data"]["count"] == 5 @@ -510,9 +505,7 @@ def test_log_operation_multiple_calls_accumulate(tmp_path: Path) -> None: # JH- json_handler.log_operation("second", module_name="accmod") json_handler.log_operation("third", module_name="accmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "accmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "accmod_log.json").read_text(encoding="utf-8")) assert len(log) >= 3, "Multiple log_operation calls must accumulate entries" ops = [e["operation"] for e in log[-3:]] assert ops == ["first", "second", "third"] @@ -535,9 +528,7 @@ def test_log_operation_fifo_rotation(tmp_path: Path) -> None: # JH-040 json_handler.log_operation(f"op_{i}", module_name="fifomod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "fifomod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "fifomod_log.json").read_text(encoding="utf-8")) assert len(log) <= max_entries, f"Log must not exceed {max_entries} entries after rotation" # First entries should have been rotated out assert log[-1]["operation"] == f"op_{max_entries + 4}", "Most recent entry must be last" @@ -546,9 +537,7 @@ def test_log_operation_fifo_rotation(tmp_path: Path) -> None: # JH-040 def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None: # JH-041 json_handler.log_operation("no_data", data={}, module_name="emptymod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "emptymod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "emptymod_log.json").read_text(encoding="utf-8")) entry = log[-1] # Empty dict should either not be attached or be an empty dict # The key test: the entry should not have a non-empty "data" field from an empty input @@ -560,6 +549,7 @@ def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None: # JH-04 # Group 8 — ensure_module_jsons (5 tests) # ============================================================================ + def test_ensure_module_jsons_creates_all_three(tmp_path: Path) -> None: # JH-036 if not hasattr(json_handler, "ensure_module_jsons"): pytest.skip("Branch does not have ensure_module_jsons") @@ -583,19 +573,13 @@ def test_ensure_module_jsons_files_pass_validation(tmp_path: Path) -> None: # J json_handler.ensure_module_jsons("valid_mod") json_dir = _json_dir_as_path(tmp_path) - config = json.loads( - (json_dir / "valid_mod_config.json").read_text(encoding="utf-8") - ) + config = json.loads((json_dir / "valid_mod_config.json").read_text(encoding="utf-8")) assert json_handler.validate_json_structure(config, "config") is True - data = json.loads( - (json_dir / "valid_mod_data.json").read_text(encoding="utf-8") - ) + data = json.loads((json_dir / "valid_mod_data.json").read_text(encoding="utf-8")) assert json_handler.validate_json_structure(data, "data") is True - log = json.loads( - (json_dir / "valid_mod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "valid_mod_log.json").read_text(encoding="utf-8")) assert json_handler.validate_json_structure(log, "log") is True @@ -604,9 +588,7 @@ def test_ensure_module_jsons_data_has_correct_keys(tmp_path: Path) -> None: # J pytest.skip("Branch does not have ensure_module_jsons") json_handler.ensure_module_jsons("keymod") json_dir = _json_dir_as_path(tmp_path) - data = json.loads( - (json_dir / "keymod_data.json").read_text(encoding="utf-8") - ) + data = json.loads((json_dir / "keymod_data.json").read_text(encoding="utf-8")) assert "created" in data, "Data file must have 'created' key" assert "last_updated" in data, "Data file must have 'last_updated' key" @@ -616,9 +598,7 @@ def test_ensure_module_jsons_log_is_empty_list(tmp_path: Path) -> None: # JH-04 pytest.skip("Branch does not have ensure_module_jsons") json_handler.ensure_module_jsons("listmod") json_dir = _json_dir_as_path(tmp_path) - log = json.loads( - (json_dir / "listmod_log.json").read_text(encoding="utf-8") - ) + log = json.loads((json_dir / "listmod_log.json").read_text(encoding="utf-8")) assert isinstance(log, list), "Log file must be a list" assert len(log) == 0, "Initial log file must be an empty list" @@ -627,11 +607,10 @@ def test_ensure_module_jsons_log_is_empty_list(tmp_path: Path) -> None: # JH-04 # Infrastructure mocking — reimport_after_mock # ============================================================================ + def test_reimport_after_mock(tmp_path: Path) -> None: """reimport_after_mock: module can be reloaded cleanly.""" - handler_module = sys.modules.get( - f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" - ) + handler_module = sys.modules.get(f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler") if handler_module: importlib.reload(handler_module) @@ -640,6 +619,7 @@ def test_reimport_after_mock(tmp_path: Path) -> None: # Group 9 — Empty file resilience (4 tests) # ============================================================================ + def test_ensure_regenerates_empty_log_file(tmp_path: Path) -> None: # JH-044 """Empty log.json should be regenerated, not crash with JSONDecodeError.""" json_dir = _json_dir_as_path(tmp_path) @@ -683,11 +663,18 @@ def test_log_operation_survives_empty_log_file(tmp_path: Path) -> None: # JH-04 json_dir.mkdir(parents=True, exist_ok=True) # Create valid config but empty log config_path = json_dir / "recover_config.json" - config_path.write_text(json.dumps({ - "module_name": "recover", "version": "1.0.0", - "config": {"max_log_entries": 100}, - "created": "2026-01-01", "last_updated": "2026-01-01", - }), encoding="utf-8") + config_path.write_text( + json.dumps( + { + "module_name": "recover", + "version": "1.0.0", + "config": {"max_log_entries": 100}, + "created": "2026-01-01", + "last_updated": "2026-01-01", + } + ), + encoding="utf-8", + ) log_path = json_dir / "recover_log.json" log_path.write_text("", encoding="utf-8") diff --git a/src/aipass/drone/tests/test_registry_handler.py b/src/aipass/drone/tests/test_registry_handler.py index 6bc058ee..572dd28b 100644 --- a/src/aipass/drone/tests/test_registry_handler.py +++ b/src/aipass/drone/tests/test_registry_handler.py @@ -39,6 +39,7 @@ from aipass.drone.apps.handlers.exceptions import ( # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def registry_dir() -> Generator[Path, None, None]: """Isolated temp directory for registry tests; cleaned up after.""" @@ -93,8 +94,8 @@ def _minimal_registry(*, metadata_id: str | None = None, branches: list | None = # 1. load_registry() — valid registry # =================================================================== -class TestLoadRegistry: +class TestLoadRegistry: def test_branches_normalised_to_dict(self, registry_dir: Path): """List-format branches are converted to a dict keyed by lowercased name.""" reg = _minimal_registry() @@ -263,8 +264,8 @@ class TestLoadRegistry: # 4 & 5. get_all_branches() # =================================================================== -class TestGetAllBranches: +class TestGetAllBranches: @pytest.fixture(autouse=True) def _isolate_home(self, monkeypatch): """Prevent real AIPASS_HOME from leaking into test results.""" @@ -340,8 +341,8 @@ class TestGetAllBranches: # 6 & 7. find_registry() / _first_registry_in() # =================================================================== -class TestFindRegistry: +class TestFindRegistry: def test_first_registry_in_finds_file(self, registry_dir: Path): """_first_registry_in returns path when *_REGISTRY.json exists.""" _write_registry(registry_dir, _minimal_registry()) @@ -391,16 +392,14 @@ class TestFindRegistry: # 8, 9, 10. _verify_registry_credential() # =================================================================== -class TestVerifyRegistryCredential: +class TestVerifyRegistryCredential: def test_passes_when_ids_match(self, registry_dir: Path, monkeypatch): """No error when passport.citizenship.registry_id == registry.metadata.id.""" shared_id = "reg-abc-123" registry_data = _minimal_registry(metadata_id=shared_id) registry_path = _write_registry(registry_dir, registry_data) - _write_passport(registry_dir, { - "citizenship": {"registry_id": shared_id} - }) + _write_passport(registry_dir, {"citizenship": {"registry_id": shared_id}}) monkeypatch.chdir(registry_dir) # Should not raise @@ -410,9 +409,7 @@ class TestVerifyRegistryCredential: """No error when registry has no metadata.id (migration period).""" registry_data = _minimal_registry() # no metadata_id registry_path = _write_registry(registry_dir, registry_data) - _write_passport(registry_dir, { - "citizenship": {"registry_id": "some-id"} - }) + _write_passport(registry_dir, {"citizenship": {"registry_id": "some-id"}}) monkeypatch.chdir(registry_dir) _verify_registry_credential(registry_path, registry_data) @@ -422,9 +419,12 @@ class TestVerifyRegistryCredential: shared_id = "reg-xyz-789" registry_data = _minimal_registry(metadata_id=shared_id) registry_path = _write_registry(registry_dir, registry_data) - _write_passport(registry_dir, { - "citizenship": {} # no registry_id - }) + _write_passport( + registry_dir, + { + "citizenship": {} # no registry_id + }, + ) monkeypatch.chdir(registry_dir) _verify_registry_credential(registry_path, registry_data) @@ -443,9 +443,7 @@ class TestVerifyRegistryCredential: """RegistryMismatchError raised when IDs differ -- security-critical.""" registry_data = _minimal_registry(metadata_id="registry-AAA") registry_path = _write_registry(registry_dir, registry_data) - _write_passport(registry_dir, { - "citizenship": {"registry_id": "registry-BBB"} - }) + _write_passport(registry_dir, {"citizenship": {"registry_id": "registry-BBB"}}) monkeypatch.chdir(registry_dir) with pytest.raises(RegistryMismatchError, match="mismatch"): @@ -457,9 +455,7 @@ class TestVerifyRegistryCredential: passport_id = "registry-DEV" registry_data = _minimal_registry(metadata_id=reg_id) registry_path = _write_registry(registry_dir, registry_data) - _write_passport(registry_dir, { - "citizenship": {"registry_id": passport_id} - }) + _write_passport(registry_dir, {"citizenship": {"registry_id": passport_id}}) monkeypatch.chdir(registry_dir) with pytest.raises(RegistryMismatchError) as exc_info: @@ -482,8 +478,8 @@ class TestVerifyRegistryCredential: # 11. Metadata parsing # =================================================================== -class TestMetadataParsing: +class TestMetadataParsing: def test_metadata_id_preserved(self, registry_dir: Path, monkeypatch): """Registry metadata.id field is available after loading.""" reg = _minimal_registry(metadata_id="my-unique-id") @@ -510,8 +506,8 @@ class TestMetadataParsing: # Registry path management # =================================================================== -class TestRegistryPathManagement: +class TestRegistryPathManagement: def test_set_and_get_registry_path(self, registry_dir: Path): """set_registry_path() overrides get_registry_path().""" custom = registry_dir / "CUSTOM_REGISTRY.json" diff --git a/src/aipass/drone/tests/test_resolver.py b/src/aipass/drone/tests/test_resolver.py index 07e54007..64ab5490 100644 --- a/src/aipass/drone/tests/test_resolver.py +++ b/src/aipass/drone/tests/test_resolver.py @@ -30,6 +30,7 @@ from aipass.drone.apps.modules.resolver import ( # Helpers # --------------------------------------------------------------------------- + def _write_registry( registry_path: Path, branches: List[Dict[str, Any]], @@ -69,6 +70,7 @@ def _make_branch( # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def registry_dir(temp_test_dir: Path): """Set registry path for the duration of a test, then reset.""" @@ -123,6 +125,7 @@ def empty_registry(registry_dir: Path) -> Path: # normalize helpers # --------------------------------------------------------------------------- + class TestNormalizeBranchName: def test_strips_at_prefix(self): assert normalize_branch_name("@FOO") == "FOO" @@ -153,6 +156,7 @@ class TestNormalizeBranchArg: # resolve_branch # --------------------------------------------------------------------------- + class TestResolveBranch: def test_resolve_with_at_prefix(self, populated_registry): path = resolve_branch("@ALPHA") @@ -205,6 +209,7 @@ class TestResolveBranch: # branch_exists # --------------------------------------------------------------------------- + class TestBranchExists: def test_exists_for_valid_branch(self, populated_registry): assert branch_exists("@ALPHA") is True @@ -231,6 +236,7 @@ class TestBranchExists: # get_branch_info # --------------------------------------------------------------------------- + class TestGetBranchInfo: def test_contains_expected_keys(self, populated_registry): info = get_branch_info("@ALPHA") @@ -271,6 +277,7 @@ class TestGetBranchInfo: # list_branches # --------------------------------------------------------------------------- + class TestListBranches: @pytest.fixture(autouse=True) def _isolate_home(self, monkeypatch): @@ -322,6 +329,7 @@ class TestListBranches: # handle_command routing # --------------------------------------------------------------------------- + class TestHandleCommand: def test_resolve_command_success(self, populated_registry): assert handle_command("resolve", ["@ALPHA"]) is True @@ -358,6 +366,7 @@ class TestHandleCommand: # sample_registry conftest fixture # --------------------------------------------------------------------------- + class TestWithSampleRegistry: """Tests using the sample_registry fixture from conftest.py.""" diff --git a/src/aipass/drone/tests/test_router.py b/src/aipass/drone/tests/test_router.py index caa7afb6..98feefae 100644 --- a/src/aipass/drone/tests/test_router.py +++ b/src/aipass/drone/tests/test_router.py @@ -77,13 +77,9 @@ class TestExecuteBranchCommand: return temp_test_dir @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_valid_command_returns_command_result( - self, mock_exec, branch_dir: Path - ): + def test_valid_command_returns_command_result(self, mock_exec, branch_dir: Path): """A valid command returns a CommandResult with correct fields.""" - mock_exec.return_value = CommandResult( - stdout="ok\n", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="ok\n", stderr="", exit_code=0, branch="", command="") result = execute_branch_command( branch_path=str(branch_dir), @@ -98,9 +94,7 @@ class TestExecuteBranchCommand: assert result.command == "status" @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_introspection_with_command_none( - self, mock_exec, branch_dir: Path - ): + def test_introspection_with_command_none(self, mock_exec, branch_dir: Path): """When command=None the entry point is invoked with no command args.""" mock_exec.return_value = CommandResult( stdout="introspect output", stderr="", exit_code=0, branch="", command="" @@ -118,13 +112,9 @@ class TestExecuteBranchCommand: assert result.command == "" @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_interactive_flag_passed_through( - self, mock_exec, branch_dir: Path - ): + def test_interactive_flag_passed_through(self, mock_exec, branch_dir: Path): """interactive=True is forwarded to execute_command.""" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") execute_branch_command( branch_path=str(branch_dir), @@ -137,13 +127,9 @@ class TestExecuteBranchCommand: assert call_kwargs.get("interactive") is True @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_sets_aipass_caller_cwd_env( - self, mock_exec, branch_dir: Path - ): + def test_sets_aipass_caller_cwd_env(self, mock_exec, branch_dir: Path): """AIPASS_CALLER_CWD is set in the env dict passed to execute_command.""" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") execute_branch_command( branch_path=str(branch_dir), @@ -157,13 +143,9 @@ class TestExecuteBranchCommand: assert env["AIPASS_CALLER_CWD"] == str(Path.cwd()) @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_timeout_propagated_to_executor( - self, mock_exec, branch_dir: Path - ): + def test_timeout_propagated_to_executor(self, mock_exec, branch_dir: Path): """Timeout value is forwarded to execute_command.""" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") execute_branch_command( branch_path=str(branch_dir), @@ -176,13 +158,9 @@ class TestExecuteBranchCommand: assert call_kwargs.get("timeout") == 120 @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_args_appended_to_command( - self, mock_exec, branch_dir: Path - ): + def test_args_appended_to_command(self, mock_exec, branch_dir: Path): """Extra args are appended after the command in the args list.""" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") execute_branch_command( branch_path=str(branch_dir), @@ -196,13 +174,9 @@ class TestExecuteBranchCommand: assert args_list[-3:] == ["deploy", "--force", "--env=prod"] @patch("aipass.drone.apps.handlers.router_handler.execute_command") - def test_uses_sys_executable( - self, mock_exec, branch_dir: Path - ): + def test_uses_sys_executable(self, mock_exec, branch_dir: Path): """execute_command is called with sys.executable as the executable.""" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") execute_branch_command( branch_path=str(branch_dir), @@ -231,9 +205,7 @@ class TestExecuteBranchCommand: class TestRouteCommand: """Tests for route_command() in the router module.""" - @patch( - "aipass.drone.apps.modules.router.execute_branch_command" - ) + @patch("aipass.drone.apps.modules.router.execute_branch_command") @patch("aipass.drone.apps.modules.router.resolve_branch") def test_valid_branch_and_command(self, mock_resolve, mock_exec): """route_command resolves target and delegates to execute_branch_command.""" @@ -257,48 +229,36 @@ class TestRouteCommand: with pytest.raises(BranchNotFoundError, match="not found"): route_command("@ghost", "status") - @patch( - "aipass.drone.apps.modules.router.execute_branch_command" - ) + @patch("aipass.drone.apps.modules.router.execute_branch_command") @patch("aipass.drone.apps.modules.router.resolve_branch") def test_timeout_forwarded(self, mock_resolve, mock_exec): """route_command passes timeout through to execute_branch_command.""" mock_resolve.return_value = "/fake/path" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="b", command="c" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="b", command="c") route_command("@somebranch", "cmd", timeout=90) call_kwargs = mock_exec.call_args.kwargs assert call_kwargs["timeout"] == 90 - @patch( - "aipass.drone.apps.modules.router.execute_branch_command" - ) + @patch("aipass.drone.apps.modules.router.execute_branch_command") @patch("aipass.drone.apps.modules.router.resolve_branch") def test_interactive_forwarded(self, mock_resolve, mock_exec): """route_command passes interactive flag through.""" mock_resolve.return_value = "/fake/path" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="b", command="c" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="b", command="c") route_command("@somebranch", "monitor", interactive=True) call_kwargs = mock_exec.call_args.kwargs assert call_kwargs["interactive"] is True - @patch( - "aipass.drone.apps.modules.router.execute_branch_command" - ) + @patch("aipass.drone.apps.modules.router.execute_branch_command") @patch("aipass.drone.apps.modules.router.resolve_branch") def test_introspection_no_command(self, mock_resolve, mock_exec): """route_command with command=None triggers introspection.""" mock_resolve.return_value = "/fake/path" - mock_exec.return_value = CommandResult( - stdout="info", stderr="", exit_code=0, branch="b", command="" - ) + mock_exec.return_value = CommandResult(stdout="info", stderr="", exit_code=0, branch="b", command="") result = route_command("@somebranch", None) @@ -306,32 +266,24 @@ class TestRouteCommand: assert call_kwargs["command"] is None assert result.stdout == "info" - @patch( - "aipass.drone.apps.modules.router.execute_branch_command" - ) + @patch("aipass.drone.apps.modules.router.execute_branch_command") @patch("aipass.drone.apps.modules.router.resolve_branch") def test_args_forwarded(self, mock_resolve, mock_exec): """route_command forwards args list to execute_branch_command.""" mock_resolve.return_value = "/fake/path" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="b", command="c" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="b", command="c") route_command("@mybranch", "deploy", args=["--env=staging"]) call_kwargs = mock_exec.call_args.kwargs assert call_kwargs["args"] == ["--env=staging"] - @patch( - "aipass.drone.apps.modules.router.execute_branch_command" - ) + @patch("aipass.drone.apps.modules.router.execute_branch_command") @patch("aipass.drone.apps.modules.router.resolve_branch") def test_branch_name_stripped_and_lowered(self, mock_resolve, mock_exec): """route_command strips @ prefix and lowercases for branch_name.""" mock_resolve.return_value = "/fake/path" - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="mybranch", command="test" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="mybranch", command="test") route_command("@MyBranch", "test") @@ -352,9 +304,7 @@ class TestRouteAll: def test_routes_to_all_active_branches(self, mock_list, mock_route): """route_all dispatches the command to every active branch.""" mock_list.return_value = ["@alpha", "@beta"] - mock_route.return_value = CommandResult( - stdout="ok", stderr="", exit_code=0, branch="", command="status" - ) + mock_route.return_value = CommandResult(stdout="ok", stderr="", exit_code=0, branch="", command="status") results = route_all("status") @@ -393,9 +343,7 @@ class TestDetectCallerBranchName: trinity = temp_test_dir / ".trinity" trinity.mkdir() passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "branch_info": {"branch_name": "alpha"} - })) + passport.write_text(json.dumps({"branch_info": {"branch_name": "alpha"}})) result = detect_caller_branch_name(temp_test_dir) assert result == "alpha" @@ -405,9 +353,7 @@ class TestDetectCallerBranchName: trinity = temp_test_dir / ".trinity" trinity.mkdir() passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "identity": {"name": "beta"} - })) + passport.write_text(json.dumps({"identity": {"name": "beta"}})) result = detect_caller_branch_name(temp_test_dir) assert result == "beta" @@ -417,10 +363,14 @@ class TestDetectCallerBranchName: trinity = temp_test_dir / ".trinity" trinity.mkdir() passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "branch_info": {"branch_name": "v1name"}, - "identity": {"name": "v2name"}, - })) + passport.write_text( + json.dumps( + { + "branch_info": {"branch_name": "v1name"}, + "identity": {"name": "v2name"}, + } + ) + ) result = detect_caller_branch_name(temp_test_dir) assert result == "v1name" @@ -445,9 +395,7 @@ class TestDetectCallerBranchName: trinity = temp_test_dir / ".trinity" trinity.mkdir() passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "branch_info": {"branch_name": "found_it"} - })) + passport.write_text(json.dumps({"branch_info": {"branch_name": "found_it"}})) sub = temp_test_dir / "deep" / "nested" / "dir" sub.mkdir(parents=True) @@ -479,13 +427,9 @@ class TestCallerBranchEnvVar: trinity = cwd_dir / ".trinity" trinity.mkdir() passport = trinity / "passport.json" - passport.write_text(json.dumps({ - "branch_info": {"branch_name": "caller_branch"} - })) + passport.write_text(json.dumps({"branch_info": {"branch_name": "caller_branch"}})) - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") with patch("aipass.drone.apps.handlers.router_handler.Path") as mock_path_cls: # Make Path.cwd() return our fake cwd @@ -514,9 +458,7 @@ class TestCallerBranchEnvVar: cwd_dir = temp_test_dir / "empty_cwd" cwd_dir.mkdir() - mock_exec.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="", command="" - ) + mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="", command="") with patch("aipass.drone.apps.handlers.router_handler.Path") as mock_path_cls: mock_path_cls.cwd.return_value = cwd_dir @@ -551,9 +493,7 @@ class TestHandleCommand: @patch("aipass.drone.apps.modules.router.route_command") def test_route_with_target_and_command(self, mock_route): """handle_command('route', ['@branch', 'cmd']) delegates to route_command.""" - mock_route.return_value = CommandResult( - stdout="output", stderr="", exit_code=0, branch="branch", command="cmd" - ) + mock_route.return_value = CommandResult(stdout="output", stderr="", exit_code=0, branch="branch", command="cmd") result = handle_command("route", ["@branch", "cmd"]) @@ -563,9 +503,7 @@ class TestHandleCommand: @patch("aipass.drone.apps.modules.router.route_command") def test_route_with_extra_args(self, mock_route): """handle_command('route', ['@b', 'cmd', '--flag']) passes extra args.""" - mock_route.return_value = CommandResult( - stdout="", stderr="", exit_code=0, branch="b", command="cmd" - ) + mock_route.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="b", command="cmd") result = handle_command("route", ["@b", "cmd", "--flag"]) @@ -575,9 +513,7 @@ class TestHandleCommand: @patch("aipass.drone.apps.modules.router.route_command") def test_route_nonzero_exit_returns_false(self, mock_route): """handle_command('route', ...) returns False when exit_code != 0.""" - mock_route.return_value = CommandResult( - stdout="", stderr="err", exit_code=1, branch="b", command="cmd" - ) + mock_route.return_value = CommandResult(stdout="", stderr="err", exit_code=1, branch="b", command="cmd") result = handle_command("route", ["@b", "cmd"]) diff --git a/src/aipass/drone/tests/test_scan.py b/src/aipass/drone/tests/test_scan.py index e87ee466..a61c994a 100644 --- a/src/aipass/drone/tests/test_scan.py +++ b/src/aipass/drone/tests/test_scan.py @@ -33,6 +33,7 @@ from aipass.drone.apps.handlers.scanning.formatters import ( # scan_help_output tests # ============================================================================= + class TestScanHelpOutput: """Tests for scan_help_output().""" @@ -43,12 +44,7 @@ class TestScanHelpOutput: (apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8") mock_result = MagicMock() - mock_result.stdout = ( - b"Usage: mybranch\n\n" - b"Commands:\n" - b" audit Run an audit\n" - b" list List items\n\n" - ) + mock_result.stdout = b"Usage: mybranch\n\nCommands:\n audit Run an audit\n list List items\n\n" mock_result.stderr = b"" with patch( @@ -70,10 +66,7 @@ class TestScanHelpOutput: (apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8") mock_result = MagicMock() - mock_result.stdout = ( - b"Commands:\n" - b" deploy Deploy to production\n\n" - ) + mock_result.stdout = b"Commands:\n deploy Deploy to production\n\n" mock_result.stderr = b"" with patch( @@ -141,6 +134,7 @@ class TestScanHelpOutput: # scan_module_files tests # ============================================================================= + class TestScanModuleFiles: """Tests for scan_module_files().""" @@ -212,8 +206,7 @@ class TestScanModuleFiles: modules_dir = temp_test_dir / "apps" / "modules" modules_dir.mkdir(parents=True) (modules_dir / "config.py").write_text( - '"""Configuration management for the branch."""\n' - "def handle_command(command=None, args=None): pass\n", + '"""Configuration management for the branch."""\ndef handle_command(command=None, args=None): pass\n', encoding="utf-8", ) @@ -242,6 +235,7 @@ class TestScanModuleFiles: # scan_branch tests # ============================================================================= + class TestScanBranch: """Tests for scan_branch().""" @@ -325,6 +319,7 @@ class TestScanBranch: # format_scan_results tests # ============================================================================= + class TestFormatScanResults: """Tests for format_scan_results().""" @@ -362,6 +357,7 @@ class TestFormatScanResults: # format_no_commands tests # ============================================================================= + class TestFormatNoCommands: """Tests for format_no_commands().""" @@ -385,6 +381,7 @@ class TestFormatNoCommands: # scan module handle_command tests # ============================================================================= + class TestScanHandleCommand: """Tests for scan.handle_command() routing.""" @@ -434,6 +431,7 @@ class TestScanHandleCommand: # scan module scan() tests # ============================================================================= + class TestScanFunction: """Tests for scan.scan() orchestration.""" diff --git a/src/aipass/drone/tests/test_system_pr.py b/src/aipass/drone/tests/test_system_pr.py index 3c190a85..26439cb3 100644 --- a/src/aipass/drone/tests/test_system_pr.py +++ b/src/aipass/drone/tests/test_system_pr.py @@ -38,10 +38,12 @@ def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: trinity.mkdir() passport = trinity / "passport.json" passport.write_text( - json.dumps({ - "branch_info": {"branch_name": "devpulse"}, - "identity": {"name": "devpulse"}, - }), + json.dumps( + { + "branch_info": {"branch_name": "devpulse"}, + "identity": {"name": "devpulse"}, + } + ), encoding="utf-8", ) monkeypatch.chdir(tmp_path) @@ -55,10 +57,12 @@ def seedgo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: trinity.mkdir() passport = trinity / "passport.json" passport.write_text( - json.dumps({ - "branch_info": {"branch_name": "seedgo"}, - "identity": {"name": "seedgo"}, - }), + json.dumps( + { + "branch_info": {"branch_name": "seedgo"}, + "identity": {"name": "seedgo"}, + } + ), encoding="utf-8", ) monkeypatch.chdir(tmp_path) @@ -160,9 +164,7 @@ class TestSystemPrNotOnMain: @patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.find_repo_root") @patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.subprocess.run") - def test_system_pr_not_on_main( - self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path - ) -> None: + def test_system_pr_not_on_main(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: mock_root.return_value = tmp_path # Simulate being on a feature branch diff --git a/src/aipass/flow/apps/flow.py b/src/aipass/flow/apps/flow.py index 84bc30b0..6cf3c266 100755 --- a/src/aipass/flow/apps/flow.py +++ b/src/aipass/flow/apps/flow.py @@ -18,6 +18,7 @@ Auto-discovery architecture: # INFRASTRUCTURE IMPORT PATTERN import sys from pathlib import Path + _PKG_ROOT = Path(__file__).resolve().parents[2] # flow.py → apps/ → flow/ → aipass/ # Standard library imports @@ -27,7 +28,7 @@ from typing import List, Any # Handle broken pipe gracefully (e.g. output piped to head) # SIGPIPE does not exist on Windows -if hasattr(signal, 'SIGPIPE'): +if hasattr(signal, "SIGPIPE"): signal.signal(signal.SIGPIPE, signal.SIG_DFL) # Prax logger @@ -42,6 +43,7 @@ from aipass.cli.apps.modules import console, header, error MODULES_DIR = Path(__file__).parent / "modules" + def discover_modules() -> List[Any]: """ Auto-discover modules in modules/ directory @@ -68,7 +70,7 @@ def discover_modules() -> List[Any]: module = importlib.import_module(module_name) # Check if module has handle_command function - if hasattr(module, 'handle_command'): + if hasattr(module, "handle_command"): modules.append(module) logger.info(f"[FLOW] Loaded module: {file_path.stem}") else: @@ -104,10 +106,12 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: return False + # ============================================================================= # MAIN # ============================================================================= + def main(): """Main entry point - routes commands or shows help""" try: @@ -138,12 +142,12 @@ def _main_impl(): return 0 # Show version - if args[0] in ['--version', '-V']: + if args[0] in ["--version", "-V"]: console.print("FLOW v2.2.1") return 0 # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: + if args[0] in ["--help", "-h", "help"]: print_help(modules) return 0 @@ -158,7 +162,7 @@ def _main_impl(): return 0 # Fallback: try module-specific help if command wasn't handled - if remaining_args and remaining_args[0] in ['--help', '-h']: + if remaining_args and remaining_args[0] in ["--help", "-h"]: print_module_help(command, modules) return 0 else: @@ -183,11 +187,11 @@ def print_introspection(modules: List[Any]): if modules: for module in modules: - module_name = module.__name__.split('.')[-1] + module_name = module.__name__.split(".")[-1] # Get first line of docstring description = "No description" if module.__doc__: - description = module.__doc__.strip().split('\n')[0] + description = module.__doc__.strip().split("\n")[0] console.print(f" [cyan]•[/cyan] {module_name:20} [dim]{description}[/dim]") else: console.print(" [dim]No modules discovered[/dim]") @@ -223,14 +227,14 @@ def print_help(modules: List[Any]): if modules: for module in modules: - module_name = module.__name__.split('.')[-1] + module_name = module.__name__.split(".")[-1] # Extract short form (before underscore if present) - short_name = module_name.split('_')[0] if '_' in module_name else module_name + short_name = module_name.split("_")[0] if "_" in module_name else module_name # Get first line of docstring description = "No description" if module.__doc__: - description = module.__doc__.strip().split('\n')[0] + description = module.__doc__.strip().split("\n")[0] # Display both forms if short_name != module_name: @@ -247,9 +251,11 @@ def print_help(modules: List[Any]): console.print("[bold cyan]EXAMPLES:[/bold cyan]") console.print() console.print(" [yellow]Create plans:[/yellow]") - console.print(" [dim]drone @flow create . \"Implementation task\"[/dim] [dim]# FPLAN (default)[/dim]") - console.print(" [dim]drone @flow create . \"subject\" master[/dim] [dim]# FPLAN master template[/dim]") - console.print(" [dim]drone @flow create . \"Design topic\" dplan[/dim] [dim]# DPLAN[/dim]") + console.print(' [dim]drone @flow create . "Implementation task"[/dim] [dim]# FPLAN (default)[/dim]') + console.print( + ' [dim]drone @flow create . "subject" master[/dim] [dim]# FPLAN master template[/dim]' + ) + console.print(' [dim]drone @flow create . "Design topic" dplan[/dim] [dim]# DPLAN[/dim]') console.print() console.print(" [yellow]Close plans:[/yellow]") console.print(" [dim]drone @flow close FPLAN-0042[/dim]") @@ -262,7 +268,9 @@ def print_help(modules: List[Any]): console.print() console.print(" [yellow]Templates:[/yellow]") console.print(" [dim]drone @flow templates[/dim] [dim]# List registered types[/dim]") - console.print(" [dim]drone @flow scan[/dim] [dim]# Find unregistered dirs[/dim]") + console.print( + " [dim]drone @flow scan[/dim] [dim]# Find unregistered dirs[/dim]" + ) console.print(" [dim]drone @flow register testing TPLAN[/dim] [dim]# Register new type[/dim]") console.print(" [dim]drone @flow unregister testing[/dim] [dim]# Remove type[/dim]") console.print() @@ -279,7 +287,7 @@ def print_module_help(command: str, modules: List[Any]): # Try to find the module that handles this command target_module = None for module in modules: - module_name = module.__name__.split('.')[-1] + module_name = module.__name__.split(".")[-1] # Check if module name matches command (e.g., create_plan matches "create" or "create_plan") if command == module_name or module_name.startswith(command): target_module = module @@ -294,7 +302,7 @@ def print_module_help(command: str, modules: List[Any]): return console.print() - module_name = target_module.__name__.split('.')[-1] + module_name = target_module.__name__.split(".")[-1] header(f"Flow - {module_name} Command") console.print() @@ -313,6 +321,7 @@ if __name__ == "__main__": sys.exit(main()) except BrokenPipeError: import os + logger.info("[FLOW] Broken pipe in main (stdout closed early)") try: sys.stdout.close() diff --git a/src/aipass/flow/apps/handlers/__init__.py b/src/aipass/flow/apps/handlers/__init__.py index 81c53f40..00ac5896 100644 --- a/src/aipass/flow/apps/handlers/__init__.py +++ b/src/aipass/flow/apps/handlers/__init__.py @@ -62,8 +62,10 @@ def _guard_branch_access(): # DEBUG: Print what we found import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) @@ -87,9 +89,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -103,7 +105,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/flow/apps/handlers/dashboard/__init__.py b/src/aipass/flow/apps/handlers/dashboard/__init__.py index 1a4bc67e..66a53ade 100644 --- a/src/aipass/flow/apps/handlers/dashboard/__init__.py +++ b/src/aipass/flow/apps/handlers/dashboard/__init__.py @@ -1,4 +1,3 @@ - # ===================AIPASS==================== # META DATA HEADER # Name: __init__.py diff --git a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py index d57e5049..9d678164 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py @@ -66,6 +66,7 @@ DASHBOARD_TEMPLATE_FILE = _PKG_ROOT / "devpulse" / "templates" / "DASHBOARD.temp # DASHBOARD WRITE (local — no cross-branch imports) # ============================================= + def _write_dashboard_section(branch_path: Path, section_name: str, section_data: Dict[str, Any]) -> bool: """ Write a single section to a branch's DASHBOARD.local.json. @@ -128,9 +129,7 @@ def _create_fresh_dashboard(branch_path: Path) -> Dict[str, Any]: if DASHBOARD_TEMPLATE_FILE.exists(): try: template = json.loads(DASHBOARD_TEMPLATE_FILE.read_text()) - dashboard = json.loads( - json.dumps(template).replace("{{BRANCHNAME}}", branch_path.name.upper()) - ) + dashboard = json.loads(json.dumps(template).replace("{{BRANCHNAME}}", branch_path.name.upper())) dashboard["last_updated"] = datetime.now().isoformat() return dashboard except (json.JSONDecodeError, OSError) as exc: @@ -147,8 +146,8 @@ def _create_fresh_dashboard(branch_path: Path) -> Dict[str, Any]: "flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""}, "memory": {"managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": ""}, "devpulse": {"managed_by": "devpulse", "summary": {}, "last_updated": ""}, - "commons_activity": {"managed_by": "commons", "mentions": 0, "last_updated": ""} - } + "commons_activity": {"managed_by": "commons", "mentions": 0, "last_updated": ""}, + }, } @@ -191,7 +190,7 @@ def _calculate_quick_status(sections: Dict[str, Any]) -> Dict[str, Any]: "active_plans": active_plans, "commons_mentions": mentions, "action_required": action_required, - "summary": ", ".join(parts) if parts else "All clear" + "summary": ", ".join(parts) if parts else "All clear", } @@ -213,7 +212,9 @@ def _get_all_registry_files() -> List[str]: if files: return files except Exception as exc: - logger.warning("[push_branch_dashboard] Failed to discover plan types, falling back to default registry: %s", exc) + logger.warning( + "[push_branch_dashboard] Failed to discover plan types, falling back to default registry: %s", exc + ) return [REGISTRY_FILE.name] @@ -221,6 +222,7 @@ def _get_all_registry_files() -> List[str]: # HELPER FUNCTIONS # ============================================= + def _load_registry() -> Dict[str, Any]: """ Load all per-type plan registries and merge into a single dict. @@ -234,7 +236,7 @@ def _load_registry() -> Dict[str, Any]: try: if not target.exists(): continue - with open(target, 'r', encoding='utf-8') as f: + with open(target, "r", encoding="utf-8") as f: data = json.load(f) for plan_num, plan_data in data.get("plans", {}).items(): merged["plans"][plan_num] = plan_data @@ -248,8 +250,7 @@ def _load_registry() -> Dict[str, Any]: def _filter_branch_plans( - registry: Dict[str, Any], - branch_path: Path + registry: Dict[str, Any], branch_path: Path ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int]: """ Filter plans for a specific branch from the registry. @@ -281,17 +282,19 @@ def _filter_branch_plans( # Extract plan prefix from file_path (e.g., DPLAN, FPLAN, TDPLAN) file_path = plan_data.get("file_path", "") filename = Path(file_path).name if file_path else "" - prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix_match = re.match(r"^([A-Z]+PLAN)", filename) prefix = prefix_match.group(1) if prefix_match else "FPLAN" plan_id = f"{prefix}-{plan_num.zfill(4)}" if plan_data.get("status") == "open": - active_plans.append({ - "id": plan_id, - "subject": plan_data.get("subject", ""), - "created": plan_data.get("created", ""), - "location": location - }) + active_plans.append( + { + "id": plan_id, + "subject": plan_data.get("subject", ""), + "created": plan_data.get("created", ""), + "location": location, + } + ) elif plan_data.get("status") == "closed": closed_ts = plan_data.get("closed", "") # Only include recently closed (within 7 days) @@ -299,19 +302,15 @@ def _filter_branch_plans( try: closed_dt = datetime.fromisoformat(closed_ts) if closed_dt >= cutoff: - closed_plans.append({ - "id": plan_id, - "subject": plan_data.get("subject", ""), - "closed": closed_ts - }) + closed_plans.append( + {"id": plan_id, "subject": plan_data.get("subject", ""), "closed": closed_ts} + ) except (ValueError, TypeError) as exc: # If we can't parse the timestamp, include it anyway - logger.warning("Unparseable closed timestamp '%s' for plan %s, including anyway: %s", closed_ts, plan_id, exc) - closed_plans.append({ - "id": plan_id, - "subject": plan_data.get("subject", ""), - "closed": closed_ts - }) + logger.warning( + "Unparseable closed timestamp '%s' for plan %s, including anyway: %s", closed_ts, plan_id, exc + ) + closed_plans.append({"id": plan_id, "subject": plan_data.get("subject", ""), "closed": closed_ts}) # Sort active by created date (newest first) active_plans.sort(key=lambda x: x.get("created", ""), reverse=True) @@ -324,9 +323,7 @@ def _filter_branch_plans( def _build_section_data( - active_plans: List[Dict[str, Any]], - recently_closed: List[Dict[str, Any]], - total_plans: int + active_plans: List[Dict[str, Any]], recently_closed: List[Dict[str, Any]], total_plans: int ) -> Dict[str, Any]: """ Build the flow section data for write_section(). @@ -344,7 +341,7 @@ def _build_section_data( "active_plans": active_plans, "active_count": len(active_plans), "recently_closed": recently_closed, - "total_plans": total_plans + "total_plans": total_plans, } @@ -352,6 +349,7 @@ def _build_section_data( # HANDLER FUNCTION # ============================================= + def push_flow_to_branch_dashboard(branch_path: Path) -> bool: """ Push flow section to a branch's DASHBOARD.local.json via write_section(). @@ -389,13 +387,16 @@ def push_flow_to_branch_dashboard(branch_path: Path) -> bool: result = _write_dashboard_section(branch_path, "flow", section_data) if result: - json_handler.log_operation("branch_dashboard_pushed", { - "branch": branch_path.name, - "active_plans": len(active_plans), - "recently_closed": len(recently_closed), - "total_plans": total_plans, - "success": True, - }) + json_handler.log_operation( + "branch_dashboard_pushed", + { + "branch": branch_path.name, + "active_plans": len(active_plans), + "recently_closed": len(recently_closed), + "total_plans": total_plans, + "success": True, + }, + ) return result diff --git a/src/aipass/flow/apps/handlers/dashboard/push_central.py b/src/aipass/flow/apps/handlers/dashboard/push_central.py index 6df9deed..682929a4 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_central.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_central.py @@ -90,6 +90,7 @@ def _get_all_registry_files() -> List[str]: # HELPER FUNCTIONS # ============================================= + def _load_registry() -> Dict[str, Any]: """Load all per-type plan registries and merge into a single dict. @@ -102,7 +103,7 @@ def _load_registry() -> Dict[str, Any]: try: if not target.exists(): continue - with open(target, 'r', encoding='utf-8') as f: + with open(target, "r", encoding="utf-8") as f: data = json.load(f) for plan_num, plan_data in data.get("plans", {}).items(): merged["plans"][plan_num] = plan_data @@ -136,7 +137,7 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict # Extract plan prefix from file_path (e.g., DPLAN, FPLAN, TDPLAN) file_path_str = plan_data.get("file_path", "") filename = Path(file_path_str).name if file_path_str else "" - prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix_match = re.match(r"^([A-Z]+PLAN)", filename) prefix = prefix_match.group(1) if prefix_match else "FPLAN" # Build plan entry @@ -146,7 +147,7 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict "status": plan_data.get("status", "open"), "created": plan_data.get("created", ""), "file_path": plan_data.get("file_path", ""), - "relative_path": plan_data.get("relative_path", "") + "relative_path": plan_data.get("relative_path", ""), } if plan_data.get("status") == "open": @@ -177,26 +178,18 @@ def _load_central() -> Dict[str, Any]: return { "generated_at": "", "branches": {}, - "global_statistics": { - "total_active": 0, - "total_closed": 0, - "branches_reporting": 0 - } + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, } try: - with open(CENTRAL_FILE, 'r', encoding='utf-8') as f: + with open(CENTRAL_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception as exc: logger.warning("Failed to load PLANS.central.json '%s': %s", CENTRAL_FILE, exc) return { "generated_at": "", "branches": {}, - "global_statistics": { - "total_active": 0, - "total_closed": 0, - "branches_reporting": 0 - } + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, } @@ -218,17 +211,14 @@ def _calculate_global_statistics(central_data: Dict[str, Any]) -> Dict[str, int] total_active += stats.get("active_count", 0) total_closed += stats.get("total_closed", 0) - return { - "total_active": total_active, - "total_closed": total_closed, - "branches_reporting": len(branches) - } + return {"total_active": total_active, "total_closed": total_closed, "branches_reporting": len(branches)} # ============================================= # MAIN HANDLER FUNCTION # ============================================= + def push_to_plans_central() -> bool: """Push Flow's plan data to .ai_central/PLANS.central.json @@ -266,10 +256,14 @@ def push_to_plans_central() -> bool: "recently_closed": recently_closed, "statistics": { "active_count": len(active_plans), - "total_closed": len([p for p in registry.get("plans", {}).values() - if p.get("location") == str(FLOW_ROOT) - and p.get("status") == "closed"]) - } + "total_closed": len( + [ + p + for p in registry.get("plans", {}).values() + if p.get("location") == str(FLOW_ROOT) and p.get("status") == "closed" + ] + ), + }, } # Load existing central file @@ -287,19 +281,22 @@ def push_to_plans_central() -> bool: central_data["generated_at"] = now # Write back to central file - with open(CENTRAL_FILE, 'w', encoding='utf-8') as f: + with open(CENTRAL_FILE, "w", encoding="utf-8") as f: json.dump(central_data, f, indent=2, ensure_ascii=False) # Call aggregate_central_impl to rebuild top-level arrays with validation # This ensures active_plans is built from all branches and validates files exist aggregate_central_impl(heal=True, central_file=CENTRAL_FILE, central_dir=AI_CENTRAL_DIR) - json_handler.log_operation("plans_central_pushed", { - "active_plans": len(active_plans), - "recently_closed": len(recently_closed), - "branches_reporting": central_data["global_statistics"].get("branches_reporting", 0), - "success": True, - }) + json_handler.log_operation( + "plans_central_pushed", + { + "active_plans": len(active_plans), + "recently_closed": len(recently_closed), + "branches_reporting": central_data["global_statistics"].get("branches_reporting", 0), + "success": True, + }, + ) return True diff --git a/src/aipass/flow/apps/handlers/dashboard/update_local.py b/src/aipass/flow/apps/handlers/dashboard/update_local.py index 619afd9c..7edd4e65 100644 --- a/src/aipass/flow/apps/handlers/dashboard/update_local.py +++ b/src/aipass/flow/apps/handlers/dashboard/update_local.py @@ -113,6 +113,7 @@ def _get_all_registry_files() -> List[str]: # HELPER FUNCTIONS # ============================================= + def _read_registry() -> Optional[Dict[str, Any]]: """ Read all per-type plan registries and merge into a single dict. @@ -127,7 +128,7 @@ def _read_registry() -> Optional[Dict[str, Any]]: try: if not target.exists(): continue - with open(target, 'r', encoding='utf-8') as f: + with open(target, "r", encoding="utf-8") as f: data = json.load(f) found_any = True for plan_num, plan_data in data.get("plans", {}).items(): @@ -165,7 +166,7 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], # Extract plan prefix from file_path (e.g., DPLAN, FPLAN, TDPLAN) file_path_str = plan_data.get("file_path", "") filename = Path(file_path_str).name if file_path_str else "" - prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix_match = re.match(r"^([A-Z]+PLAN)", filename) prefix = prefix_match.group(1) if prefix_match else "FPLAN" # Build plan entry @@ -175,7 +176,7 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], "subject": plan_data.get("subject", ""), "status": plan_data.get("status", "unknown"), "file_path": plan_data.get("file_path", ""), - "location": location + "location": location, } # Add timestamps @@ -198,8 +199,9 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], return active, closed -def _calculate_statistics(active: List[Dict[str, Any]], closed: List[Dict[str, Any]], - registry: Dict[str, Any]) -> Dict[str, int]: +def _calculate_statistics( + active: List[Dict[str, Any]], closed: List[Dict[str, Any]], registry: Dict[str, Any] +) -> Dict[str, int]: """ Calculate statistics for Flow's plans. @@ -211,11 +213,7 @@ def _calculate_statistics(active: List[Dict[str, Any]], closed: List[Dict[str, A Returns: Statistics dict """ - return { - "active_count": len(active), - "total_closed": len(closed), - "next_number": registry.get("next_number", 1) - } + return {"active_count": len(active), "total_closed": len(closed), "next_number": registry.get("next_number", 1)} def _read_existing_dashboard() -> Dict[str, Any]: @@ -228,7 +226,7 @@ def _read_existing_dashboard() -> Dict[str, Any]: try: if not DASHBOARD_FILE.exists(): return {} - with open(DASHBOARD_FILE, 'r', encoding='utf-8') as f: + with open(DASHBOARD_FILE, "r", encoding="utf-8") as f: content = f.read().strip() # Handle old markdown format gracefully if not content or content.startswith("⚠️"): @@ -240,9 +238,9 @@ def _read_existing_dashboard() -> Dict[str, Any]: return {} -def _build_dashboard_data(active: List[Dict[str, Any]], closed: List[Dict[str, Any]], - statistics: Dict[str, int], - existing: Dict[str, Any]) -> Dict[str, Any]: +def _build_dashboard_data( + active: List[Dict[str, Any]], closed: List[Dict[str, Any]], statistics: Dict[str, int], existing: Dict[str, Any] +) -> Dict[str, Any]: """ Build updated dashboard data with Flow's section. @@ -264,7 +262,7 @@ def _build_dashboard_data(active: List[Dict[str, Any]], closed: List[Dict[str, A dashboard["flow_plans"] = { "active": active, "recently_closed": closed[-5:] if len(closed) > 0 else [], # Last 5 - "statistics": statistics + "statistics": statistics, } return dashboard @@ -282,7 +280,7 @@ def _write_dashboard(dashboard: Dict[str, Any]) -> bool: """ try: DASHBOARD_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(DASHBOARD_FILE, 'w', encoding='utf-8') as f: + with open(DASHBOARD_FILE, "w", encoding="utf-8") as f: json.dump(dashboard, f, indent=2, ensure_ascii=False) return True except Exception as exc: @@ -294,6 +292,7 @@ def _write_dashboard(dashboard: Dict[str, Any]) -> bool: # HANDLER FUNCTION # ============================================= + def update_dashboard_local() -> bool: """ Update DASHBOARD.local.json with Flow's plan summaries from registry. @@ -334,10 +333,13 @@ def update_dashboard_local() -> bool: result = _write_dashboard(dashboard) if result: - json_handler.log_operation("dashboard_local_updated", { - "active_count": statistics["active_count"], - "total_closed": statistics["total_closed"], - "success": True, - }) + json_handler.log_operation( + "dashboard_local_updated", + { + "active_count": statistics["active_count"], + "total_closed": statistics["total_closed"], + "success": True, + }, + ) return result diff --git a/src/aipass/flow/apps/handlers/json/json_handler.py b/src/aipass/flow/apps/handlers/json/json_handler.py index c46a1dac..07a7728d 100644 --- a/src/aipass/flow/apps/handlers/json/json_handler.py +++ b/src/aipass/flow/apps/handlers/json/json_handler.py @@ -47,7 +47,7 @@ def _get_caller_module_name() -> str: module_name = caller_path.stem # Validate module name - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name # Fallback @@ -104,12 +104,10 @@ def _atomic_write_json(target_path: Path, data: Any) -> None: Prevents corruption from concurrent processes writing the same file. """ - fd, tmp_path = tempfile.mkstemp( - dir=str(target_path.parent), suffix=".tmp", prefix=target_path.stem - ) + fd, tmp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".tmp", prefix=target_path.stem) succeeded = False try: - with os.fdopen(fd, 'w', encoding='utf-8') as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) os.replace(tmp_path, str(target_path)) succeeded = True @@ -133,14 +131,16 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: if json_path.exists(): try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if validate_json_structure(data, json_type): return True except Exception as exc: # File exists but is corrupted - will regenerate below - logger.warning("[json_handler] Corrupted JSON file for '%s/%s', regenerating: %s", module_name, json_type, exc) + logger.warning( + "[json_handler] Corrupted JSON file for '%s/%s', regenerating: %s", module_name, json_type, exc + ) template = _default_template(json_type, module_name) if template is None: @@ -162,7 +162,7 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: json_path = get_json_path(module_name, json_type) try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as exc: logger.error("[json_handler] Failed to load JSON for '%s/%s': %s", module_name, json_type, exc) @@ -229,10 +229,7 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam log = [] # Create new entry - entry: Dict[str, Any] = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } + entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation} if data: entry["data"] = data @@ -284,10 +281,7 @@ if __name__ == "__main__": console = Console() console.print() - console.print(Panel.fit( - "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", - border_style="bright_blue" - )) + console.print(Panel.fit("[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", border_style="bright_blue")) console.print() console.print("[yellow]TESTING:[/yellow] Creating FLOW JSONs...") diff --git a/src/aipass/flow/apps/handlers/mbank/process.py b/src/aipass/flow/apps/handlers/mbank/process.py index 00ffc2e8..c512a8b1 100644 --- a/src/aipass/flow/apps/handlers/mbank/process.py +++ b/src/aipass/flow/apps/handlers/mbank/process.py @@ -41,6 +41,8 @@ from aipass.prax.apps.modules.logger import system_logger as logger FLOW_ROOT = _PKG_ROOT / "flow" FLOW_JSON_DIR = FLOW_ROOT / "flow_json" + + def _find_repo_root() -> Path: """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -64,6 +66,7 @@ def _get_all_registry_files() -> List[str]: """Return per-type registry filenames via plan-type discovery.""" try: from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types # type: ignore[import-not-found] + files: List[str] = [] for _key, config in discover_plan_types().items(): rf = config.get("registry_file") @@ -80,27 +83,30 @@ def _get_all_registry_files() -> List[str]: # REGISTRY OPERATIONS # ============================================= + def load_flow_registry(registry_file: str | None = None) -> Dict[str, Any]: """Load a plan registry.""" target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE if not target.exists(): raise Exception(f"Flow registry not found at {target}") try: - with open(target, 'r', encoding='utf-8') as f: + with open(target, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: raise Exception(f"Failed to load flow registry: {e}") + def save_flow_registry(registry: Dict[str, Any], registry_file: str | None = None) -> None: """Save a plan registry.""" target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE try: registry["last_updated"] = datetime.now(timezone.utc).isoformat() - with open(target, 'w', encoding='utf-8') as f: + with open(target, "w", encoding="utf-8") as f: json.dump(registry, f, indent=2, ensure_ascii=False) except Exception as e: raise Exception(f"Failed to save flow registry: {e}") + def get_closed_plans() -> List[Dict[str, Any]]: """Get closed PLANs from ALL per-type registries. @@ -119,18 +125,22 @@ def get_closed_plans() -> List[Dict[str, Any]]: if plan_info.get("status") == "closed" and plan_info.get("processed") is not True: file_path = Path(plan_info.get("file_path", "")) if file_path.exists(): - closed_plans.append({ - "number": plan_num, - "path": file_path, - "info": plan_info, - "registry_file": reg_file, - }) + closed_plans.append( + { + "number": plan_num, + "path": file_path, + "info": plan_info, + "registry_file": reg_file, + } + ) return closed_plans + # ============================================= # TEMPLATE DETECTION # ============================================= + def is_template_content(content: str) -> bool: """Check if plan content is still unedited template (v4.0) @@ -146,10 +156,10 @@ def is_template_content(content: str) -> bool: """ # User content indicators — if ANY of these are found, plan has real work user_content_signals = [ - "- [x] Agent deployed", # Checked execution log item - "- [x] Agent completed", # Checked execution log item - "- [x] Seedgo checklist", # Checked completion item - "- [x] All goals achieved", # Checked completion item + "- [x] Agent deployed", # Checked execution log item + "- [x] Agent completed", # Checked execution log item + "- [x] Seedgo checklist", # Checked completion item + "- [x] All goals achieved", # Checked completion item ] for signal in user_content_signals: if signal in content: @@ -157,7 +167,8 @@ def is_template_content(content: str) -> bool: # Check Notes section for user content (not just the placeholder) import re - notes_match = re.search(r'## Notes\s*\n(.*?)(?=\n---|\n## |\Z)', content, re.DOTALL) + + notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL) if notes_match: notes_content = notes_match.group(1).strip() # If notes has content beyond the template placeholder, it's real work @@ -165,10 +176,10 @@ def is_template_content(content: str) -> bool: return False # Check Execution Log for user-added entries beyond template - exec_match = re.search(r'## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)', content, re.DOTALL) + exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL) if exec_match: exec_content = exec_match.group(1).strip() - lines = [line.strip() for line in exec_content.split('\n') if line.strip()] + lines = [line.strip() for line in exec_content.split("\n") if line.strip()] # Template has ~6 lines (date header + checkbox items). More = user added content. if len(lines) > 8: return False @@ -205,6 +216,7 @@ def is_template_content(content: str) -> bool: return False + # ============================================= # CONTENT ANALYSIS (DISABLED) # AI summarization removed — plans vectorized directly from flow/processed_plans/ @@ -394,6 +406,7 @@ def is_template_content(content: str) -> bool: # PLAN ARCHIVAL # ============================================= + def archive_plan(plan_path: Path) -> bool: """Move processed plan file to flow/processed_plans/ @@ -438,10 +451,12 @@ def archive_plan(plan_path: Path) -> bool: logger.error("[mbank] Failed to archive plan '%s': %s", plan_path, exc) return False + # ============================================= # TEMP FILE CLEANUP # ============================================= + def cleanup_temp_files() -> Dict[str, Any]: """Remove old -TEMP files from @memory (empty template plans) @@ -473,49 +488,40 @@ def cleanup_temp_files() -> Dict[str, Any]: # Verify deletion if not temp_file.exists(): files_deleted += 1 - details.append({ - "file": temp_file.name, - "status": "deleted" - }) + details.append({"file": temp_file.name, "status": "deleted"}) else: failed_deletes += 1 - details.append({ - "file": temp_file.name, - "status": "delete_failed", - "error": "File still exists after deletion" - }) + details.append( + { + "file": temp_file.name, + "status": "delete_failed", + "error": "File still exists after deletion", + } + ) except Exception as e: logger.warning("[mbank] Failed to delete temp file '%s': %s", temp_file.name, e) failed_deletes += 1 - details.append({ - "file": temp_file.name, - "status": "delete_failed", - "error": str(e) - }) + details.append({"file": temp_file.name, "status": "delete_failed", "error": str(e)}) except Exception as e: # Failed to scan directory logger.error("[mbank] Failed to scan @memory for temp files: %s", e) - return { - "files_found": 0, - "files_deleted": 0, - "failed_deletes": 0, - "details": [], - "scan_error": str(e) - } + return {"files_found": 0, "files_deleted": 0, "failed_deletes": 0, "details": [], "scan_error": str(e)} return { "files_found": files_found, "files_deleted": files_deleted, "failed_deletes": failed_deletes, - "details": details + "details": details, } + # ============================================= # ORPHAN HEALING # ============================================= + def verify_and_heal_orphaned_plans() -> Dict[str, Any]: """Cross-check ALL registries vs filesystem and auto-heal orphaned plans.""" orphans_found = 0 @@ -547,25 +553,44 @@ def verify_and_heal_orphaned_plans() -> Dict[str, Any]: original_path.rename(destination) if destination.exists() and not original_path.exists(): successfully_healed += 1 - orphan_details.append({"plan": plan_label, "status": "healed", - "original_path": str(original_path), "destination": str(destination)}) + orphan_details.append( + { + "plan": plan_label, + "status": "healed", + "original_path": str(original_path), + "destination": str(destination), + } + ) else: failed_to_heal += 1 - orphan_details.append({"plan": plan_label, "status": "heal_failed", - "error": "Verification failed", "path": str(original_path)}) + orphan_details.append( + { + "plan": plan_label, + "status": "heal_failed", + "error": "Verification failed", + "path": str(original_path), + } + ) except Exception as e: logger.warning("[mbank] Failed to heal orphaned plan '%s': %s", plan_label, e) failed_to_heal += 1 - orphan_details.append({"plan": plan_label, "status": "heal_failed", - "error": str(e), "path": str(original_path)}) + orphan_details.append( + {"plan": plan_label, "status": "heal_failed", "error": str(e), "path": str(original_path)} + ) + + return { + "orphans_found": orphans_found, + "successfully_healed": successfully_healed, + "failed_to_heal": failed_to_heal, + "orphans": orphan_details, + } - return {"orphans_found": orphans_found, "successfully_healed": successfully_healed, - "failed_to_heal": failed_to_heal, "orphans": orphan_details} # ============================================= # MAIN PROCESSING # ============================================= + def process_closed_plans() -> Dict[str, Any]: """Process all closed plans across all plan types. @@ -606,9 +631,14 @@ def process_closed_plans() -> Dict[str, Any]: results.append({"plan": plan_label, "status": "archived", "correlation_id": correlation_id}) else: error_count += 1 - results.append({"plan": plan_label, "status": "archive_failed", - "error": "Failed to move plan to backup/processed_plans/", - "correlation_id": correlation_id}) + results.append( + { + "plan": plan_label, + "status": "archive_failed", + "error": "Failed to move plan to backup/processed_plans/", + "correlation_id": correlation_id, + } + ) except Exception as e: logger.error("[mbank] Error processing closed plan '%s': %s", plan.get("path", "unknown"), e) error_count += 1 @@ -616,15 +646,23 @@ def process_closed_plans() -> Dict[str, Any]: cleanup_result = cleanup_temp_files() - json_handler.log_operation("closed_plans_processed", { + json_handler.log_operation( + "closed_plans_processed", + { + "processed": processed_count, + "errors": error_count, + "cleanup_deleted": cleanup_result.get("files_deleted", 0), + "success": True, + }, + ) + + return { + "success": True, "processed": processed_count, "errors": error_count, - "cleanup_deleted": cleanup_result.get("files_deleted", 0), - "success": True, - }) - - return {"success": True, "processed": processed_count, "errors": error_count, - "results": results, "cleanup": cleanup_result} + "results": results, + "cleanup": cleanup_result, + } except Exception as e: logger.error("[mbank] Unexpected error in process_closed_plans: %s", e) diff --git a/src/aipass/flow/apps/handlers/plan/aggregate_ops.py b/src/aipass/flow/apps/handlers/plan/aggregate_ops.py index 39628c8e..3da1d55f 100644 --- a/src/aipass/flow/apps/handlers/plan/aggregate_ops.py +++ b/src/aipass/flow/apps/handlers/plan/aggregate_ops.py @@ -22,6 +22,7 @@ from datetime import datetime, timezone from typing import Dict, Any, List, Tuple, Optional from aipass.prax import logger + # logger imported from aipass.prax from aipass.flow.apps.handlers.json import json_handler @@ -36,6 +37,7 @@ MODULE_NAME = "aggregate_central" # HELPER FUNCTIONS # ============================================= + def find_branch_registry(branch_path: Path, branch_name: str) -> Optional[Path]: """Find the registry file for a branch @@ -82,7 +84,7 @@ def load_branch_registry(registry_path: Path) -> Dict[str, Any]: Registry dict or empty structure on error """ try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to load registry {registry_path}: {e}") @@ -101,7 +103,7 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool: """ try: registry["last_updated"] = datetime.now(timezone.utc).isoformat() - with open(registry_path, 'w', encoding='utf-8') as f: + with open(registry_path, "w", encoding="utf-8") as f: json.dump(registry, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -118,9 +120,9 @@ def extract_plan_number(plan_id: str) -> Optional[str]: Returns: Plan number string or None if invalid format """ - if not plan_id or '-' not in plan_id: + if not plan_id or "-" not in plan_id: return None - return plan_id.split('-', 1)[1] + return plan_id.split("-", 1)[1] def auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool: @@ -169,8 +171,9 @@ def auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool return False -def validate_and_heal_branch(branch_name: str, branch_data: Dict[str, Any], - heal: bool = True) -> Tuple[List[Dict], List[Dict]]: +def validate_and_heal_branch( + branch_name: str, branch_data: Dict[str, Any], heal: bool = True +) -> Tuple[List[Dict], List[Dict]]: """Validate plans in a branch and heal missing files Args: @@ -235,24 +238,16 @@ def load_central(central_file: Path) -> Dict[str, Any]: "generated_at": "", "active_plans": [], "recently_closed": [], - "statistics": { - "active_count": 0, - "total_closed": 0, - "recently_closed_included": 0 - }, + "statistics": {"active_count": 0, "total_closed": 0, "recently_closed_included": 0}, "branches": {}, - "global_statistics": { - "total_active": 0, - "total_closed": 0, - "branches_reporting": 0 - } + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, } if not central_file.exists(): return empty_structure try: - with open(central_file, 'r', encoding='utf-8') as f: + with open(central_file, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to load {central_file}: {e}") @@ -272,7 +267,7 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str, """ try: central_dir.mkdir(parents=True, exist_ok=True) - with open(central_file, 'w', encoding='utf-8') as f: + with open(central_file, "w", encoding="utf-8") as f: json.dump(central_data, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -284,9 +279,10 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str, # MAIN AGGREGATION IMPLEMENTATION # ============================================= -def aggregate_central_impl(heal: bool = True, - central_file: Path | None = None, - central_dir: Path | None = None) -> bool: + +def aggregate_central_impl( + heal: bool = True, central_file: Path | None = None, central_dir: Path | None = None +) -> bool: """Aggregate and validate central plans Algorithm: @@ -336,26 +332,20 @@ def aggregate_central_impl(heal: bool = True, logger.info(f"[{MODULE_NAME}] Processing branch: {branch_name}") # Validate and heal - valid_active, closed_plans = validate_and_heal_branch( - branch_name, branch_data, heal - ) + valid_active, closed_plans = validate_and_heal_branch(branch_name, branch_data, heal) # Update branch-level active_plans with validated list branch_data["active_plans"] = valid_active # Update branch-level recently_closed (sorted, newest first) - branch_recently_closed = sorted( - closed_plans, - key=lambda x: x.get("closed", ""), - reverse=True - )[:5] + branch_recently_closed = sorted(closed_plans, key=lambda x: x.get("closed", ""), reverse=True)[:5] branch_data["recently_closed"] = branch_recently_closed # Update branch-level statistics to match validated arrays branch_data["statistics"] = { "active_count": len(valid_active), "total_closed": len(closed_plans), - "recently_closed_included": len(branch_recently_closed) + "recently_closed_included": len(branch_recently_closed), } # Add branch name to each plan for identification @@ -370,16 +360,10 @@ def aggregate_central_impl(heal: bool = True, all_closed.append(plan) # Sort active by created date (newest first) - all_active.sort( - key=lambda x: x.get("created", ""), - reverse=True - ) + all_active.sort(key=lambda x: x.get("created", ""), reverse=True) # Sort closed by closed date (newest first) and limit to last 5 - all_closed.sort( - key=lambda x: x.get("closed", ""), - reverse=True - ) + all_closed.sort(key=lambda x: x.get("closed", ""), reverse=True) recently_closed = all_closed[:5] # Update top-level arrays @@ -390,17 +374,16 @@ def aggregate_central_impl(heal: bool = True, central_data["statistics"] = { "active_count": len(all_active), "total_closed": len(all_closed), - "recently_closed_included": len(recently_closed) + "recently_closed_included": len(recently_closed), } # Update global_statistics (aggregated from all branches) central_data["global_statistics"] = { "total_active": len(all_active), "total_closed": len(all_closed), - "branches_reporting": len([ - b for b in branches.values() - if b.get("active_plans") or b.get("recently_closed") - ]) + "branches_reporting": len( + [b for b in branches.values() if b.get("active_plans") or b.get("recently_closed")] + ), } # Update generated_at timestamp @@ -408,19 +391,27 @@ def aggregate_central_impl(heal: bool = True, # Save central file if save_central(central_file, central_dir, central_data): - logger.info(f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed") + logger.info( + f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed" + ) # Fire trigger event try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('central_aggregated', - active_count=len(all_active), - closed_count=len(recently_closed), - branches_count=len(branches)) + + trigger.fire( + "central_aggregated", + active_count=len(all_active), + closed_count=len(recently_closed), + branches_count=len(branches), + ) except ImportError as e: logger.warning(f"[{MODULE_NAME}] Trigger module not available, skipping central_aggregated event: {e}") - json_handler.log_operation("central_aggregated", {"active_count": len(all_active), "closed_count": len(recently_closed), "success": True}) + json_handler.log_operation( + "central_aggregated", + {"active_count": len(all_active), "closed_count": len(recently_closed), "success": True}, + ) return True else: logger.error(f"[{MODULE_NAME}] Failed to save central file") diff --git a/src/aipass/flow/apps/handlers/plan/append_closed_plan.py b/src/aipass/flow/apps/handlers/plan/append_closed_plan.py index f8847795..327af08d 100644 --- a/src/aipass/flow/apps/handlers/plan/append_closed_plan.py +++ b/src/aipass/flow/apps/handlers/plan/append_closed_plan.py @@ -44,7 +44,7 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) # Extract prefix from plan_info's file_path (e.g., FPLAN, DPLAN) file_path = plan_info.get("file_path", "") filename = Path(file_path).name if file_path else "" - prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix_match = re.match(r"^([A-Z]+PLAN)", filename) prefix = prefix_match.group(1) if prefix_match else "FPLAN" plan_id = f"{prefix}-{plan_key}" @@ -58,14 +58,14 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) "type": prefix, "subject": plan_info.get("subject", ""), "date_closed": date_closed, - "location": plan_info.get("relative_path", "") + "location": plan_info.get("relative_path", ""), } # Read existing file or create new structure closed_plans_path = plan_location / CLOSED_PLANS_FILE if closed_plans_path.exists(): - with open(closed_plans_path, 'r', encoding='utf-8') as f: + with open(closed_plans_path, "r", encoding="utf-8") as f: data = json.load(f) else: data = {"closed_plans": []} @@ -79,12 +79,14 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) # Append and write data["closed_plans"].append(entry) - with open(closed_plans_path, 'w', encoding='utf-8') as f: + with open(closed_plans_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') + f.write("\n") logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}") - json_handler.log_operation("closed_plan_appended", {"plan_id": plan_id, "path": str(closed_plans_path), "success": True}) + json_handler.log_operation( + "closed_plan_appended", {"plan_id": plan_id, "path": str(closed_plans_path), "success": True} + ) return True except Exception as e: diff --git a/src/aipass/flow/apps/handlers/plan/build_registry_entry.py b/src/aipass/flow/apps/handlers/plan/build_registry_entry.py index e263d8f9..1d9ca8a0 100644 --- a/src/aipass/flow/apps/handlers/plan/build_registry_entry.py +++ b/src/aipass/flow/apps/handlers/plan/build_registry_entry.py @@ -20,12 +20,7 @@ from aipass.flow.apps.handlers.json import json_handler def build_plan_registry_entry( - plan_num: int, - target_dir: Path, - relative_location: str, - subject: str, - plan_file: Path, - template_type: str + plan_num: int, target_dir: Path, relative_location: str, subject: str, plan_file: Path, template_type: str ) -> Dict[str, Any]: """ Build registry entry for new plan @@ -72,7 +67,9 @@ def build_plan_registry_entry( "subject": subject, "status": "open", "file_path": str(plan_file), - "template_type": template_type + "template_type": template_type, } - json_handler.log_operation("registry_entry_built", {"plan_num": plan_num, "subject": subject, "location": relative_location}) + json_handler.log_operation( + "registry_entry_built", {"plan_num": plan_num, "subject": subject, "location": relative_location} + ) return entry diff --git a/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py b/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py index 796e1e14..e619b8dc 100644 --- a/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py +++ b/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py @@ -18,10 +18,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.flow.apps.handlers.json import json_handler -def calculate_relative_location( - target_dir: Path, - ecosystem_root: Path -) -> str: +def calculate_relative_location(target_dir: Path, ecosystem_root: Path) -> str: """ Calculate relative location from ecosystem root @@ -69,5 +66,7 @@ def calculate_relative_location( except ValueError: # target_dir is outside ecosystem_root - logger.warning(f"[calculate_relative_path] Target '{target_dir}' is outside ecosystem root '{ecosystem_root}', using absolute path") + logger.warning( + f"[calculate_relative_path] Target '{target_dir}' is outside ecosystem root '{ecosystem_root}', using absolute path" + ) return str(target_dir) diff --git a/src/aipass/flow/apps/handlers/plan/close_ops.py b/src/aipass/flow/apps/handlers/plan/close_ops.py index 6f6d51d7..cd0f2fb5 100644 --- a/src/aipass/flow/apps/handlers/plan/close_ops.py +++ b/src/aipass/flow/apps/handlers/plan/close_ops.py @@ -25,6 +25,7 @@ from datetime import datetime, timezone from typing import Dict, Any, List from aipass.prax import logger + # logger imported from aipass.prax from aipass.flow.apps.handlers.json import json_handler @@ -45,7 +46,8 @@ MODULE_NAME = "close_plan" def _extract_prefix(plan_num_raw: str) -> str | None: """Extract plan-type prefix (e.g. ``"DPLAN"``) from raw input.""" import re - m = re.match(r'^([A-Z]+PLAN)-', plan_num_raw.strip(), re.IGNORECASE) + + m = re.match(r"^([A-Z]+PLAN)-", plan_num_raw.strip(), re.IGNORECASE) return m.group(1).upper() if m else None @@ -59,6 +61,7 @@ def _resolve_registry_file(plan_num_raw: str) -> str | None: return None try: from aipass.flow.apps.handlers.template.plan_type_loader import get_plan_type # type: ignore[import-not-found] + config = get_plan_type(prefix) return config.get("registry_file") except Exception as e: @@ -73,6 +76,7 @@ def _find_plan_across_registries(plan_key: str, load_registry_fn: Any) -> str | """ try: from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types # type: ignore[import-not-found] + for _type_key, config in discover_plan_types().items(): reg_file = config.get("registry_file") if not reg_file: @@ -93,14 +97,12 @@ def _find_plan_across_registries(plan_key: str, load_registry_fn: Any) -> str | # HELPER # ============================================= + def _spawn_background_runner(): """Spawn post_close_runner.py as a fully detached background process""" bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py" subprocess.Popen( - [sys.executable, str(bg_runner)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True + [sys.executable, str(bg_runner)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True ) @@ -108,20 +110,25 @@ def _spawn_background_runner(): # CLOSE PLAN IMPLEMENTATION # ============================================= -def close_plan_impl(plan_num: Any = None, confirm: bool = False, - all_plans: bool = False, spawn_background: bool = True, - dry_run: bool = False, - # Dependencies injected from module - normalize_plan_number: Any = None, - load_registry: Any = None, - save_registry: Any = None, - validate_plan_exists: Any = None, - confirm_plan_deletion: Any = None, - is_template_content: Any = None, - update_dashboard_local: Any = None, - push_to_plans_central: Any = None, - push_flow_to_branch_dashboard: Any = None, - close_all_plans_fn: Any = None) -> Dict[str, Any]: + +def close_plan_impl( + plan_num: Any = None, + confirm: bool = False, + all_plans: bool = False, + spawn_background: bool = True, + dry_run: bool = False, + # Dependencies injected from module + normalize_plan_number: Any = None, + load_registry: Any = None, + save_registry: Any = None, + validate_plan_exists: Any = None, + confirm_plan_deletion: Any = None, + is_template_content: Any = None, + update_dashboard_local: Any = None, + push_to_plans_central: Any = None, + push_flow_to_branch_dashboard: Any = None, + close_all_plans_fn: Any = None, +) -> Dict[str, Any]: """ Implement plan closure workflow @@ -217,15 +224,21 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, } # 4. IDEMPOTENCY CHECK: Prevent double-closing (with orphan cleanup) - if plan_info['status'] == 'closed': - closed_date = plan_info.get('closed', 'unknown') + if plan_info["status"] == "closed": + closed_date = plan_info.get("closed", "unknown") # Check if .md file is orphaned on disk (registry-closed but file never moved) if plan_file.exists(): - messages.append({"type": "warning", "text": f"{plan_label} already closed on {closed_date} — orphaned .md file detected"}) + messages.append( + { + "type": "warning", + "text": f"{plan_label} already closed on {closed_date} — orphaned .md file detected", + } + ) messages.append({"type": "dim", "text": f" Cleaning up: moving {plan_file.name} to processed_plans/"}) try: from aipass.flow.apps.handlers.mbank.process import archive_plan + if archive_plan(plan_file): logger.info(f"[{MODULE_NAME}] Cleaned up orphaned file for {plan_label}: {plan_file}") # Update registry flags that were missed on the failed first close @@ -240,7 +253,9 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, messages.append({"type": "success", "text": " Orphaned file archived successfully"}) else: logger.warning(f"[{MODULE_NAME}] Failed to archive orphaned file for {plan_label}: {plan_file}") - messages.append({"type": "error_text", "text": " Failed to move orphaned file — manual cleanup required"}) + messages.append( + {"type": "error_text", "text": " Failed to move orphaned file — manual cleanup required"} + ) except Exception as e: logger.warning(f"[{MODULE_NAME}] Error cleaning orphaned file for {plan_label}: {e}") messages.append({"type": "error_text", "text": f" Error during cleanup: {e}"}) @@ -263,11 +278,13 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, # --- Step 1/5: Template check (may fast-delete) --- messages.append({"type": "step", "text": "[1/5] Checking template status..."}) try: - with open(plan_file, 'r', encoding='utf-8') as f: + with open(plan_file, "r", encoding="utf-8") as f: content = f.read() if is_template_content(content): - messages.append({"type": "warning", "text": f" {plan_label} is empty template - fast-deleting (not archiving)"}) + messages.append( + {"type": "warning", "text": f" {plan_label} is empty template - fast-deleting (not archiving)"} + ) # Delete the file plan_file.unlink() @@ -281,7 +298,9 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, save_registry(registry) logger.info(f"[{MODULE_NAME}] Removed {plan_label} from registry") - messages.append({"type": "success", "text": f" Empty template deleted - {plan_label} removed from system"}) + messages.append( + {"type": "success", "text": f" Empty template deleted - {plan_label} removed from system"} + ) return { "success": True, "messages": messages, @@ -294,7 +313,9 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, messages.append({"type": "warning", "text": " Plan file not found, continuing with registry close"}) except Exception as e: logger.warning(f"[{MODULE_NAME}] Template check failed: {e}") - messages.append({"type": "warning", "text": " Could not check template status, continuing with normal close"}) + messages.append( + {"type": "warning", "text": " Could not check template status, continuing with normal close"} + ) # DISPLAY: plan info header messages.append({"type": "header", "plan_key": plan_key, "plan_info": plan_info, "prefix": plan_prefix}) @@ -314,8 +335,8 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, messages.append({"type": "step", "text": "[2/5] Marking plan as closed..."}) try: # CRITICAL: Close ALWAYS succeeds from this point. Archive is non-blocking. - plan_info['status'] = 'closed' - plan_info['closed'] = datetime.now(timezone.utc).isoformat() + plan_info["status"] = "closed" + plan_info["closed"] = datetime.now(timezone.utc).isoformat() if reg_file: save_registry(registry, registry_file=reg_file) else: @@ -335,6 +356,7 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, messages.append({"type": "step", "text": "[3/5] Archiving plan..."}) try: from aipass.flow.apps.handlers.mbank.process import archive_plan + archive_success = archive_plan(plan_file) if archive_success: # Set flags on same registry object we already have in memory @@ -360,7 +382,8 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, try: subprocess.run( ["drone", "@memory", "process-plans"], - capture_output=True, timeout=30, + capture_output=True, + timeout=30, ) except Exception as e: logger.warning(f"[{MODULE_NAME}] Best-effort drone @memory process-plans failed: {e}") @@ -368,6 +391,7 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, # Verify vectorization via memory's verify module try: from aipass.memory.apps.modules.verify import is_plan_vectorized # type: ignore[import-not-found] + result = is_plan_vectorized(plan_label) if result.get("found"): chunk_count = result.get("count", 0) @@ -400,7 +424,9 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, if plan_location: branch_dashboard_success = push_flow_to_branch_dashboard(Path(plan_location)) if not branch_dashboard_success: - logger.warning(f"[{MODULE_NAME}] Failed to push flow section to branch dashboard at {plan_location}") + logger.warning( + f"[{MODULE_NAME}] Failed to push flow section to branch dashboard at {plan_location}" + ) except Exception as e: logger.warning(f"[{MODULE_NAME}] Dashboard update error: {e}") messages.append({"type": "warning", "text": f" Dashboard update failed (non-critical): {e}"}) @@ -412,6 +438,7 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, # Append to branch's CLOSED_PLANS.local.json try: from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans + append_to_closed_plans(plan_key, plan_info, plan_file.parent) except Exception as e: logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}") @@ -419,7 +446,8 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, # Fire trigger event for plan closure try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('plan_closed', plan_number=plan_key, location=str(plan_file.parent)) + + trigger.fire("plan_closed", plan_number=plan_key, location=str(plan_file.parent)) except ImportError: logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire") except Exception as e: @@ -452,10 +480,13 @@ def close_plan_impl(plan_num: Any = None, confirm: bool = False, } -def close_all_plans_impl(confirm: bool = False, dry_run: bool = False, - # Dependencies injected from module - get_open_plans: Any = None, - close_plan_fn: Any = None) -> Dict[str, Any]: +def close_all_plans_impl( + confirm: bool = False, + dry_run: bool = False, + # Dependencies injected from module + get_open_plans: Any = None, + close_plan_fn: Any = None, +) -> Dict[str, Any]: """ Close all open plans in one operation @@ -570,18 +601,25 @@ def close_all_plans_impl(confirm: bool = False, dry_run: bool = False, messages.append({"type": "dim", "text": f"Background processing started for {success_count} plan(s)"}) except Exception as e: logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}") - messages.append({"type": "warning", "text": "Background processing failed to start - will retry on next close"}) + messages.append( + {"type": "warning", "text": "Background processing failed to start - will retry on next close"} + ) # Summary - messages.append({ - "type": "close_all_summary", - "success_count": success_count, - "failure_count": failure_count, - "total": len(open_plans), - }) + messages.append( + { + "type": "close_all_summary", + "success_count": success_count, + "failure_count": failure_count, + "total": len(open_plans), + } + ) logger.info(f"[{MODULE_NAME}] close_all completed: {success_count} success, {failure_count} failures") - json_handler.log_operation("all_plans_closed", {"success_count": success_count, "failure_count": failure_count, "total": len(open_plans)}) + json_handler.log_operation( + "all_plans_closed", + {"success_count": success_count, "failure_count": failure_count, "total": len(open_plans)}, + ) return { "success": success_count > 0, "messages": messages, diff --git a/src/aipass/flow/apps/handlers/plan/command_parser.py b/src/aipass/flow/apps/handlers/plan/command_parser.py index d51f7e4a..9ff7ba47 100644 --- a/src/aipass/flow/apps/handlers/plan/command_parser.py +++ b/src/aipass/flow/apps/handlers/plan/command_parser.py @@ -61,13 +61,16 @@ def parse_create_plan_args(args: List[str]) -> Tuple[str | None, str, str]: # Map raw type argument to plan_type_key via registry try: from aipass.flow.apps.handlers.template.registry_ops import get_type_map + type_map = get_type_map() except Exception as e: logger.warning(f"[{MODULE_NAME}] Failed to load type map from registry_ops, using defaults: {e}") type_map = {"default": "flow_plans", "dplan": "dev_plans"} plan_type_key = type_map.get(raw_type.lower(), raw_type) - json_handler.log_operation("create_args_parsed", {"location": location, "subject": subject, "plan_type_key": plan_type_key}) + json_handler.log_operation( + "create_args_parsed", {"location": location, "subject": subject, "plan_type_key": plan_type_key} + ) return location, subject, plan_type_key @@ -120,15 +123,15 @@ def parse_close_command_args(args: List[str]) -> Tuple[str | None, bool, bool, b (None, False, False, False, "Plan number or --all required") """ # Check for --all flag - all_plans = '--all' in args + all_plans = "--all" in args # Default: auto-confirm (confirm=False means no prompt) # --confirm or --interactive explicitly requests a prompt # --yes/-y kept for backwards compat (redundant, already auto-confirms) - confirm = '--confirm' in args or '--interactive' in args + confirm = "--confirm" in args or "--interactive" in args # Check for --dry-run or --preview flag - dry_run = '--dry-run' in args or '--preview' in args + dry_run = "--dry-run" in args or "--preview" in args # If --all, plan_num is None if all_plans: @@ -136,7 +139,7 @@ def parse_close_command_args(args: List[str]) -> Tuple[str | None, bool, bool, b # Otherwise, need plan number # Filter out flag args to find the plan number - non_flag_args = [a for a in args if not a.startswith('--') and a not in ('-y',)] + non_flag_args = [a for a in args if not a.startswith("--") and a not in ("-y",)] if not non_flag_args: return None, False, False, dry_run, "Plan number or --all required" diff --git a/src/aipass/flow/apps/handlers/plan/confirmation.py b/src/aipass/flow/apps/handlers/plan/confirmation.py index af1e4277..8590fedc 100644 --- a/src/aipass/flow/apps/handlers/plan/confirmation.py +++ b/src/aipass/flow/apps/handlers/plan/confirmation.py @@ -51,7 +51,7 @@ def confirm_plan_deletion(plan_key: str) -> bool: try: response = input(f"Close FPLAN-{plan_key}? (yes/no): ").strip().lower() - return response in ['yes', 'y'] + return response in ["yes", "y"] except EOFError: # Fallback for edge cases where isatty() returns True but input fails logger.warning(f"[confirmation] EOFError reading input for plan {plan_key} deletion, auto-confirming") diff --git a/src/aipass/flow/apps/handlers/plan/create_file.py b/src/aipass/flow/apps/handlers/plan/create_file.py index 5056a350..7b191564 100644 --- a/src/aipass/flow/apps/handlers/plan/create_file.py +++ b/src/aipass/flow/apps/handlers/plan/create_file.py @@ -19,10 +19,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.flow.apps.handlers.json import json_handler -def create_plan_file( - plan_file: Path, - content: str -) -> Tuple[bool, str]: +def create_plan_file(plan_file: Path, content: str) -> Tuple[bool, str]: """ Create plan file with validation @@ -57,7 +54,7 @@ def create_plan_file( # Create file try: - with open(plan_file, 'w', encoding='utf-8') as f: + with open(plan_file, "w", encoding="utf-8") as f: f.write(content) json_handler.log_operation("plan_file_created", {"file_path": str(plan_file), "success": True}) return True, "" diff --git a/src/aipass/flow/apps/handlers/plan/create_ops.py b/src/aipass/flow/apps/handlers/plan/create_ops.py index 4297c52f..0927e377 100644 --- a/src/aipass/flow/apps/handlers/plan/create_ops.py +++ b/src/aipass/flow/apps/handlers/plan/create_ops.py @@ -24,6 +24,7 @@ from datetime import datetime from typing import Callable, Tuple, List, Dict, Any from aipass.prax import logger + # logger imported from aipass.prax from aipass.flow.apps.handlers.json import json_handler @@ -39,17 +40,19 @@ MODULE_NAME = "create_plan" # HELPERS # ============================================= + def slugify_subject(subject: str, max_length: int = 40) -> str: """Sanitize subject for filename: lowercase, underscores, max *max_length* chars.""" - slug = re.sub(r'[^\w\s-]', '', subject.lower()) - slug = re.sub(r'[\s-]+', '_', slug) - return slug.strip('_')[:max_length] + slug = re.sub(r"[^\w\s-]", "", subject.lower()) + slug = re.sub(r"[\s-]+", "_", slug) + return slug.strip("_")[:max_length] # ============================================= # CREATE PLAN IMPLEMENTATION # ============================================= + def create_plan_impl( location: str | None = None, subject: str = "", @@ -186,10 +189,7 @@ def create_plan_impl( template_path = available[0] elif len(available) > 1: names = [p.stem for p in available] - error_msg = ( - f"Multiple templates in {tmpl_dir.name}/. " - f"Specify which one: {names}" - ) + error_msg = f"Multiple templates in {tmpl_dir.name}/. Specify which one: {names}" return False, 0, "", "", error_msg, [] else: error_msg = f"No templates found in {tmpl_dir.name}/" @@ -243,7 +243,9 @@ def create_plan_impl( # STEP 11b: Push flow section to branch's dashboard via write-through branch_dashboard_success = push_flow_to_branch_dashboard(target_dir) if not branch_dashboard_success: - messages.append({"type": "dim", "text": f"No branch dashboard at {target_dir} -- no branch is tracking this plan"}) + messages.append( + {"type": "dim", "text": f"No branch dashboard at {target_dir} -- no branch is tracking this plan"} + ) # STEP 12: Log success plan_id = f"{prefix}-{formatted_num}" @@ -251,19 +253,27 @@ def create_plan_impl( # Build display message display_msg = display_plan_created( - NEXT_NUM, RELATIVE_LOCATION, subject, template_type, - prefix=prefix, digits=digits, + NEXT_NUM, + RELATIVE_LOCATION, + subject, + template_type, + prefix=prefix, + digits=digits, ) messages.append({"type": "display", "text": display_msg}) # Fire trigger event for plan creation try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('plan_created', plan_number=NEXT_NUM, location=RELATIVE_LOCATION, subject=subject) + + trigger.fire("plan_created", plan_number=NEXT_NUM, location=RELATIVE_LOCATION, subject=subject) except ImportError: logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping plan_created event") - json_handler.log_operation("plan_created", {"plan_number": NEXT_NUM, "location": RELATIVE_LOCATION, "template": template_type, "success": True}) + json_handler.log_operation( + "plan_created", + {"plan_number": NEXT_NUM, "location": RELATIVE_LOCATION, "template": template_type, "success": True}, + ) return True, NEXT_NUM, RELATIVE_LOCATION, template_type, "", messages except Exception as e: diff --git a/src/aipass/flow/apps/handlers/plan/display.py b/src/aipass/flow/apps/handlers/plan/display.py index 013e8bd2..5e096952 100644 --- a/src/aipass/flow/apps/handlers/plan/display.py +++ b/src/aipass/flow/apps/handlers/plan/display.py @@ -22,6 +22,7 @@ from aipass.flow.apps.handlers.json import json_handler # CREATE PLAN DISPLAY FUNCTIONS + def display_plan_created( plan_num: int, relative_location: str, @@ -48,9 +49,11 @@ def display_plan_created( lines = [ f"[FLOW] Created {plan_id} in {relative_location}", f"[FLOW] Template: {template_type}", - f"[FLOW] Subject: {subject}" + f"[FLOW] Subject: {subject}", ] - json_handler.log_operation("plan_displayed", {"plan_id": plan_id, "location": relative_location, "template": template_type}) + json_handler.log_operation( + "plan_displayed", {"plan_id": plan_id, "location": relative_location, "template": template_type} + ) return "\n".join(lines) @@ -87,6 +90,7 @@ def display_plan_result( # DELETE PLAN DISPLAY FUNCTIONS + def format_plan_deletion_header(plan_key: str, plan_info: Dict[str, Any], prefix: str = "FPLAN") -> str: """ Format plan information header for deletion confirmation @@ -111,7 +115,7 @@ def format_plan_deletion_header(plan_key: str, plan_info: Dict[str, Any], prefix f" [dim]File:[/dim] {plan_file}", "", "[dim]─" + "─" * 68 + "[/dim]", - "" + "", ] return "\n".join(lines) @@ -175,18 +179,13 @@ def format_delete_usage_error() -> str: Returns: Formatted usage instructions """ - lines = [ - "", - "ERROR: Plan number required", - "", - "Usage: delete [--yes]", - "" - ] + lines = ["", "ERROR: Plan number required", "", "Usage: delete [--yes]", ""] return "\n".join(lines) # RESTORE PLAN DISPLAY FUNCTIONS + def format_restore_header(plan_key: str, plan_info: Dict[str, Any], prefix: str = "FPLAN") -> str: """ Format plan information header for restore confirmation @@ -215,7 +214,7 @@ def format_restore_header(plan_key: str, plan_info: Dict[str, Any], prefix: str f" [dim]File:[/dim] {plan_file}", "", "[dim]─" + "─" * 68 + "[/dim]", - "" + "", ] return "\n".join(lines) @@ -237,7 +236,9 @@ def format_restore_success(plan_key: str, restored_location: str | None = None, return f"\n[SUCCESS] {prefix}-{plan_key} restored to open status\n" -def format_restore_error(error_type: str, plan_key: str | None = None, details: str | None = None, prefix: str = "FPLAN") -> str: +def format_restore_error( + error_type: str, plan_key: str | None = None, details: str | None = None, prefix: str = "FPLAN" +) -> str: """ Format error messages for restore operations @@ -271,18 +272,13 @@ def format_restore_usage_error() -> str: Returns: Formatted usage instructions """ - lines = [ - "", - "ERROR: Plan number required", - "", - "Usage: restore ", - "" - ] + lines = ["", "ERROR: Plan number required", "", "Usage: restore ", ""] return "\n".join(lines) # LIST PLAN DISPLAY FUNCTIONS + def format_plan_info(plan_key: str, plan_info: Dict[str, Any], prefix: str = "FPLAN") -> str: """ Format a single plan's information for display @@ -310,7 +306,7 @@ def format_plan_info(plan_key: str, plan_info: Dict[str, Any], prefix: str = "FP # Format created date if it's an ISO timestamp if created != "unknown": try: - dt = datetime.fromisoformat(created.replace('Z', '+00:00')) + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) created = dt.strftime("%Y-%m-%d %H:%M") except (ValueError, AttributeError) as e: logger.warning(f"[display] Failed to parse created date '{created}': {e}") @@ -321,9 +317,7 @@ def format_plan_info(plan_key: str, plan_info: Dict[str, Any], prefix: str = "FP def format_plans_list( - plans: Dict[str, Dict[str, Any]], - filter_status: str | None = None, - show_header: bool = True + plans: Dict[str, Dict[str, Any]], filter_status: str | None = None, show_header: bool = True ) -> str: """ Format multiple plans for display with optional filtering @@ -341,10 +335,7 @@ def format_plans_list( # Filter plans if needed if filter_status: - filtered_plans = { - k: v for k, v in plans.items() - if v.get("status") == filter_status - } + filtered_plans = {k: v for k, v in plans.items() if v.get("status") == filter_status} else: filtered_plans = plans @@ -390,10 +381,10 @@ def format_statistics_summary(stats: Dict[str, Any]) -> str: "[bold]Summary:[/bold]", f" Total plans: {stats['total_plans']}", f" Open: {stats['open_plans']}", - f" Closed: {stats['closed_plans']}" + f" Closed: {stats['closed_plans']}", ] - if stats['other_plans'] > 0: + if stats["other_plans"] > 0: lines.append(f" Other: {stats['other_plans']}") lines.append("") diff --git a/src/aipass/flow/apps/handlers/plan/get_closed_plans.py b/src/aipass/flow/apps/handlers/plan/get_closed_plans.py index 2d6b3e6d..f85d0b4f 100644 --- a/src/aipass/flow/apps/handlers/plan/get_closed_plans.py +++ b/src/aipass/flow/apps/handlers/plan/get_closed_plans.py @@ -44,6 +44,7 @@ def _get_all_registry_files() -> List[str]: """ try: from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types + files: List[str] = [] for _key, config in discover_plan_types().items(): rf = config.get("registry_file") @@ -60,6 +61,7 @@ def _get_all_registry_files() -> List[str]: # HANDLER FUNCTION # ============================================= + def get_closed_plans() -> List[Tuple[str, Dict[str, Any]]]: """ Get all closed plans from ALL discovered registries diff --git a/src/aipass/flow/apps/handlers/plan/get_open_plans.py b/src/aipass/flow/apps/handlers/plan/get_open_plans.py index f4dc77d4..a90a207e 100644 --- a/src/aipass/flow/apps/handlers/plan/get_open_plans.py +++ b/src/aipass/flow/apps/handlers/plan/get_open_plans.py @@ -34,10 +34,12 @@ from aipass.flow.apps.handlers.json import json_handler # HANDLER FUNCTION # ============================================= + def _get_all_registry_files() -> List[str]: """Return per-type registry filenames via plan-type discovery.""" try: from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types # type: ignore[import-not-found] + files: List[str] = [] for _key, config in discover_plan_types().items(): rf = config.get("registry_file") diff --git a/src/aipass/flow/apps/handlers/plan/list_ops.py b/src/aipass/flow/apps/handlers/plan/list_ops.py index 754eeccf..01f675d0 100644 --- a/src/aipass/flow/apps/handlers/plan/list_ops.py +++ b/src/aipass/flow/apps/handlers/plan/list_ops.py @@ -38,6 +38,7 @@ def _get_all_registry_info() -> Tuple[list[str], Dict[str, str]]: """ try: from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types # type: ignore[import-not-found] + files: list[str] = [] prefix_map: Dict[str, str] = {} for _key, config in discover_plan_types().items(): @@ -57,6 +58,7 @@ def _get_all_registry_info() -> Tuple[list[str], Dict[str, str]]: # LIST PLANS IMPLEMENTATION # ============================================= + def list_plans_impl( filter_type: str = "open", # Dependencies injected from module @@ -132,7 +134,9 @@ def list_plans_impl( # STEP 5: Log success logger.info(f"[{MODULE_NAME}] Listed plans (filter: {filter_type})") - json_handler.log_operation("plans_listed", {"filter_type": filter_type, "count": len(merged_plans), "success": True}) + json_handler.log_operation( + "plans_listed", {"filter_type": filter_type, "count": len(merged_plans), "success": True} + ) return { "success": True, diff --git a/src/aipass/flow/apps/handlers/plan/resolve_location.py b/src/aipass/flow/apps/handlers/plan/resolve_location.py index 6bb97024..b7b2750f 100644 --- a/src/aipass/flow/apps/handlers/plan/resolve_location.py +++ b/src/aipass/flow/apps/handlers/plan/resolve_location.py @@ -35,10 +35,7 @@ def _get_caller_cwd() -> Path: return Path.cwd() -def resolve_plan_location( - location: str | None, - ecosystem_root: Path -) -> Tuple[bool, Path, str]: +def resolve_plan_location(location: str | None, ecosystem_root: Path) -> Tuple[bool, Path, str]: """ Resolve plan location relative to the CALLER's directory. diff --git a/src/aipass/flow/apps/handlers/plan/restore_ops.py b/src/aipass/flow/apps/handlers/plan/restore_ops.py index ced17e7f..3f140a02 100644 --- a/src/aipass/flow/apps/handlers/plan/restore_ops.py +++ b/src/aipass/flow/apps/handlers/plan/restore_ops.py @@ -24,6 +24,7 @@ from datetime import datetime, timezone from typing import Dict, Any, List from aipass.prax import logger + # logger imported from aipass.prax from aipass.flow.apps.handlers.json import json_handler @@ -34,6 +35,7 @@ from aipass.flow.apps.handlers.json import json_handler _PKG_ROOT = Path(__file__).resolve().parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" + def _find_repo_root() -> Path: """Walk up to find the repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -42,6 +44,7 @@ def _find_repo_root() -> Path: return parent return Path.cwd() + PROCESSED_PLANS_DIR = _find_repo_root() / ".backup" / "processed_plans" MODULE_NAME = "restore_plan" @@ -51,6 +54,7 @@ MODULE_NAME = "restore_plan" # RECOVERY IMPLEMENTATION # ============================================= + def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_registry: Any = None) -> tuple[bool, str]: """ Attempt to recover a plan from processed_plans backup. @@ -81,12 +85,12 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi # Read plan file to extract original location from header try: - with open(plan_file, 'r', encoding='utf-8') as f: + with open(plan_file, "r", encoding="utf-8") as f: content = f.read() # Parse location from header (e.g., "**Location**: /path/to/dir") original_location = None - for line in content.split('\n')[:20]: # Check first 20 lines + for line in content.split("\n")[:20]: # Check first 20 lines if line.startswith("**Location**:"): original_location = line.split("**Location**:")[1].strip() break @@ -97,7 +101,7 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi # CRITICAL: Convert relative paths to absolute paths # If location is relative (like "flow"), resolve it - if not original_location.startswith('/'): + if not original_location.startswith("/"): # Relative path - resolve against _PKG_ROOT if original_location == "flow": original_location = str(FLOW_ROOT) @@ -124,7 +128,9 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi relative_path = str(original_path) except Exception as e: - logger.warning(f"[{MODULE_NAME}] Failed to parse plan file '{plan_file}' for recovery, defaulting to FLOW_ROOT: {e}") + logger.warning( + f"[{MODULE_NAME}] Failed to parse plan file '{plan_file}' for recovery, defaulting to FLOW_ROOT: {e}" + ) original_location = str(FLOW_ROOT) relative_path = "flow" @@ -146,7 +152,7 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi "subject": "Recovered from backup", "closed": datetime.now(timezone.utc).isoformat(), "closed_reason": "recovered_from_backup", - "template_type": "default" + "template_type": "default", } save_registry(registry) @@ -157,6 +163,7 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi # RESTORE PLAN IMPLEMENTATION # ============================================= + def restore_plan_impl( plan_num: str | None = None, # Dependencies injected from module @@ -258,14 +265,14 @@ def restore_plan_impl( messages.append({"type": "restore_header", "plan_key": plan_key, "plan_info": plan_info}) # 7. UPDATE REGISTRY: Restore to open status - plan_info['status'] = 'open' + plan_info["status"] = "open" # Remove all close-related metadata - plan_info.pop('closed', None) - plan_info.pop('closed_reason', None) - plan_info.pop('memory_created', None) - plan_info.pop('memory_created_date', None) - plan_info.pop('memory_file', None) + plan_info.pop("closed", None) + plan_info.pop("closed_reason", None) + plan_info.pop("memory_created", None) + plan_info.pop("memory_created_date", None) + plan_info.pop("memory_file", None) save_registry(registry) logger.info(f"[{MODULE_NAME}] Restored plan {plan_key} to open status") @@ -287,11 +294,14 @@ def restore_plan_impl( # Fire trigger event for plan restore try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('plan_restored', plan_number=plan_key, location=restored_location) + + trigger.fire("plan_restored", plan_number=plan_key, location=restored_location) except ImportError: logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire") - json_handler.log_operation("plan_restored", {"plan_key": plan_key, "location": restored_location, "success": True}) + json_handler.log_operation( + "plan_restored", {"plan_key": plan_key, "location": restored_location, "success": True} + ) return { "success": True, "messages": messages, diff --git a/src/aipass/flow/apps/handlers/plan/validator.py b/src/aipass/flow/apps/handlers/plan/validator.py index 42364611..1851638d 100644 --- a/src/aipass/flow/apps/handlers/plan/validator.py +++ b/src/aipass/flow/apps/handlers/plan/validator.py @@ -19,7 +19,7 @@ from typing import Dict, Any, Tuple from aipass.flow.apps.handlers.json import json_handler # Matches any PREFIX- at the start (e.g. FPLAN-, DPLAN-, XPLAN-) -_PREFIX_RE = re.compile(r'^([A-Z]+PLAN)-', re.IGNORECASE) +_PREFIX_RE = re.compile(r"^([A-Z]+PLAN)-", re.IGNORECASE) def normalize_plan_number(plan_num: str) -> str: @@ -49,7 +49,7 @@ def normalize_plan_number(plan_num: str) -> str: upper = plan_num.upper().strip() m = _PREFIX_RE.match(upper) if m: - plan_num = plan_num[m.end():] + plan_num = plan_num[m.end() :] elif upper.startswith("PLAN-"): plan_num = plan_num[5:] elif upper.startswith("PLAN"): diff --git a/src/aipass/flow/apps/handlers/registry/load_registry.py b/src/aipass/flow/apps/handlers/registry/load_registry.py index 26948e1a..d7fa8d3c 100644 --- a/src/aipass/flow/apps/handlers/registry/load_registry.py +++ b/src/aipass/flow/apps/handlers/registry/load_registry.py @@ -45,6 +45,7 @@ REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json" # HANDLER FUNCTION # ============================================= + def load_registry(registry_file: str | None = None) -> Dict[str, Any]: """Load PLAN registry @@ -67,13 +68,16 @@ def load_registry(registry_file: str | None = None) -> Dict[str, Any]: return {"plans": {}, "next_number": 1} try: - with open(target, 'r', encoding='utf-8') as f: + with open(target, "r", encoding="utf-8") as f: data = json.load(f) - json_handler.log_operation("registry_loaded", { - "target_file": target.name, - "plan_count": len(data.get("plans", {})), - "success": True, - }) + json_handler.log_operation( + "registry_loaded", + { + "target_file": target.name, + "plan_count": len(data.get("plans", {})), + "success": True, + }, + ) return data except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to load registry from {target}: {e}") diff --git a/src/aipass/flow/apps/handlers/registry/monitor_ops.py b/src/aipass/flow/apps/handlers/registry/monitor_ops.py index 9e1c4730..5efdd845 100644 --- a/src/aipass/flow/apps/handlers/registry/monitor_ops.py +++ b/src/aipass/flow/apps/handlers/registry/monitor_ops.py @@ -40,27 +40,48 @@ from aipass.flow.apps.handlers.json import json_handler MODULE_NAME = "registry_monitor" # PLAN file pattern — matches any plan prefix (FPLAN, DPLAN, APLAN, RPLAN, TDPLAN, etc.) -PLAN_PATTERN = re.compile(r'^[A-Z]+PLAN-\d{4}\.md$') +PLAN_PATTERN = re.compile(r"^[A-Z]+PLAN-\d{4}\.md$") # Directories to ignore during monitoring IGNORE_FOLDERS = { # Development and version control - ".git", ".venv", "venv", "__pycache__", "node_modules", - ".pytest_cache", "dist", "build", ".idea", ".vscode", - + ".git", + ".venv", + "venv", + "__pycache__", + "node_modules", + ".pytest_cache", + "dist", + "build", + ".idea", + ".vscode", # Backup and archive - "backup", "backups", ".backup", "archive", ".archive", - "backup", "archive_temp", "processed_plans", - + "backup", + "backups", + ".backup", + "archive", + ".archive", + "backup", + "archive_temp", + "processed_plans", # Memory and admin - "memory", "admin", "aipass-help", - + "memory", + "admin", + "aipass-help", # User directories - ".local", "Downloads", "downloads", - + ".local", + "Downloads", + "downloads", # System directories (permission issues) - "proc", "sys", "dev", "run", "boot", "lost+found", - "timeshift", "snapshots", ".snapshots" + "proc", + "sys", + "dev", + "run", + "boot", + "lost+found", + "timeshift", + "snapshots", + ".snapshots", } # Global observer instance @@ -76,6 +97,7 @@ DEDUPE_WINDOW = 2.0 # seconds # FILE WATCHER CLASS # ============================================= + class PlanFileWatcher(FileSystemEventHandler): """Monitors PLAN file changes and fires trigger events""" @@ -116,7 +138,7 @@ class PlanFileWatcher(FileSystemEventHandler): def _get_plan_number(self, file_path: Path) -> Optional[str]: """Extract plan number from filename (e.g., FPLAN-0001.md -> 0001, DPLAN-0005.md -> 0005)""" - match = re.search(r'[A-Z]+PLAN-(\d{4})\.md$', file_path.name) + match = re.search(r"[A-Z]+PLAN-(\d{4})\.md$", file_path.name) return match.group(1) if match else None def _is_duplicate_event(self, event_type: str, plan_num: str) -> bool: @@ -125,8 +147,7 @@ class PlanFileWatcher(FileSystemEventHandler): now = time.time() # Clean old events - _recent_events = [(et, pn, ts) for et, pn, ts in _recent_events - if now - ts < DEDUPE_WINDOW] + _recent_events = [(et, pn, ts) for et, pn, ts in _recent_events if now - ts < DEDUPE_WINDOW] # Check for duplicates for et, pn, ts in _recent_events: @@ -156,31 +177,41 @@ class PlanFileWatcher(FileSystemEventHandler): """Fire plan_file_created event - Trigger handles registry update""" try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('plan_file_created', path=str(file_path)) + + trigger.fire("plan_file_created", path=str(file_path)) except ImportError: - logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_created event not fired for {file_path.name}") + logger.warning( + f"[{MODULE_NAME}] Trigger not available - plan_file_created event not fired for {file_path.name}" + ) def _fire_plan_file_deleted(self, file_path: Path): """Fire plan_file_deleted event - Trigger handles registry update""" try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('plan_file_deleted', path=str(file_path)) + + trigger.fire("plan_file_deleted", path=str(file_path)) except ImportError: - logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_deleted event not fired for {file_path.name}") + logger.warning( + f"[{MODULE_NAME}] Trigger not available - plan_file_deleted event not fired for {file_path.name}" + ) def _fire_plan_file_moved(self, src_path: Path, dest_path: Path): """Fire plan_file_moved event - Trigger handles registry update""" try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('plan_file_moved', src_path=str(src_path), dest_path=str(dest_path)) + + trigger.fire("plan_file_moved", src_path=str(src_path), dest_path=str(dest_path)) except ImportError: - logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_moved event not fired for {dest_path.name}") + logger.warning( + f"[{MODULE_NAME}] Trigger not available - plan_file_moved event not fired for {dest_path.name}" + ) # ============================================= # HELPER # ============================================= + def _fire_event(event_name: str, **kwargs) -> bool: """ Fire a trigger event (internal helper) @@ -194,6 +225,7 @@ def _fire_event(event_name: str, **kwargs) -> bool: """ try: from aipass.trigger.apps.modules.core import trigger + trigger.fire(event_name, **kwargs) return True except ImportError: @@ -205,7 +237,10 @@ def _fire_event(event_name: str, **kwargs) -> bool: # SCAN AND HEAL IMPLEMENTATION # ============================================= -def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[str, Any]] = lambda: {"plans": {}}) -> Dict[str, Any]: + +def scan_plan_files_impl( + ecosystem_root: Path, load_registry: Callable[[], Dict[str, Any]] = lambda: {"plans": {}} +) -> Dict[str, Any]: """ Scan ecosystem for PLAN files and fire events to heal registry @@ -247,7 +282,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ for filename in files: if PLAN_PATTERN.match(filename): file_path = Path(root) / filename - match = re.search(r'[A-Z]+PLAN-(\d{4})\.md$', filename) + match = re.search(r"[A-Z]+PLAN-(\d{4})\.md$", filename) if match: plan_number = match.group(1) @@ -274,7 +309,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ for dup_path in paths[1:]: # Skip first path (already in plan_files) old_name = dup_path.name # Preserve original plan prefix (FPLAN, DPLAN, TDPLAN, etc.) - prefix_match = re.match(r'^([A-Z]+PLAN)', old_name) + prefix_match = re.match(r"^([A-Z]+PLAN)", old_name) dup_prefix = prefix_match.group(1) if prefix_match else "FPLAN" new_num = f"{next_available:04d}" new_name = f"{dup_prefix}-{new_num}.md" @@ -287,11 +322,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ # Add to plan_files with new number plan_files[new_num] = new_path - renumbered.append({ - "old_number": plan_num, - "new_number": new_num, - "path": str(new_path) - }) + renumbered.append({"old_number": plan_num, "new_number": new_num, "path": str(new_path)}) next_available += 1 except Exception as e: @@ -310,7 +341,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ for plan_number, file_path in plan_files.items(): if plan_number not in plans: # File exists but not in registry - fire created event - if _fire_event('plan_file_created', path=str(file_path)): + if _fire_event("plan_file_created", path=str(file_path)): added.append(plan_number) logger.info(f"[{MODULE_NAME}] Fired plan_file_created for {file_path.name}") else: @@ -318,7 +349,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ current_path = plans[plan_number].get("file_path", "") if current_path != str(file_path): # Fire moved event - if _fire_event('plan_file_moved', src_path=current_path, dest_path=str(file_path)): + if _fire_event("plan_file_moved", src_path=current_path, dest_path=str(file_path)): updated.append(plan_number) logger.info(f"[{MODULE_NAME}] Fired plan_file_moved for {file_path.name}") @@ -328,13 +359,15 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ # Registry entry but no file - fire deleted event orphan_path = plans[plan_number].get("file_path", "") orphan_name = Path(orphan_path).name if orphan_path else f"PLAN-{plan_number}.md" - if _fire_event('plan_file_deleted', path=orphan_path or f"PLAN-{plan_number}.md"): + if _fire_event("plan_file_deleted", path=orphan_path or f"PLAN-{plan_number}.md"): removed.append(plan_number) logger.info(f"[{MODULE_NAME}] Fired plan_file_deleted for {orphan_name}") # Log event results if added or updated or removed or renumbered: - logger.info(f"[{MODULE_NAME}] Events fired - Created: {len(added)}, Moved: {len(updated)}, Deleted: {len(removed)}, Renumbered: {len(renumbered)}") + logger.info( + f"[{MODULE_NAME}] Events fired - Created: {len(added)}, Moved: {len(updated)}, Deleted: {len(removed)}, Renumbered: {len(renumbered)}" + ) # Reload registry to get updated count (after handlers processed events) registry = load_registry() @@ -342,14 +375,17 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ logger.info(f"[{MODULE_NAME}] Scan complete - {total_plans} PLAN files in registry") - json_handler.log_operation("plan_files_scanned", { - "total_plans": total_plans, - "added": len(added), - "updated": len(updated), - "removed": len(removed), - "renumbered": len(renumbered), - "success": True, - }) + json_handler.log_operation( + "plan_files_scanned", + { + "total_plans": total_plans, + "added": len(added), + "updated": len(updated), + "removed": len(removed), + "renumbered": len(renumbered), + "success": True, + }, + ) return { "total_plans": total_plans, @@ -357,7 +393,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ "updated": updated, "removed": removed, "renumbered": renumbered, - "healing_performed": len(added) + len(updated) + len(removed) + len(renumbered) > 0 + "healing_performed": len(added) + len(updated) + len(removed) + len(renumbered) > 0, } @@ -365,6 +401,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ # MONITOR CONTROL IMPLEMENTATIONS # ============================================= + def start_monitoring_impl(ecosystem_root: Path) -> Dict[str, Any]: """Start PLAN file monitoring with watchdog @@ -414,7 +451,9 @@ def stop_monitoring_impl() -> Dict[str, Any]: return {"success": False, "message": "Monitor is not running", "status": "not_running"} -def get_status_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[str, Any]] = lambda: {"plans": {}}) -> Dict[str, Any]: +def get_status_impl( + ecosystem_root: Path, load_registry: Callable[[], Dict[str, Any]] = lambda: {"plans": {}} +) -> Dict[str, Any]: """Get monitoring status Args: @@ -440,5 +479,5 @@ def get_status_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[str, "watch_location": str(ecosystem_root), "total_plans": total_plans, "open_plans": open_plans, - "ignore_folders": len(IGNORE_FOLDERS) + "ignore_folders": len(IGNORE_FOLDERS), } diff --git a/src/aipass/flow/apps/handlers/registry/save_registry.py b/src/aipass/flow/apps/handlers/registry/save_registry.py index 129a31e6..bd8fd5c1 100644 --- a/src/aipass/flow/apps/handlers/registry/save_registry.py +++ b/src/aipass/flow/apps/handlers/registry/save_registry.py @@ -48,6 +48,7 @@ REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json" # HANDLER FUNCTION # ============================================= + def save_registry(registry: Dict[str, Any], registry_file: str | None = None) -> bool: """Save PLAN registry @@ -69,13 +70,16 @@ def save_registry(registry: Dict[str, Any], registry_file: str | None = None) -> try: FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) registry["last_updated"] = datetime.now(timezone.utc).isoformat() - with open(target, 'w', encoding='utf-8') as f: + with open(target, "w", encoding="utf-8") as f: json.dump(registry, f, indent=2, ensure_ascii=False) - json_handler.log_operation("registry_saved", { - "target_file": target.name, - "plan_count": len(registry.get("plans", {})), - "success": True, - }) + json_handler.log_operation( + "registry_saved", + { + "target_file": target.name, + "plan_count": len(registry.get("plans", {})), + "success": True, + }, + ) return True except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to save registry to {target}: {e}") diff --git a/src/aipass/flow/apps/handlers/registry/statistics.py b/src/aipass/flow/apps/handlers/registry/statistics.py index 6096cae6..ae8a36fd 100644 --- a/src/aipass/flow/apps/handlers/registry/statistics.py +++ b/src/aipass/flow/apps/handlers/registry/statistics.py @@ -39,6 +39,7 @@ _PKG_ROOT = Path(__file__).resolve().parents[4] # HANDLER FUNCTION # ============================================= + def get_registry_statistics(registry: Dict[str, Any]) -> Dict[str, Any]: """Calculate statistics from registry @@ -65,14 +66,17 @@ def get_registry_statistics(registry: Dict[str, Any]) -> Dict[str, Any]: "open_plans": open_count, "closed_plans": closed_count, "other_plans": other_count, - "timestamp": datetime.now(timezone.utc).isoformat() + "timestamp": datetime.now(timezone.utc).isoformat(), } - json_handler.log_operation("registry_statistics_calculated", { - "total_plans": len(plans), - "open_plans": open_count, - "closed_plans": closed_count, - "success": True, - }) + json_handler.log_operation( + "registry_statistics_calculated", + { + "total_plans": len(plans), + "open_plans": open_count, + "closed_plans": closed_count, + "success": True, + }, + ) return result diff --git a/src/aipass/flow/apps/handlers/template/get_template.py b/src/aipass/flow/apps/handlers/template/get_template.py index 9d3564df..99f6482b 100644 --- a/src/aipass/flow/apps/handlers/template/get_template.py +++ b/src/aipass/flow/apps/handlers/template/get_template.py @@ -46,6 +46,7 @@ DEFAULT_TEMPLATE = "default" # HELPER FUNCTIONS # ============================================= + def _template_search_dirs() -> list[Path]: """Determine the ordered list of directories to search for templates""" return [TEMPLATES_DIR] @@ -72,21 +73,17 @@ def _find_template_file(template_name: str) -> Path: return candidate # Not found — error with available templates - available = [ - p.stem for p in search_paths[0].iterdir() - if p.suffix == ".md" - ] if search_paths[0].is_dir() else [] + available = [p.stem for p in search_paths[0].iterdir() if p.suffix == ".md"] if search_paths[0].is_dir() else [] searched = ", ".join(str(path) for path in search_paths) - error_msg = ( - f"Template '{template_name}' not found in: {searched}. " - f"Available: {available if available else 'none'}" - ) + error_msg = f"Template '{template_name}' not found in: {searched}. Available: {available if available else 'none'}" raise FileNotFoundError(error_msg) + # ============================================= # HANDLER FUNCTION # ============================================= + def get_template( template_name: str = "default", number: int = 0, @@ -140,11 +137,11 @@ def get_template( template_file = _find_template_file(template_name) # Read template file - with open(template_file, 'r', encoding='utf-8') as f: + with open(template_file, "r", encoding="utf-8") as f: template_content = f.read() # Get current date for {today} placeholder - today = datetime.now().strftime('%Y-%m-%d') + today = datetime.now().strftime("%Y-%m-%d") # Build formatted number string (zero-padded) formatted_number = f"{number:0{digits}d}" @@ -161,11 +158,14 @@ def get_template( tag="", ) - json_handler.log_operation("template_loaded", { - "template": template_file.stem, - "plan_number": plan_number, - "success": True, - }) + json_handler.log_operation( + "template_loaded", + { + "template": template_file.stem, + "plan_number": plan_number, + "success": True, + }, + ) return formatted_content diff --git a/src/aipass/flow/apps/handlers/template/plan_type_loader.py b/src/aipass/flow/apps/handlers/template/plan_type_loader.py index 9f780e6b..738638fb 100644 --- a/src/aipass/flow/apps/handlers/template/plan_type_loader.py +++ b/src/aipass/flow/apps/handlers/template/plan_type_loader.py @@ -61,6 +61,7 @@ def _get_prefix_map() -> Dict[str, str]: """Load prefix map from persistent template registry.""" try: from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map + return get_prefix_map() except Exception as exc: logger.warning( @@ -70,6 +71,7 @@ def _get_prefix_map() -> Dict[str, str]: ) return _FALLBACK_PREFIX_MAP + # Standardised defaults applied to every discovered plan type STANDARD_DIGITS = 4 STANDARD_SLUG_MAX = 45 @@ -119,8 +121,7 @@ def _build_cache() -> Dict[str, Dict]: prefix = prefix_map.get(dir_name) if prefix is None: logger.warning( - "%s: Unknown plan type '%s' in templates/. " - "Register with: drone @flow register %s ", + "%s: Unknown plan type '%s' in templates/. Register with: drone @flow register %s ", MODULE_NAME, dir_name, dir_name, @@ -201,10 +202,7 @@ def _resolve_type_key(type_key: str) -> tuple[str, str | None]: if key.lower() == lower: return key, None - raise ValueError( - f"Unknown plan type '{type_key}'. " - f"Available: {', '.join(cache.keys())}" - ) + raise ValueError(f"Unknown plan type '{type_key}'. Available: {', '.join(cache.keys())}") # ============================================= @@ -226,11 +224,14 @@ def discover_plan_types() -> Dict[str, Dict]: _plan_type_cache = None cache = _get_cache() - json_handler.log_operation("plan_types_discovered", { - "types_found": len(cache), - "type_keys": list(cache.keys()), - "success": True, - }) + json_handler.log_operation( + "plan_types_discovered", + { + "types_found": len(cache), + "type_keys": list(cache.keys()), + "success": True, + }, + ) return cache diff --git a/src/aipass/flow/apps/handlers/template/registry_ops.py b/src/aipass/flow/apps/handlers/template/registry_ops.py index c9ce1935..89763672 100644 --- a/src/aipass/flow/apps/handlers/template/registry_ops.py +++ b/src/aipass/flow/apps/handlers/template/registry_ops.py @@ -72,6 +72,7 @@ _PROTECTED_TYPES: frozenset[str] = frozenset({"flow_plans", "dev_plans"}) # Internal helpers # --------------------------------------------------------------------------- + def _today() -> str: """Return today's date as ISO string.""" return datetime.now().date().isoformat() @@ -102,6 +103,7 @@ def _empty_registry() -> Dict[str, Any]: # Core CRUD # --------------------------------------------------------------------------- + def load_registry() -> Dict[str, Any]: """Load the template registry from disk, auto-creating if missing. @@ -162,10 +164,7 @@ def _prune_orphaned_types(data: Dict[str, Any]) -> bool: Returns True if any entries were pruned. """ templates_dir = FLOW_ROOT / "templates" - orphaned = [ - d for d in data["types"] - if d not in _PROTECTED_TYPES and not (templates_dir / d).is_dir() - ] + orphaned = [d for d in data["types"] if d not in _PROTECTED_TYPES and not (templates_dir / d).is_dir()] if not orphaned: return False @@ -206,8 +205,10 @@ def _auto_register_new_types(data: Dict[str, Any]) -> bool: continue # Collision — needs manual registration shorthand = prefix.lower() data["types"][child.name] = { - "prefix": prefix, "shorthand": shorthand, - "created": _today(), "registered_by": "auto", + "prefix": prefix, + "shorthand": shorthand, + "created": _today(), + "registered_by": "auto", } used_prefixes.add(prefix) registered.add(child.name) @@ -306,7 +307,9 @@ def add_type( # Validate: dir_name not already registered if dir_name in registry["types"]: logger.error( - "[%s] Type '%s' is already registered", MODULE_NAME, dir_name, + "[%s] Type '%s' is already registered", + MODULE_NAME, + dir_name, ) return False @@ -323,7 +326,9 @@ def add_type( templates_dir = FLOW_ROOT / "templates" / dir_name if not templates_dir.is_dir(): logger.error( - "[%s] Templates directory not found: %s", MODULE_NAME, templates_dir, + "[%s] Templates directory not found: %s", + MODULE_NAME, + templates_dir, ) return False @@ -331,7 +336,9 @@ def add_type( md_files = list(templates_dir.glob("*.md")) if not md_files: logger.error( - "[%s] No .md template files found in %s", MODULE_NAME, templates_dir, + "[%s] No .md template files found in %s", + MODULE_NAME, + templates_dir, ) return False @@ -397,7 +404,9 @@ def remove_type(dir_name: str) -> bool: if dir_name in _PROTECTED_TYPES: logger.error( - "[%s] Cannot remove protected type '%s'", MODULE_NAME, dir_name, + "[%s] Cannot remove protected type '%s'", + MODULE_NAME, + dir_name, ) return False @@ -417,6 +426,7 @@ def remove_type(dir_name: str) -> bool: # Lookup helpers # --------------------------------------------------------------------------- + def prefix_exists(prefix: str) -> bool: """Check whether any registered type uses *prefix* (case-insensitive). @@ -428,10 +438,7 @@ def prefix_exists(prefix: str) -> bool: """ registry = load_registry() upper = prefix.upper() - return any( - entry.get("prefix", "").upper() == upper - for entry in registry["types"].values() - ) + return any(entry.get("prefix", "").upper() == upper for entry in registry["types"].values()) def get_prefix_map() -> Dict[str, str]: @@ -441,11 +448,7 @@ def get_prefix_map() -> Dict[str, str]: :mod:`plan_type_loader`. """ registry = load_registry() - return { - dir_name: entry["prefix"] - for dir_name, entry in registry["types"].items() - if "prefix" in entry - } + return {dir_name: entry["prefix"] for dir_name, entry in registry["types"].items() if "prefix" in entry} def get_type_map() -> Dict[str, str]: @@ -467,6 +470,7 @@ def get_type_map() -> Dict[str, str]: # Discovery # --------------------------------------------------------------------------- + def scan_unregistered() -> list[Dict[str, str | int | list[str]]]: """Scan ``templates/`` for directories not yet in the registry. @@ -483,7 +487,9 @@ def scan_unregistered() -> list[Dict[str, str | int | list[str]]]: if not templates_dir.is_dir(): logger.warning( - "[%s] Templates directory not found: %s", MODULE_NAME, templates_dir, + "[%s] Templates directory not found: %s", + MODULE_NAME, + templates_dir, ) return [] @@ -502,10 +508,12 @@ def scan_unregistered() -> list[Dict[str, str | int | list[str]]]: continue stems = [p.stem for p in md_files] - unregistered.append({ - "dir_name": child.name, - "template_count": len(stems), - "templates": stems, - }) + unregistered.append( + { + "dir_name": child.name, + "template_count": len(stems), + "templates": stems, + } + ) return unregistered diff --git a/src/aipass/flow/apps/modules/aggregate_central.py b/src/aipass/flow/apps/modules/aggregate_central.py index 417e3e00..ce94976a 100755 --- a/src/aipass/flow/apps/modules/aggregate_central.py +++ b/src/aipass/flow/apps/modules/aggregate_central.py @@ -70,6 +70,7 @@ CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json" # MAIN AGGREGATION FUNCTION (thin orchestrator) # ============================================= + def aggregate_central(heal: bool = True) -> bool: """Aggregate and validate central plans (thin orchestrator) @@ -92,6 +93,7 @@ def aggregate_central(heal: bool = True) -> bool: # DISPLAY FUNCTIONS # ============================================= + def print_introspection(): """Display module introspection info.""" console.print() @@ -130,6 +132,7 @@ def print_help(): # COMMAND INTERFACE # ============================================= + def handle_command(command: str, args: List[str]) -> bool: """Handle module commands @@ -153,10 +156,7 @@ def handle_command(command: str, args: List[str]) -> bool: return True # Log the operation - json_handler.log_operation( - "central_aggregated", - {"command": command, "args": args} - ) + json_handler.log_operation("central_aggregated", {"command": command, "args": args}) # Check for --heal flag (default is True) heal = True @@ -175,10 +175,11 @@ def handle_command(command: str, args: List[str]) -> bool: # MAIN ENTRY POINT # ============================================= + def main(): """Main entry point for standalone execution""" # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) > 1 and sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) diff --git a/src/aipass/flow/apps/modules/close_plan.py b/src/aipass/flow/apps/modules/close_plan.py index a2094250..c12aa95f 100644 --- a/src/aipass/flow/apps/modules/close_plan.py +++ b/src/aipass/flow/apps/modules/close_plan.py @@ -49,7 +49,7 @@ from aipass.flow.apps.handlers.plan.display import ( format_plan_error, format_plan_deletion_success, format_deletion_cancelled, - format_delete_usage_error + format_delete_usage_error, ) # Internal: Dashboard handlers @@ -74,6 +74,7 @@ MODULE_NAME = "close_plan" # DISPLAY HELPERS # ============================================= + def _display_messages(messages: List[Dict[str, Any]]): """Render handler result messages to console @@ -90,7 +91,7 @@ def _display_messages(messages: List[Dict[str, Any]]): console.print(format_plan_error(error_text, plan_num, details=details)) elif msg_type == "warning": - warning(msg['text']) + warning(msg["text"]) elif msg_type == "dim": console.print(f"[dim]{msg['text']}[/dim]") @@ -102,10 +103,12 @@ def _display_messages(messages: List[Dict[str, Any]]): console.print(f"[green]{msg['text']}[/green]") elif msg_type == "error_text": - error(msg['text']) + error(msg["text"]) elif msg_type == "header": - console.print(format_plan_deletion_header(msg["plan_key"], msg["plan_info"], prefix=msg.get("prefix", "FPLAN"))) + console.print( + format_plan_deletion_header(msg["plan_key"], msg["plan_info"], prefix=msg.get("prefix", "FPLAN")) + ) elif msg_type == "cancelled": console.print(format_deletion_cancelled()) @@ -141,6 +144,7 @@ def _display_messages(messages: List[Dict[str, Any]]): # INTROSPECTION # ============================================= + def print_introspection(): """Display module info and connected handlers""" console.print() @@ -174,6 +178,7 @@ def print_introspection(): console.print("[dim]Run 'drone @flow close --help' for usage[/dim]") console.print() + def print_help(): """Print help information for close_plan module""" console.print() @@ -199,7 +204,14 @@ def print_help(): # CLOSE PLAN WORKFLOW (thin orchestrator) # ============================================= -def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bool = False, spawn_background: bool = True, dry_run: bool = False) -> bool: + +def close_plan( + plan_num: str | None = None, + confirm: bool = False, + all_plans: bool = False, + spawn_background: bool = True, + dry_run: bool = False, +) -> bool: """ Orchestrate plan closure workflow (thin orchestrator) @@ -313,10 +325,7 @@ def handle_command(command: str, args: List[str]) -> bool: from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args # Log the operation - json_handler.log_operation( - "plan_closed", - {"command": command, "args": args} - ) + json_handler.log_operation("plan_closed", {"command": command, "args": args}) # 1. PARSE ARGS: Use command_parser handler plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(args) @@ -346,10 +355,11 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) > 1 and sys.argv[1] in ["--help", "-h", "help"]: import argparse + PARSER = argparse.ArgumentParser( - description='Close PLAN file', + description="Close PLAN file", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" COMMANDS: @@ -376,7 +386,7 @@ EXAMPLES: # Close all open plans (auto-confirms) drone @flow close --all - """ + """, ) PARSER.print_help() sys.exit(0) @@ -385,10 +395,7 @@ EXAMPLES: logger.info("Prax logger connected to close_plan") # Log standalone execution - json_handler.log_operation( - "plan_closed", - {"command": "standalone"} - ) + json_handler.log_operation("plan_closed", {"command": "standalone"}) # Call handle_command with default args = sys.argv[1:] if len(sys.argv) > 1 else [] @@ -399,8 +406,8 @@ EXAMPLES: sys.exit(1) # If first arg is not command, assume it's plan number (backward compatibility) - if args[0] not in ['close', 'close_plan']: - args.insert(0, 'close') + if args[0] not in ["close", "close_plan"]: + args.insert(0, "close") result = handle_command(args[0], args[1:]) # Result is True on success, False on failure diff --git a/src/aipass/flow/apps/modules/create_plan.py b/src/aipass/flow/apps/modules/create_plan.py index e4b387b5..64d81198 100755 --- a/src/aipass/flow/apps/modules/create_plan.py +++ b/src/aipass/flow/apps/modules/create_plan.py @@ -83,6 +83,7 @@ ECOSYSTEM_ROOT = _PKG_ROOT # INTROSPECTION # ============================================= + def print_introspection(): """Display module info and connected handlers""" console.print() @@ -120,6 +121,7 @@ def print_introspection(): console.print("[dim]Run 'drone @flow create --help' for usage[/dim]") console.print() + def print_help(): """Print help information for create_plan module""" console.print() @@ -147,6 +149,7 @@ def print_help(): # ORCHESTRATION WORKFLOWS (thin wrappers) # ============================================= + def create_plan( location: str | None = None, subject: str = "", @@ -213,7 +216,7 @@ def create_plan( if msg_type == "dim": console.print(f"[dim]{msg['text']}[/dim]") elif msg_type == "warning": - warning(msg['text']) + warning(msg["text"]) elif msg_type == "display": console.print(msg["text"]) return ok, num, loc, tmpl, error @@ -254,10 +257,7 @@ def handle_command(command: str, args: List[str]) -> bool: return True # Log the operation - json_handler.log_operation( - "plan_created", - {"command": command, "args": args} - ) + json_handler.log_operation("plan_created", {"command": command, "args": args}) # STEP 1: Parse arguments (delegate to handler) location, subject, plan_type_key = parse_create_plan_args(args) @@ -276,7 +276,8 @@ def handle_command(command: str, args: List[str]) -> bool: # STEP 2: Execute workflow success, num, loc, tmpl, error = create_plan( - location, subject, + location, + subject, plan_type_key=plan_type_key, plan_type_config=plan_type_config, ) @@ -285,8 +286,13 @@ def handle_command(command: str, args: List[str]) -> bool: prefix = plan_type_config["prefix"] if plan_type_config else "FPLAN" digits = plan_type_config["digits"] if plan_type_config else 4 result_msg = display_plan_result( - success, num, loc, tmpl, error, - prefix=prefix, digits=digits, + success, + num, + loc, + tmpl, + error, + prefix=prefix, + digits=digits, ) console.print(result_msg) @@ -307,7 +313,7 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) > 1 and sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) @@ -315,18 +321,15 @@ if __name__ == "__main__": logger.info("Prax logger connected to create_plan") # Log standalone execution - json_handler.log_operation( - "plan_created", - {"command": "standalone"} - ) + json_handler.log_operation("plan_created", {"command": "standalone"}) # Call handle_command with default args = sys.argv[1:] if len(sys.argv) > 1 else [] - if args and args[0] not in ['create', 'create_plan']: + if args and args[0] not in ["create", "create_plan"]: # If first arg is not command, assume it's location (backward compatibility) - args.insert(0, 'create') + args.insert(0, "create") - result = handle_command(args[0] if args else 'create', args[1:] if args else []) + result = handle_command(args[0] if args else "create", args[1:] if args else []) if result: sys.exit(0) else: diff --git a/src/aipass/flow/apps/modules/list_plans.py b/src/aipass/flow/apps/modules/list_plans.py index c3003af0..7966111d 100755 --- a/src/aipass/flow/apps/modules/list_plans.py +++ b/src/aipass/flow/apps/modules/list_plans.py @@ -50,10 +50,7 @@ from aipass.flow.apps.handlers.registry.load_registry import load_registry from aipass.flow.apps.handlers.registry.statistics import get_registry_statistics # Plan display handler -from aipass.flow.apps.handlers.plan.display import ( - format_plans_list, - format_statistics_summary -) +from aipass.flow.apps.handlers.plan.display import format_plans_list, format_statistics_summary # Implementation handler from aipass.flow.apps.handlers.plan.list_ops import list_plans_impl @@ -68,6 +65,7 @@ MODULE_NAME = "list_plans" # INTROSPECTION FUNCTION # ============================================= + def print_introspection(): """Display module info and connected handlers""" console.print() @@ -115,6 +113,7 @@ def print_help(): # ORCHESTRATION WORKFLOWS # ============================================= + def list_plans(filter_type: str = "open") -> bool: """ Orchestrate plan listing workflow (thin orchestrator) @@ -189,10 +188,7 @@ def handle_command(command: str, args: List[str]) -> bool: return True # Log the operation - json_handler.log_operation( - "plans_listed", - {"command": command, "args": args} - ) + json_handler.log_operation("plans_listed", {"command": command, "args": args}) # STEP 1: Parse filter argument filter_type = "open" # Default to open plans @@ -225,7 +221,7 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) > 1 and sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) @@ -233,21 +229,18 @@ if __name__ == "__main__": logger.info("Prax logger connected to list_plans") # Log standalone execution - json_handler.log_operation( - "plans_listed", - {"command": "standalone"} - ) + json_handler.log_operation("plans_listed", {"command": "standalone"}) # Call handle_command args = sys.argv[1:] if len(sys.argv) > 1 else [] # If first arg is not our command, assume it's a filter (backward compatibility) - if args and args[0] not in ['list', 'list_plans']: + if args and args[0] not in ["list", "list_plans"]: # First arg is filter - result = handle_command('list', args) + result = handle_command("list", args) else: # Standard command format - cmd = args[0] if args else 'list' + cmd = args[0] if args else "list" result = handle_command(cmd, args[1:] if len(args) > 1 else []) # Exit with appropriate code @@ -256,6 +249,7 @@ if __name__ == "__main__": except BrokenPipeError: # Pipe closed by reader - exit cleanly import os + logger.info(f"[{MODULE_NAME}] Broken pipe in standalone mode (stdout closed early)") try: sys.stdout.close() diff --git a/src/aipass/flow/apps/modules/post_close_runner.py b/src/aipass/flow/apps/modules/post_close_runner.py index 1ee2297a..93a0b9d2 100644 --- a/src/aipass/flow/apps/modules/post_close_runner.py +++ b/src/aipass/flow/apps/modules/post_close_runner.py @@ -70,10 +70,7 @@ def handle_command(command: str, args: list) -> bool: return True # Log the operation - json_handler.log_operation( - "post_close_processed", - {"command": command, "args": args} - ) + json_handler.log_operation("post_close_processed", {"command": command, "args": args}) # Run the post-close processing directly (foreground) if not _acquire_lock(): @@ -181,7 +178,7 @@ def print_help(): if __name__ == "__main__": - if '--help' in sys.argv or '-h' in sys.argv: + if "--help" in sys.argv or "-h" in sys.argv: print_help() sys.exit(0) diff --git a/src/aipass/flow/apps/modules/registry_monitor.py b/src/aipass/flow/apps/modules/registry_monitor.py index 926281a8..9a029a63 100644 --- a/src/aipass/flow/apps/modules/registry_monitor.py +++ b/src/aipass/flow/apps/modules/registry_monitor.py @@ -90,6 +90,7 @@ ECOSYSTEM_ROOT = REPO_ROOT # Scan from repo root # THIN ORCHESTRATION WRAPPERS # ============================================= + def scan_plan_files() -> Dict[str, Any]: """ Scan ecosystem for PLAN files and fire events to heal registry (thin orchestrator) @@ -119,7 +120,7 @@ def start_monitoring(): elif status == "started": console.print(f"[green]OK[/green] {result['message']}") elif status == "error": - error(result['message']) + error(result["message"]) return result.get("success", False) @@ -151,6 +152,7 @@ def get_status() -> Dict[str, Any]: # COMMAND HANDLER # ============================================= + def handle_command(command: str, args: List[str]) -> bool: """ Handle command routing for registry_monitor module @@ -186,10 +188,7 @@ def handle_command(command: str, args: List[str]) -> bool: subcommand = args[0] if args else "status" # Log the operation - json_handler.log_operation( - "registry_monitor", - {"command": command, "subcommand": subcommand} - ) + json_handler.log_operation("registry_monitor", {"command": command, "subcommand": subcommand}) if subcommand in ["scan", "heal"]: console.print("[bold]Scanning for PLAN files...[/bold]") @@ -203,9 +202,11 @@ def handle_command(command: str, args: List[str]) -> bool: console.print(f" • Removed: {len(result['removed'])}") console.print(f" • Renumbered: {len(result['renumbered'])}") - if result['healing_performed']: - change_count = len(result['added']) + len(result['updated']) + len(result['removed']) - warning(f"Registry scan found {change_count} mismatch(es) — trigger event handlers not wired, no changes applied") + if result["healing_performed"]: + change_count = len(result["added"]) + len(result["updated"]) + len(result["removed"]) + warning( + f"Registry scan found {change_count} mismatch(es) — trigger event handlers not wired, no changes applied" + ) else: console.print("\n[dim]No changes needed - registry is healthy[/dim]") @@ -251,7 +252,9 @@ def handle_command(command: str, args: List[str]) -> bool: console.print("[bold cyan]Registry Monitor Status[/bold cyan]") console.print() console.print(f" • Version: {status['version']}") - console.print(f" • Monitoring: {'[green]Active[/green]' if status['monitoring_active'] else '[yellow]Inactive[/yellow]'}") + console.print( + f" • Monitoring: {'[green]Active[/green]' if status['monitoring_active'] else '[yellow]Inactive[/yellow]'}" + ) console.print(f" • Watch location: {status['watch_location']}") console.print(f" • Total plans: {status['total_plans']}") console.print(f" • Open plans: {status['open_plans']}") @@ -279,6 +282,7 @@ def handle_command(command: str, args: List[str]) -> bool: # INTROSPECTION # ============================================= + def print_introspection(): """Display module info and usage""" console.print() @@ -349,7 +353,7 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) > 1 and sys.argv[1] in ["--help", "-h", "help"]: print_help() sys.exit(0) @@ -357,10 +361,7 @@ if __name__ == "__main__": logger.info("Prax logger connected to registry_monitor") # Log standalone execution - json_handler.log_operation( - "registry_monitor", - {"command": "standalone"} - ) + json_handler.log_operation("registry_monitor", {"command": "standalone"}) # Call handle_command args = sys.argv[1:] if len(sys.argv) > 1 else [] diff --git a/src/aipass/flow/apps/modules/restore_plan.py b/src/aipass/flow/apps/modules/restore_plan.py index 8ca48810..9fdc5d50 100644 --- a/src/aipass/flow/apps/modules/restore_plan.py +++ b/src/aipass/flow/apps/modules/restore_plan.py @@ -45,7 +45,7 @@ from aipass.flow.apps.handlers.plan.display import ( format_restore_header, format_restore_error, format_restore_success, - format_restore_usage_error + format_restore_usage_error, ) # Internal: Dashboard handlers @@ -68,6 +68,7 @@ MODULE_NAME = "restore_plan" # DISPLAY HELPERS # ============================================= + def _display_messages(messages: List[Dict[str, Any]]): """Render handler result messages to console @@ -84,7 +85,7 @@ def _display_messages(messages: List[Dict[str, Any]]): console.print(format_restore_error(error_type, plan_key, details=details)) elif msg_type == "warning": - warning(msg['text']) + warning(msg["text"]) elif msg_type == "dim": console.print(f"[dim]{msg['text']}[/dim]") @@ -98,10 +99,12 @@ def _display_messages(messages: List[Dict[str, Any]]): elif msg_type == "restore_success": console.print(format_restore_success(msg["plan_key"], msg.get("location"))) + # ============================================= # INTROSPECTION # ============================================= + def print_introspection(): """Display module info and connected handlers""" console.print() @@ -131,6 +134,7 @@ def print_introspection(): console.print("[dim]Run 'drone @flow restore --help' for usage[/dim]") console.print() + def print_help(): """Print help information for restore_plan module""" console.print() @@ -152,6 +156,7 @@ def print_help(): # RECOVERY FUNCTIONS # ============================================= + def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]: """ Attempt to recover a plan from processed_plans backup (thin orchestrator) @@ -170,10 +175,12 @@ def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]: save_registry=save_registry, ) + # ============================================= # RESTORE PLAN WORKFLOW # ============================================= + def restore_plan(plan_num: str | None) -> bool: """ Orchestrate plan restore workflow (thin orchestrator) @@ -241,10 +248,7 @@ def handle_command(command: str, args: List[str]) -> bool: from aipass.flow.apps.handlers.plan.command_parser import parse_restore_command_args # Log the operation - json_handler.log_operation( - "plan_restored", - {"command": command, "args": args} - ) + json_handler.log_operation("plan_restored", {"command": command, "args": args}) # 1. PARSE ARGS: Use command_parser handler plan_num, error = parse_restore_command_args(args) @@ -274,10 +278,11 @@ if __name__ == "__main__": sys.exit(0) # Handle help flag - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + if len(sys.argv) > 1 and sys.argv[1] in ["--help", "-h", "help"]: import argparse + PARSER = argparse.ArgumentParser( - description='Restore PLAN file to open status', + description="Restore PLAN file to open status", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" COMMANDS: @@ -298,7 +303,7 @@ NOTES: - Plan must be closed to restore - Plan file must exist at registered location - Only updates registry metadata (does not move files) - """ + """, ) PARSER.print_help() sys.exit(0) @@ -307,10 +312,7 @@ NOTES: logger.info("Prax logger connected to restore_plan") # Log standalone execution - json_handler.log_operation( - "plan_restored", - {"command": "standalone"} - ) + json_handler.log_operation("plan_restored", {"command": "standalone"}) # Call handle_command with default args = sys.argv[1:] if len(sys.argv) > 1 else [] @@ -321,8 +323,8 @@ NOTES: sys.exit(1) # If first arg is not command, assume it's plan number (backward compatibility) - if args[0] not in ['restore', 'restore_plan']: - args.insert(0, 'restore') + if args[0] not in ["restore", "restore_plan"]: + args.insert(0, "restore") result = handle_command(args[0], args[1:]) # Result is True on success, False on failure diff --git a/src/aipass/flow/apps/modules/template_manager.py b/src/aipass/flow/apps/modules/template_manager.py index 7efa44e6..2780a8e8 100644 --- a/src/aipass/flow/apps/modules/template_manager.py +++ b/src/aipass/flow/apps/modules/template_manager.py @@ -229,7 +229,7 @@ def handle_command(command: str, args: List[str]) -> bool: if registered: success(f"Registered '{dir_name}' with prefix {prefix}") console.print() - console.print(f"[dim]Create plans with: drone @flow create . \"subject\" {prefix.lower()}[/dim]") + console.print(f'[dim]Create plans with: drone @flow create . "subject" {prefix.lower()}[/dim]') console.print() else: error(f"Failed to register '{dir_name}' — check logs for details") diff --git a/src/aipass/flow/tests/conftest.py b/src/aipass/flow/tests/conftest.py index 612a1d94..73ca6a70 100644 --- a/src/aipass/flow/tests/conftest.py +++ b/src/aipass/flow/tests/conftest.py @@ -1,4 +1,5 @@ """Shared pytest fixtures for flow tests""" + import os import tempfile @@ -32,20 +33,20 @@ def mock_logger(): @pytest.fixture(autouse=True) def mock_json_handler(): """Mock json_handler to prevent real JSON operations.""" - with patch( - "aipass.flow.apps.handlers.json.json_handler.log_operation" - ) as mock_log_op: + with patch("aipass.flow.apps.handlers.json.json_handler.log_operation") as mock_log_op: yield mock_log_op @pytest.fixture(autouse=True) def mock_console(): """Mock CLI console to prevent real console output.""" - with patch("aipass.cli.apps.modules.console") as console_mock, \ - patch("aipass.cli.apps.modules.error") as error_mock, \ - patch("aipass.cli.apps.modules.warning") as warning_mock, \ - patch("aipass.cli.apps.modules.success") as success_mock, \ - patch("aipass.cli.apps.modules.header") as header_mock: + with ( + patch("aipass.cli.apps.modules.console") as console_mock, + patch("aipass.cli.apps.modules.error") as error_mock, + patch("aipass.cli.apps.modules.warning") as warning_mock, + patch("aipass.cli.apps.modules.success") as success_mock, + patch("aipass.cli.apps.modules.header") as header_mock, + ): yield { "console": console_mock, "error": error_mock, @@ -77,7 +78,7 @@ def mock_registry(tmp_path): "created": "2026-03-20", "file_path": str(tmp_path / "FPLAN-0001_test_plan_one_2026-03-20.md"), "location": str(tmp_path), - "relative_path": "FPLAN-0001_test_plan_one_2026-03-20.md" + "relative_path": "FPLAN-0001_test_plan_one_2026-03-20.md", }, "2": { "subject": "Closed plan", @@ -87,7 +88,7 @@ def mock_registry(tmp_path): "closed_reason": "completed", "file_path": str(tmp_path / "FPLAN-0002_closed_plan_2026-03-18.md"), "location": str(tmp_path), - "relative_path": "FPLAN-0002_closed_plan_2026-03-18.md" + "relative_path": "FPLAN-0002_closed_plan_2026-03-18.md", }, "3": { "subject": "Another open", @@ -95,9 +96,9 @@ def mock_registry(tmp_path): "created": "2026-03-22", "file_path": str(tmp_path / "FPLAN-0003_another_open_2026-03-22.md"), "location": str(tmp_path), - "relative_path": "FPLAN-0003_another_open_2026-03-22.md" - } - } + "relative_path": "FPLAN-0003_another_open_2026-03-22.md", + }, + }, } registry_file = tmp_path / "fplan_registry.json" registry_file.write_text(json.dumps(registry, indent=2), encoding="utf-8") @@ -109,16 +110,8 @@ def mock_template_registry(tmp_path): """Create a mock template registry.""" registry = { "types": { - "flow_plans": { - "prefix": "FPLAN", - "shorthand": "fplan", - "created": "2026-03-07" - }, - "dev_plans": { - "prefix": "DPLAN", - "shorthand": "dplan", - "created": "2026-03-07" - } + "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan", "created": "2026-03-07"}, + "dev_plans": {"prefix": "DPLAN", "shorthand": "dplan", "created": "2026-03-07"}, } } registry_file = tmp_path / "template_registry.json" diff --git a/src/aipass/flow/tests/test_aggregate_central.py b/src/aipass/flow/tests/test_aggregate_central.py index ee00d4ac..f1486b63 100644 --- a/src/aipass/flow/tests/test_aggregate_central.py +++ b/src/aipass/flow/tests/test_aggregate_central.py @@ -3,22 +3,24 @@ from unittest.mock import patch - # ─── Patch targets ─────────────────────────────────────── _MOD = "aipass.flow.apps.modules.aggregate_central" # ─── Helpers ───────────────────────────────────────────── + def _import_handle_command(): """Import handle_command inside each test so autouse mocks are active.""" from aipass.flow.apps.modules.aggregate_central import handle_command + return handle_command def _import_aggregate_central(): """Import aggregate_central orchestrator.""" from aipass.flow.apps.modules.aggregate_central import aggregate_central + return aggregate_central @@ -26,8 +28,8 @@ def _import_aggregate_central(): # 1. Command != "aggregate" -> returns False # ═══════════════════════════════════════════════════════════ -class TestCommandRouting: +class TestCommandRouting: def test_wrong_command_returns_false(self): handle_command = _import_handle_command() assert handle_command("create", []) is False @@ -45,8 +47,8 @@ class TestCommandRouting: # 2. command == "aggregate" with no args -> introspection # ═══════════════════════════════════════════════════════════ -class TestNoArgs: +class TestNoArgs: @patch(f"{_MOD}.print_introspection") def test_no_args_calls_introspection(self, mock_intro): """No args should show introspection.""" @@ -60,8 +62,8 @@ class TestNoArgs: # 3. command == "aggregate" with --help -> help # ═══════════════════════════════════════════════════════════ -class TestHelp: +class TestHelp: @patch(f"{_MOD}.print_help") def test_help_flag(self, mock_help): handle_command = _import_handle_command() @@ -88,8 +90,8 @@ class TestHelp: # 4. command == "aggregate" with ["run"] -> calls aggregate_central(heal=True) # ═══════════════════════════════════════════════════════════ -class TestRunCommand: +class TestRunCommand: @patch(f"{_MOD}.aggregate_central", return_value=True) def test_run_calls_aggregate_with_heal(self, mock_aggregate): handle_command = _import_handle_command() @@ -116,8 +118,8 @@ class TestRunCommand: # 5. command == "aggregate" with ["--no-heal"] -> calls aggregate_central(heal=False) # ═══════════════════════════════════════════════════════════ -class TestNoHealFlag: +class TestNoHealFlag: @patch(f"{_MOD}.aggregate_central", return_value=True) def test_no_heal_flag(self, mock_aggregate): handle_command = _import_handle_command() @@ -137,8 +139,8 @@ class TestNoHealFlag: # 6. aggregate_central orchestrator delegates to aggregate_central_impl # ═══════════════════════════════════════════════════════════ -class TestAggregateCentralOrchestrator: +class TestAggregateCentralOrchestrator: @patch(f"{_MOD}.aggregate_central_impl", return_value=True) def test_impl_success_returns_true(self, mock_impl): aggregate_central = _import_aggregate_central() @@ -178,8 +180,8 @@ class TestAggregateCentralOrchestrator: # 7. json_handler.log_operation is called on valid commands # ═══════════════════════════════════════════════════════════ -class TestOperationLogging: +class TestOperationLogging: @patch(f"{_MOD}.aggregate_central", return_value=True) @patch(f"{_MOD}.json_handler") def test_logs_operation(self, mock_jh, mock_aggregate): diff --git a/src/aipass/flow/tests/test_aggregate_ops.py b/src/aipass/flow/tests/test_aggregate_ops.py index ac615312..b0b6ad22 100644 --- a/src/aipass/flow/tests/test_aggregate_ops.py +++ b/src/aipass/flow/tests/test_aggregate_ops.py @@ -5,16 +5,17 @@ from pathlib import Path from unittest.mock import patch - # ─── Patch targets ─────────────────────────────────────── _MOD = "aipass.flow.apps.handlers.plan.aggregate_ops" # ─── Import helpers ────────────────────────────────────── + def _import(name: str): """Import a function from aggregate_ops inside each test.""" import aipass.flow.apps.handlers.plan.aggregate_ops as mod + return getattr(mod, name) @@ -22,8 +23,8 @@ def _import(name: str): # 1. find_branch_registry # ═══════════════════════════════════════════════════════════ -class TestFindBranchRegistry: +class TestFindBranchRegistry: def test_returns_none_when_branch_path_missing(self, tmp_path): find_branch_registry = _import("find_branch_registry") missing = tmp_path / "no_such_dir" @@ -72,8 +73,8 @@ class TestFindBranchRegistry: # 2. load_branch_registry # ═══════════════════════════════════════════════════════════ -class TestLoadBranchRegistry: +class TestLoadBranchRegistry: def test_loads_valid_json(self, tmp_path): load_branch_registry = _import("load_branch_registry") data = {"plans": {"1": {"status": "open"}}, "next_number": 2} @@ -99,8 +100,8 @@ class TestLoadBranchRegistry: # 3. save_branch_registry # ═══════════════════════════════════════════════════════════ -class TestSaveBranchRegistry: +class TestSaveBranchRegistry: def test_saves_valid_json(self, tmp_path): save_branch_registry = _import("save_branch_registry") reg_file = tmp_path / "registry.json" @@ -133,8 +134,8 @@ class TestSaveBranchRegistry: # 4. extract_plan_number # ═══════════════════════════════════════════════════════════ -class TestExtractPlanNumber: +class TestExtractPlanNumber: def test_valid_plan_id(self): extract_plan_number = _import("extract_plan_number") assert extract_plan_number("FPLAN-0148") == "0148" @@ -165,8 +166,8 @@ class TestExtractPlanNumber: # 5. auto_close_plan # ═══════════════════════════════════════════════════════════ -class TestAutoClosePlan: +class TestAutoClosePlan: def _make_registry(self, tmp_path, plans: dict) -> Path: reg_file = tmp_path / "registry.json" data = {"plans": plans, "next_number": 10} @@ -175,9 +176,7 @@ class TestAutoClosePlan: def test_closes_open_plan(self, tmp_path): auto_close_plan = _import("auto_close_plan") - reg = self._make_registry(tmp_path, { - "0148": {"status": "open", "subject": "Test"} - }) + reg = self._make_registry(tmp_path, {"0148": {"status": "open", "subject": "Test"}}) result = auto_close_plan(reg, "FPLAN-0148", "flow") assert result is True saved = json.loads(reg.read_text(encoding="utf-8")) @@ -186,9 +185,7 @@ class TestAutoClosePlan: def test_returns_false_for_already_closed(self, tmp_path): auto_close_plan = _import("auto_close_plan") - reg = self._make_registry(tmp_path, { - "0001": {"status": "closed", "subject": "Done"} - }) + reg = self._make_registry(tmp_path, {"0001": {"status": "closed", "subject": "Done"}}) result = auto_close_plan(reg, "FPLAN-0001", "flow") assert result is False @@ -206,9 +203,7 @@ class TestAutoClosePlan: def test_returns_false_on_save_failure(self, tmp_path): auto_close_plan = _import("auto_close_plan") - reg = self._make_registry(tmp_path, { - "0001": {"status": "open", "subject": "Test"} - }) + reg = self._make_registry(tmp_path, {"0001": {"status": "open", "subject": "Test"}}) with patch(f"{_MOD}.save_branch_registry", return_value=False): result = auto_close_plan(reg, "FPLAN-0001", "flow") assert result is False @@ -218,8 +213,8 @@ class TestAutoClosePlan: # 6. validate_and_heal_branch # ═══════════════════════════════════════════════════════════ -class TestValidateAndHealBranch: +class TestValidateAndHealBranch: def test_valid_plans_kept(self, tmp_path): validate_and_heal_branch = _import("validate_and_heal_branch") # Create an actual plan file on disk @@ -228,9 +223,7 @@ class TestValidateAndHealBranch: branch_data = { "branch_path": str(tmp_path), - "active_plans": [ - {"plan_id": "FPLAN-0001", "file_path": str(plan_file), "created": "2026-03-01"} - ], + "active_plans": [{"plan_id": "FPLAN-0001", "file_path": str(plan_file), "created": "2026-03-01"}], "recently_closed": [], } valid, closed = validate_and_heal_branch("test", branch_data, heal=False) @@ -279,9 +272,7 @@ class TestValidateAndHealBranch: def test_recently_closed_preserved(self, tmp_path): validate_and_heal_branch = _import("validate_and_heal_branch") - existing_closed = [ - {"plan_id": "FPLAN-0010", "status": "closed", "closed": "2026-03-20"} - ] + existing_closed = [{"plan_id": "FPLAN-0010", "status": "closed", "closed": "2026-03-20"}] branch_data = { "branch_path": str(tmp_path), "active_plans": [], @@ -312,8 +303,8 @@ class TestValidateAndHealBranch: # 7. load_central # ═══════════════════════════════════════════════════════════ -class TestLoadCentral: +class TestLoadCentral: def test_loads_valid_file(self, tmp_path): load_central = _import("load_central") data = { @@ -349,8 +340,8 @@ class TestLoadCentral: # 8. save_central # ═══════════════════════════════════════════════════════════ -class TestSaveCentral: +class TestSaveCentral: def test_saves_valid_json(self, tmp_path): save_central = _import("save_central") central_dir = tmp_path / ".ai_central" @@ -383,8 +374,8 @@ class TestSaveCentral: # 9. aggregate_central_impl # ═══════════════════════════════════════════════════════════ -class TestAggregateCentralImpl: +class TestAggregateCentralImpl: def test_returns_false_when_paths_none(self): aggregate_central_impl = _import("aggregate_central_impl") result = aggregate_central_impl(heal=True, central_file=None, central_dir=None) @@ -404,9 +395,7 @@ class TestAggregateCentralImpl: "global_statistics": {}, } central_file.write_text(json.dumps(data), encoding="utf-8") - result = aggregate_central_impl( - heal=True, central_file=central_file, central_dir=central_dir - ) + result = aggregate_central_impl(heal=True, central_file=central_file, central_dir=central_dir) assert result is True def test_aggregates_active_plans_across_branches(self, tmp_path): @@ -431,16 +420,12 @@ class TestAggregateCentralImpl: "branches": { "branch_a": { "branch_path": str(tmp_path / "branch_a"), - "active_plans": [ - {"plan_id": "FPLAN-0001", "file_path": str(plan1), "created": "2026-03-01"} - ], + "active_plans": [{"plan_id": "FPLAN-0001", "file_path": str(plan1), "created": "2026-03-01"}], "recently_closed": [], }, "branch_b": { "branch_path": str(tmp_path / "branch_b"), - "active_plans": [ - {"plan_id": "FPLAN-0002", "file_path": str(plan2), "created": "2026-03-02"} - ], + "active_plans": [{"plan_id": "FPLAN-0002", "file_path": str(plan2), "created": "2026-03-02"}], "recently_closed": [], }, }, @@ -449,9 +434,7 @@ class TestAggregateCentralImpl: central_file.write_text(json.dumps(data), encoding="utf-8") with patch(f"{_MOD}.trigger", create=True): - result = aggregate_central_impl( - heal=False, central_file=central_file, central_dir=central_dir - ) + result = aggregate_central_impl(heal=False, central_file=central_file, central_dir=central_dir) assert result is True saved = json.loads(central_file.read_text(encoding="utf-8")) @@ -470,8 +453,7 @@ class TestAggregateCentralImpl: # Build 7 closed plans in a single branch closed_plans = [ - {"plan_id": f"FPLAN-{i:04d}", "status": "closed", "closed": f"2026-03-{i:02d}"} - for i in range(1, 8) + {"plan_id": f"FPLAN-{i:04d}", "status": "closed", "closed": f"2026-03-{i:02d}"} for i in range(1, 8) ] data = { "generated_at": "", @@ -490,9 +472,7 @@ class TestAggregateCentralImpl: central_file.write_text(json.dumps(data), encoding="utf-8") with patch(f"{_MOD}.trigger", create=True): - result = aggregate_central_impl( - heal=False, central_file=central_file, central_dir=central_dir - ) + result = aggregate_central_impl(heal=False, central_file=central_file, central_dir=central_dir) assert result is True saved = json.loads(central_file.read_text(encoding="utf-8")) @@ -509,19 +489,19 @@ class TestAggregateCentralImpl: "active_plans": [], "recently_closed": [], "statistics": {}, - "branches": {"b": { - "branch_path": str(tmp_path), - "active_plans": [], - "recently_closed": [], - }}, + "branches": { + "b": { + "branch_path": str(tmp_path), + "active_plans": [], + "recently_closed": [], + } + }, "global_statistics": {}, } central_file.write_text(json.dumps(data), encoding="utf-8") with patch(f"{_MOD}.save_central", return_value=False): - result = aggregate_central_impl( - heal=True, central_file=central_file, central_dir=central_dir - ) + result = aggregate_central_impl(heal=True, central_file=central_file, central_dir=central_dir) assert result is False def test_returns_false_on_exception(self): @@ -544,19 +524,19 @@ class TestAggregateCentralImpl: "active_plans": [], "recently_closed": [], "statistics": {}, - "branches": {"b": { - "branch_path": str(tmp_path), - "active_plans": [], - "recently_closed": [], - }}, + "branches": { + "b": { + "branch_path": str(tmp_path), + "active_plans": [], + "recently_closed": [], + } + }, "global_statistics": {}, } central_file.write_text(json.dumps(data), encoding="utf-8") with patch(f"{_MOD}.trigger", create=True): - aggregate_central_impl( - heal=False, central_file=central_file, central_dir=central_dir - ) + aggregate_central_impl(heal=False, central_file=central_file, central_dir=central_dir) saved = json.loads(central_file.read_text(encoding="utf-8")) assert saved["generated_at"] != "" diff --git a/src/aipass/flow/tests/test_close_ops.py b/src/aipass/flow/tests/test_close_ops.py index 0eb0817b..7f6920b6 100644 --- a/src/aipass/flow/tests/test_close_ops.py +++ b/src/aipass/flow/tests/test_close_ops.py @@ -3,21 +3,24 @@ from unittest.mock import MagicMock, patch - # ─── Helpers ───────────────────────────────────────────── + def _import_extract_prefix(): from aipass.flow.apps.handlers.plan.close_ops import _extract_prefix + return _extract_prefix def _import_close_plan_impl(): from aipass.flow.apps.handlers.plan.close_ops import close_plan_impl + return close_plan_impl def _import_close_all_plans_impl(): from aipass.flow.apps.handlers.plan.close_ops import close_all_plans_impl + return close_all_plans_impl @@ -43,8 +46,8 @@ def _make_deps(**overrides) -> dict: # 1. _extract_prefix # ═══════════════════════════════════════════════════════════ -class TestExtractPrefix: +class TestExtractPrefix: def test_fplan_prefix(self): fn = _import_extract_prefix() assert fn("FPLAN-0001") == "FPLAN" @@ -91,6 +94,7 @@ class TestExtractPrefix: # 2. close_plan_impl — single plan closure # ═══════════════════════════════════════════════════════════ + class TestClosePlanImplNoNumber: """Plan number is required for single plan closure.""" @@ -261,9 +265,11 @@ class TestClosePlanImplSuccess: deps["load_registry"].return_value = registry deps["validate_plan_exists"].return_value = (True, None) - with patch("aipass.flow.apps.handlers.mbank.process.archive_plan", return_value=True), \ - patch("aipass.flow.apps.handlers.plan.close_ops.json_handler"), \ - patch("aipass.flow.apps.handlers.plan.append_closed_plan.append_to_closed_plans", create=True): + with ( + patch("aipass.flow.apps.handlers.mbank.process.archive_plan", return_value=True), + patch("aipass.flow.apps.handlers.plan.close_ops.json_handler"), + patch("aipass.flow.apps.handlers.plan.append_closed_plan.append_to_closed_plans", create=True), + ): result = close_plan_impl(plan_num="1", **deps) assert result["success"] is True @@ -326,6 +332,7 @@ class TestClosePlanImplValueError: # 3. close_all_plans_impl # ═══════════════════════════════════════════════════════════ + class TestCloseAllNoPlans: """No open plans to close.""" @@ -403,10 +410,12 @@ class TestCloseAllPartialFailure: ] mock_get = MagicMock(return_value=open_plans) # First succeeds, second fails - mock_close = MagicMock(side_effect=[ - {"success": True, "messages": []}, - {"success": False, "messages": []}, - ]) + mock_close = MagicMock( + side_effect=[ + {"success": True, "messages": []}, + {"success": False, "messages": []}, + ] + ) result = close_all(get_open_plans=mock_get, close_plan_fn=mock_close) assert result["success"] is True # At least one succeeded diff --git a/src/aipass/flow/tests/test_close_plan.py b/src/aipass/flow/tests/test_close_plan.py index 1f3cdb8f..71213034 100644 --- a/src/aipass/flow/tests/test_close_plan.py +++ b/src/aipass/flow/tests/test_close_plan.py @@ -3,7 +3,6 @@ from unittest.mock import patch - # ─── Patch targets ─────────────────────────────────────── _MOD = "aipass.flow.apps.modules.close_plan" # parse_close_command_args is imported *inside* handle_command, not at module @@ -13,21 +12,25 @@ _PARSER = "aipass.flow.apps.handlers.plan.command_parser" # ─── Helpers ───────────────────────────────────────────── + def _import_handle_command(): """Import handle_command inside each test so autouse mocks are active.""" from aipass.flow.apps.modules.close_plan import handle_command + return handle_command def _import_close_plan(): """Import close_plan orchestrator.""" from aipass.flow.apps.modules.close_plan import close_plan + return close_plan def _import_close_all_plans(): """Import close_all_plans orchestrator.""" from aipass.flow.apps.modules.close_plan import close_all_plans + return close_all_plans @@ -35,8 +38,8 @@ def _import_close_all_plans(): # 1. Command != "close" -> returns False # ═══════════════════════════════════════════════════════════ -class TestCommandRouting: +class TestCommandRouting: def test_wrong_command_returns_false(self): handle_command = _import_handle_command() assert handle_command("create", []) is False @@ -54,8 +57,8 @@ class TestCommandRouting: # 2. command == "close" with no args -> introspection # ═══════════════════════════════════════════════════════════ -class TestIntrospection: +class TestIntrospection: @patch(f"{_MOD}.print_introspection") def test_no_args_calls_introspection(self, mock_introspection): handle_command = _import_handle_command() @@ -77,8 +80,8 @@ class TestIntrospection: # 3. command == "close" with --help -> help # ═══════════════════════════════════════════════════════════ -class TestHelp: +class TestHelp: @patch(f"{_MOD}.print_help") def test_help_flag(self, mock_help): handle_command = _import_handle_command() @@ -105,8 +108,8 @@ class TestHelp: # 4. command == "close" with plan number -> calls close_plan # ═══════════════════════════════════════════════════════════ -class TestCloseSinglePlan: +class TestCloseSinglePlan: @patch(f"{_MOD}.close_plan") @patch(f"{_PARSER}.parse_close_command_args", return_value=("42", False, False, False, None)) def test_plan_number_calls_close_plan(self, mock_parse, mock_close): @@ -114,7 +117,10 @@ class TestCloseSinglePlan: result = handle_command("close", ["42"]) assert result is True mock_close.assert_called_once_with( - plan_num="42", confirm=False, all_plans=False, dry_run=False, + plan_num="42", + confirm=False, + all_plans=False, + dry_run=False, ) @patch(f"{_MOD}.close_plan") @@ -132,7 +138,10 @@ class TestCloseSinglePlan: result = handle_command("close", ["FPLAN-0042"]) assert result is True mock_close.assert_called_once_with( - plan_num="FPLAN-0042", confirm=False, all_plans=False, dry_run=False, + plan_num="FPLAN-0042", + confirm=False, + all_plans=False, + dry_run=False, ) @@ -140,8 +149,8 @@ class TestCloseSinglePlan: # 5. command == "close" with --all -> calls close_plan(all_plans=True) # ═══════════════════════════════════════════════════════════ -class TestCloseAllPlans: +class TestCloseAllPlans: @patch(f"{_MOD}.close_plan") @patch(f"{_PARSER}.parse_close_command_args", return_value=(None, False, True, False, None)) def test_all_flag_calls_close_plan_with_all(self, mock_parse, mock_close): @@ -149,7 +158,10 @@ class TestCloseAllPlans: result = handle_command("close", ["--all"]) assert result is True mock_close.assert_called_once_with( - plan_num=None, confirm=False, all_plans=True, dry_run=False, + plan_num=None, + confirm=False, + all_plans=True, + dry_run=False, ) @patch(f"{_MOD}.close_plan") @@ -159,7 +171,10 @@ class TestCloseAllPlans: result = handle_command("close", ["--all", "--confirm"]) assert result is True mock_close.assert_called_once_with( - plan_num=None, confirm=True, all_plans=True, dry_run=False, + plan_num=None, + confirm=True, + all_plans=True, + dry_run=False, ) @@ -167,8 +182,8 @@ class TestCloseAllPlans: # 6. command == "close" with --dry-run -> passes dry_run flag # ═══════════════════════════════════════════════════════════ -class TestDryRun: +class TestDryRun: @patch(f"{_MOD}.close_plan") @patch(f"{_PARSER}.parse_close_command_args", return_value=("42", False, False, True, None)) def test_dry_run_flag(self, mock_parse, mock_close): @@ -176,7 +191,10 @@ class TestDryRun: result = handle_command("close", ["--dry-run", "42"]) assert result is True mock_close.assert_called_once_with( - plan_num="42", confirm=False, all_plans=False, dry_run=True, + plan_num="42", + confirm=False, + all_plans=False, + dry_run=True, ) @patch(f"{_MOD}.close_plan") @@ -186,7 +204,10 @@ class TestDryRun: result = handle_command("close", ["--all", "--dry-run"]) assert result is True mock_close.assert_called_once_with( - plan_num=None, confirm=False, all_plans=True, dry_run=True, + plan_num=None, + confirm=False, + all_plans=True, + dry_run=True, ) @@ -194,11 +215,13 @@ class TestDryRun: # 7. Parse error -> displays usage error, returns True # ═══════════════════════════════════════════════════════════ -class TestParseError: +class TestParseError: @patch(f"{_MOD}.format_delete_usage_error", return_value="Usage error text") @patch(f"{_MOD}.close_plan") - @patch(f"{_PARSER}.parse_close_command_args", return_value=(None, False, False, False, "Plan number or --all required")) + @patch( + f"{_PARSER}.parse_close_command_args", return_value=(None, False, False, False, "Plan number or --all required") + ) def test_parse_error_returns_true(self, mock_parse, mock_close, mock_format): """Parse error is still a handled command.""" handle_command = _import_handle_command() @@ -208,7 +231,9 @@ class TestParseError: @patch(f"{_MOD}.format_delete_usage_error", return_value="Usage error text") @patch(f"{_MOD}.close_plan") - @patch(f"{_PARSER}.parse_close_command_args", return_value=(None, False, False, False, "Plan number or --all required")) + @patch( + f"{_PARSER}.parse_close_command_args", return_value=(None, False, False, False, "Plan number or --all required") + ) def test_parse_error_shows_usage(self, mock_parse, mock_close, mock_format): handle_command = _import_handle_command() result = handle_command("close", ["--unknown-flag"]) @@ -220,8 +245,8 @@ class TestParseError: # 8. close_plan orchestrator delegates to close_plan_impl # ═══════════════════════════════════════════════════════════ -class TestClosePlanOrchestrator: +class TestClosePlanOrchestrator: @patch(f"{_MOD}.close_plan_impl", return_value={"success": True, "messages": []}) def test_close_plan_impl_success(self, mock_impl): close_plan = _import_close_plan() @@ -247,8 +272,8 @@ class TestClosePlanOrchestrator: # 9. close_all_plans delegates to close_all_plans_impl # ═══════════════════════════════════════════════════════════ -class TestCloseAllOrchestrator: +class TestCloseAllOrchestrator: @patch(f"{_MOD}.close_all_plans_impl", return_value={"success": True, "messages": []}) def test_close_all_success(self, mock_impl): close_all_plans = _import_close_all_plans() diff --git a/src/aipass/flow/tests/test_command_parser.py b/src/aipass/flow/tests/test_command_parser.py index beff1e09..5e991297 100644 --- a/src/aipass/flow/tests/test_command_parser.py +++ b/src/aipass/flow/tests/test_command_parser.py @@ -3,6 +3,7 @@ Covers parse_create_plan_args, parse_close_command_args, and parse_restore_command_args from apps/handlers/plan/command_parser.py. """ + from unittest.mock import patch @@ -65,9 +66,7 @@ class TestParseCreatePlanArgs: def test_dplan_type_resolves_to_dev_plans(self, _mock_type_map): from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args - location, subject, plan_type_key = parse_create_plan_args( - ["@flow", "Dev work", "dplan"] - ) + location, subject, plan_type_key = parse_create_plan_args(["@flow", "Dev work", "dplan"]) assert location == "@flow" assert subject == "Dev work" assert plan_type_key == "dev_plans" @@ -79,9 +78,7 @@ class TestParseCreatePlanArgs: def test_master_type_resolves_to_master(self, _mock_type_map): from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args - _, _, plan_type_key = parse_create_plan_args( - ["@flow", "Important task", "master"] - ) + _, _, plan_type_key = parse_create_plan_args(["@flow", "Important task", "master"]) assert plan_type_key == "master" @patch( @@ -91,9 +88,7 @@ class TestParseCreatePlanArgs: def test_unknown_type_passed_through(self, _mock_type_map): from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args - _, _, plan_type_key = parse_create_plan_args( - ["@flow", "Experiment", "custom_thing"] - ) + _, _, plan_type_key = parse_create_plan_args(["@flow", "Experiment", "custom_thing"]) # Not in the type map, so the raw value is returned as-is assert plan_type_key == "custom_thing" @@ -104,9 +99,7 @@ class TestParseCreatePlanArgs: def test_type_resolution_is_case_insensitive(self, _mock_type_map): from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args - _, _, plan_type_key = parse_create_plan_args( - ["@flow", "Subject", "DPLAN"] - ) + _, _, plan_type_key = parse_create_plan_args(["@flow", "Subject", "DPLAN"]) assert plan_type_key == "dev_plans" @patch( @@ -116,9 +109,7 @@ class TestParseCreatePlanArgs: def test_default_keyword_resolves_to_flow_plans(self, _mock_type_map): from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args - _, _, plan_type_key = parse_create_plan_args( - ["@flow", "Subject", "default"] - ) + _, _, plan_type_key = parse_create_plan_args(["@flow", "Subject", "default"]) assert plan_type_key == "flow_plans" @patch( @@ -128,9 +119,7 @@ class TestParseCreatePlanArgs: def test_fallback_type_map_on_registry_error(self, _mock_type_map): from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args - location, subject, plan_type_key = parse_create_plan_args( - ["@flow", "Fallback test", "dplan"] - ) + location, subject, plan_type_key = parse_create_plan_args(["@flow", "Fallback test", "dplan"]) assert location == "@flow" assert subject == "Fallback test" assert plan_type_key == "dev_plans" @@ -215,9 +204,7 @@ class TestParseCloseCommandArgs: def test_all_flag(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["--all"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["--all"]) assert plan_num is None assert confirm is False assert all_plans is True @@ -227,9 +214,7 @@ class TestParseCloseCommandArgs: def test_confirm_flag(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["42", "--confirm"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["42", "--confirm"]) assert plan_num == "42" assert confirm is True assert all_plans is False @@ -246,9 +231,7 @@ class TestParseCloseCommandArgs: def test_dry_run_flag(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["42", "--dry-run"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["42", "--dry-run"]) assert plan_num == "42" assert confirm is False assert all_plans is False @@ -265,9 +248,7 @@ class TestParseCloseCommandArgs: def test_all_with_confirm(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["--all", "--confirm"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["--all", "--confirm"]) assert plan_num is None assert confirm is True assert all_plans is True @@ -277,9 +258,7 @@ class TestParseCloseCommandArgs: def test_all_with_dry_run(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["--all", "--dry-run"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["--all", "--dry-run"]) assert plan_num is None assert confirm is False assert all_plans is True @@ -289,9 +268,7 @@ class TestParseCloseCommandArgs: def test_all_with_preview(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - _, _, all_plans, dry_run, error = parse_close_command_args( - ["--all", "--preview"] - ) + _, _, all_plans, dry_run, error = parse_close_command_args(["--all", "--preview"]) assert all_plans is True assert dry_run is True assert error is None @@ -299,9 +276,7 @@ class TestParseCloseCommandArgs: def test_all_confirm_dry_run_combined(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["--all", "--confirm", "--dry-run"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["--all", "--confirm", "--dry-run"]) assert plan_num is None assert confirm is True assert all_plans is True @@ -311,9 +286,7 @@ class TestParseCloseCommandArgs: def test_yes_flag_is_redundant(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["42", "--yes"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["42", "--yes"]) assert plan_num == "42" # --yes does NOT set confirm (it's for backward compat, auto-confirm is default) assert confirm is False @@ -330,9 +303,7 @@ class TestParseCloseCommandArgs: def test_plan_number_with_all_flags(self): from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args - plan_num, confirm, all_plans, dry_run, error = parse_close_command_args( - ["7", "--confirm", "--dry-run"] - ) + plan_num, confirm, all_plans, dry_run, error = parse_close_command_args(["7", "--confirm", "--dry-run"]) assert plan_num == "7" assert confirm is True assert all_plans is False diff --git a/src/aipass/flow/tests/test_create_plan.py b/src/aipass/flow/tests/test_create_plan.py index 68572349..e5ed528d 100644 --- a/src/aipass/flow/tests/test_create_plan.py +++ b/src/aipass/flow/tests/test_create_plan.py @@ -3,16 +3,17 @@ from unittest.mock import patch - # ─── Patch targets ─────────────────────────────────────── _MOD = "aipass.flow.apps.modules.create_plan" # ─── Helpers ───────────────────────────────────────────── + def _import_handle_command(): """Import handle_command inside each test so autouse mocks are active.""" from aipass.flow.apps.modules.create_plan import handle_command + return handle_command @@ -20,8 +21,8 @@ def _import_handle_command(): # 1. Command != "create" -> returns False # ═══════════════════════════════════════════════════════════ -class TestCommandRouting: +class TestCommandRouting: def test_wrong_command_returns_false(self): handle_command = _import_handle_command() assert handle_command("delete", []) is False @@ -39,8 +40,8 @@ class TestCommandRouting: # 2. command == "create" with no args -> introspection # ═══════════════════════════════════════════════════════════ -class TestIntrospection: +class TestIntrospection: @patch(f"{_MOD}.print_introspection") def test_no_args_calls_introspection(self, mock_introspection): handle_command = _import_handle_command() @@ -62,8 +63,8 @@ class TestIntrospection: # 3. command == "create" with --help -> help # ═══════════════════════════════════════════════════════════ -class TestHelp: +class TestHelp: @patch(f"{_MOD}.print_help") def test_help_flag(self, mock_help): handle_command = _import_handle_command() @@ -90,8 +91,8 @@ class TestHelp: # 4. command == "create" with valid args -> calls create_plan # ═══════════════════════════════════════════════════════════ -class TestValidArgs: +class TestValidArgs: @patch(f"{_MOD}.display_plan_result", return_value="[green]OK[/green]") @patch(f"{_MOD}.create_plan", return_value=(True, 1, ".", "default", "")) @patch(f"{_MOD}.get_plan_type", return_value={"prefix": "FPLAN", "digits": 4, "default_template": "default"}) @@ -111,7 +112,8 @@ class TestValidArgs: result = handle_command("create", [".", "My Plan"]) assert result is True # Command was handled mock_create.assert_called_once_with( - ".", "My Plan", + ".", + "My Plan", plan_type_key="flow_plans", plan_type_config={"prefix": "FPLAN", "digits": 4, "default_template": "default"}, ) @@ -125,8 +127,13 @@ class TestValidArgs: result = handle_command("create", [".", "My Plan"]) assert result is True # Command was handled mock_display.assert_called_once_with( - True, 1, ".", "default", "", - prefix="FPLAN", digits=4, + True, + 1, + ".", + "default", + "", + prefix="FPLAN", + digits=4, ) @patch(f"{_MOD}.display_plan_result", return_value="[green]OK[/green]") @@ -139,7 +146,8 @@ class TestValidArgs: result = handle_command("create", [".", "My Plan", "dplan"]) assert result is True mock_create.assert_called_once_with( - ".", "My Plan", + ".", + "My Plan", plan_type_key="dev_plans", plan_type_config={"prefix": "DPLAN", "digits": 4, "default_template": "default"}, ) @@ -149,8 +157,8 @@ class TestValidArgs: # 5. Invalid plan type -> error displayed, returns True # ═══════════════════════════════════════════════════════════ -class TestInvalidPlanType: +class TestInvalidPlanType: @patch(f"{_MOD}.parse_create_plan_args", return_value=(".", "My Plan", "bad_type")) @patch(f"{_MOD}.get_plan_type", side_effect=ValueError("Unknown plan type 'bad_type'")) def test_invalid_type_returns_true(self, mock_get_type, mock_parse): @@ -173,8 +181,8 @@ class TestInvalidPlanType: # 6. json_handler.log_operation is called on valid commands # ═══════════════════════════════════════════════════════════ -class TestOperationLogging: +class TestOperationLogging: @patch(f"{_MOD}.display_plan_result", return_value="[green]OK[/green]") @patch(f"{_MOD}.create_plan", return_value=(True, 1, ".", "default", "")) @patch(f"{_MOD}.get_plan_type", return_value={"prefix": "FPLAN", "digits": 4, "default_template": "default"}) diff --git a/src/aipass/flow/tests/test_display.py b/src/aipass/flow/tests/test_display.py index d0a96ab3..ea14e349 100644 --- a/src/aipass/flow/tests/test_display.py +++ b/src/aipass/flow/tests/test_display.py @@ -1,13 +1,13 @@ """Tests for plan display handler -- formatting and display functions.""" - - # ─── Helpers ───────────────────────────────────────────── + def _import(name: str): """Import a function from display module inside test scope.""" import aipass.flow.apps.handlers.plan.display as mod + return getattr(mod, name) @@ -15,8 +15,8 @@ def _import(name: str): # 1. display_plan_created # ═══════════════════════════════════════════════════════════ -class TestDisplayPlanCreated: +class TestDisplayPlanCreated: def test_basic_output(self): fn = _import("display_plan_created") result = fn(plan_num=1, relative_location="flow", subject="My plan", template_type="default") @@ -27,7 +27,9 @@ class TestDisplayPlanCreated: def test_custom_prefix_and_digits(self): fn = _import("display_plan_created") - result = fn(plan_num=42, relative_location="dev", subject="Dev plan", template_type="sprint", prefix="DPLAN", digits=6) + result = fn( + plan_num=42, relative_location="dev", subject="Dev plan", template_type="sprint", prefix="DPLAN", digits=6 + ) assert "DPLAN-000042" in result assert "dev" in result @@ -55,8 +57,8 @@ class TestDisplayPlanCreated: # 2. display_plan_result # ═══════════════════════════════════════════════════════════ -class TestDisplayPlanResult: +class TestDisplayPlanResult: def test_success_result(self): fn = _import("display_plan_result") result = fn(success=True, plan_num=3, location="flow", template_type="default", error="") @@ -72,7 +74,9 @@ class TestDisplayPlanResult: def test_custom_prefix_success(self): fn = _import("display_plan_result") - result = fn(success=True, plan_num=10, location="dev", template_type="sprint", error="", prefix="DPLAN", digits=6) + result = fn( + success=True, plan_num=10, location="dev", template_type="sprint", error="", prefix="DPLAN", digits=6 + ) assert "DPLAN-000010" in result def test_failure_ignores_plan_details(self): @@ -86,8 +90,8 @@ class TestDisplayPlanResult: # 3. format_plan_deletion_header # ═══════════════════════════════════════════════════════════ -class TestFormatPlanDeletionHeader: +class TestFormatPlanDeletionHeader: def test_basic_header(self): fn = _import("format_plan_deletion_header") plan_info = { @@ -118,8 +122,8 @@ class TestFormatPlanDeletionHeader: # 4. format_plan_error # ═══════════════════════════════════════════════════════════ -class TestFormatPlanError: +class TestFormatPlanError: def test_not_found(self): fn = _import("format_plan_error") result = fn("not_found", plan_num="0001") @@ -150,8 +154,8 @@ class TestFormatPlanError: # 5. format_plan_deletion_success # ═══════════════════════════════════════════════════════════ -class TestFormatPlanDeletionSuccess: +class TestFormatPlanDeletionSuccess: def test_default_prefix(self): fn = _import("format_plan_deletion_success") result = fn("0001") @@ -167,8 +171,8 @@ class TestFormatPlanDeletionSuccess: # 6. format_deletion_cancelled # ═══════════════════════════════════════════════════════════ -class TestFormatDeletionCancelled: +class TestFormatDeletionCancelled: def test_output(self): fn = _import("format_deletion_cancelled") assert fn() == "Deletion cancelled" @@ -178,8 +182,8 @@ class TestFormatDeletionCancelled: # 7. format_delete_usage_error # ═══════════════════════════════════════════════════════════ -class TestFormatDeleteUsageError: +class TestFormatDeleteUsageError: def test_contains_usage_instructions(self): fn = _import("format_delete_usage_error") result = fn() @@ -192,8 +196,8 @@ class TestFormatDeleteUsageError: # 8. format_restore_header # ═══════════════════════════════════════════════════════════ -class TestFormatRestoreHeader: +class TestFormatRestoreHeader: def test_basic_header(self): fn = _import("format_restore_header") plan_info = { @@ -227,8 +231,8 @@ class TestFormatRestoreHeader: # 9. format_restore_success # ═══════════════════════════════════════════════════════════ -class TestFormatRestoreSuccess: +class TestFormatRestoreSuccess: def test_with_location(self): fn = _import("format_restore_success") result = fn("0001", restored_location="/home/user/plans") @@ -251,8 +255,8 @@ class TestFormatRestoreSuccess: # 10. format_restore_error # ═══════════════════════════════════════════════════════════ -class TestFormatRestoreError: +class TestFormatRestoreError: def test_not_found(self): fn = _import("format_restore_error") assert "not found" in fn("not_found", plan_key="0001") @@ -286,8 +290,8 @@ class TestFormatRestoreError: # 11. format_restore_usage_error # ═══════════════════════════════════════════════════════════ -class TestFormatRestoreUsageError: +class TestFormatRestoreUsageError: def test_contains_usage(self): fn = _import("format_restore_usage_error") result = fn() @@ -299,8 +303,8 @@ class TestFormatRestoreUsageError: # 12. format_plan_info # ═══════════════════════════════════════════════════════════ -class TestFormatPlanInfo: +class TestFormatPlanInfo: def test_basic_plan_info(self): fn = _import("format_plan_info") plan_info = { @@ -362,8 +366,8 @@ class TestFormatPlanInfo: # 13. format_plans_list # ═══════════════════════════════════════════════════════════ -class TestFormatPlansList: +class TestFormatPlansList: def test_empty_plans(self): fn = _import("format_plans_list") result = fn({}) @@ -423,8 +427,8 @@ class TestFormatPlansList: # 14. format_statistics_summary # ═══════════════════════════════════════════════════════════ -class TestFormatStatisticsSummary: +class TestFormatStatisticsSummary: def test_basic_stats(self): fn = _import("format_statistics_summary") stats = {"total_plans": 10, "open_plans": 7, "closed_plans": 3, "other_plans": 0} diff --git a/src/aipass/flow/tests/test_json_handler.py b/src/aipass/flow/tests/test_json_handler.py index 5e0d3053..315e3c9f 100644 --- a/src/aipass/flow/tests/test_json_handler.py +++ b/src/aipass/flow/tests/test_json_handler.py @@ -18,9 +18,11 @@ import pytest # Helpers # --------------------------------------------------------------------------- + def _import_handler(): """Import json_handler inside test so autouse mocks are active.""" from aipass.flow.apps.handlers.json import json_handler + return json_handler @@ -38,9 +40,7 @@ def sample_data(): "created": "2026-03-27", "last_updated": "2026-03-27", }, - "log": [ - {"timestamp": "2026-03-27T10:00:00", "operation": "test"} - ], + "log": [{"timestamp": "2026-03-27T10:00:00", "operation": "test"}], } @@ -48,6 +48,7 @@ def sample_data(): # 1. _default_template -- default factory for JSON types # ═══════════════════════════════════════════════════════════ + class TestDefaultTemplate: """Tests for _create_default template factory.""" @@ -85,6 +86,7 @@ class TestDefaultTemplate: # 2. validate_json_structure # ═══════════════════════════════════════════════════════════ + class TestValidateJsonStructure: """Tests for validate_json_structure.""" @@ -121,6 +123,7 @@ class TestValidateJsonStructure: # 3. get_json_path -- path construction # ═══════════════════════════════════════════════════════════ + class TestGetJsonPath: """Tests for get_json_path -- returns pathlib.Path.""" @@ -144,13 +147,16 @@ class TestGetJsonPath: # 4. ensure_json_exists -- auto-creates files and dirs # ═══════════════════════════════════════════════════════════ + class TestEnsureJsonExists: """Tests for ensure_json_exists -- auto_creates_dir, no_overwrite.""" def test_creates_new_file(self, tmp_path): handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=tmp_path / "test_config.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=tmp_path / "test_config.json"), + ): result = handler.ensure_json_exists("test", "config") assert result is True assert (tmp_path / "test_config.json").exists() @@ -158,8 +164,10 @@ class TestEnsureJsonExists: def test_auto_creates_dir_via_mkdir(self, tmp_path): handler = _import_handler() new_dir = tmp_path / "new_subdir" - with patch.object(handler, "FLOW_JSON_DIR", new_dir), \ - patch.object(handler, "get_json_path", return_value=new_dir / "test_config.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", new_dir), + patch.object(handler, "get_json_path", return_value=new_dir / "test_config.json"), + ): result = handler.ensure_json_exists("test", "config") assert result is True # mkdir was called (dir now exists) @@ -169,14 +177,13 @@ class TestEnsureJsonExists: """already_exists valid file is not overwritten.""" handler = _import_handler() existing = tmp_path / "test_config.json" - original_data = { - "module_name": "test", "version": "1.0.0", - "config": {"custom": True}, "created": "2026-01-01" - } + original_data = {"module_name": "test", "version": "1.0.0", "config": {"custom": True}, "created": "2026-01-01"} existing.write_text(json.dumps(original_data), encoding="utf-8") - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=existing): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=existing), + ): result = handler.ensure_json_exists("test", "config") assert result is True # Verify original data preserved (no overwrite) @@ -185,8 +192,10 @@ class TestEnsureJsonExists: def test_returns_false_for_unknown_type(self, tmp_path): handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=tmp_path / "test_bad.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=tmp_path / "test_bad.json"), + ): # nonexistent type has no template result = handler.ensure_json_exists("test", "nonexistent") assert result is False @@ -196,27 +205,34 @@ class TestEnsureJsonExists: # 5. load_json -- loads with auto-create # ═══════════════════════════════════════════════════════════ + class TestLoadJson: """Tests for load_json -- returns dict or list.""" def test_load_config_returns_dict(self, tmp_path): handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=tmp_path / "t_config.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=tmp_path / "t_config.json"), + ): result = handler.load_json("t", "config") assert isinstance(result, dict) def test_load_log_returns_list(self, tmp_path): handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=tmp_path / "t_log.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=tmp_path / "t_log.json"), + ): result = handler.load_json("t", "log") assert isinstance(result, list) def test_load_returns_none_for_bad_type(self, tmp_path): handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=tmp_path / "t_bad.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=tmp_path / "t_bad.json"), + ): result = handler.load_json("t", "nonexistent") assert result is None @@ -225,6 +241,7 @@ class TestLoadJson: # 6. save_json -- validation and persistence # ═══════════════════════════════════════════════════════════ + class TestSaveJson: """Tests for save_json -- validates before writing.""" @@ -257,13 +274,16 @@ class TestSaveJson: # 7. ensure_module_jsons -- ensures all 3 types # ═══════════════════════════════════════════════════════════ + class TestEnsureModuleJsons: """Tests for ensure_module_jsons.""" def test_returns_true(self, tmp_path): handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "ensure_json_exists", return_value=True) as mock_ensure: + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "ensure_json_exists", return_value=True) as mock_ensure, + ): result = handler.ensure_module_jsons("test_mod") assert result is True assert mock_ensure.call_count == 3 @@ -273,6 +293,7 @@ class TestEnsureModuleJsons: # 8. Error resilience # ═══════════════════════════════════════════════════════════ + class TestErrorResilience: """Tests for error handling across JSON operations.""" @@ -281,8 +302,10 @@ class TestErrorResilience: handler = _import_handler() target = tmp_path / "missing_config.json" assert not target.exists() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=target): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=target), + ): result = handler.load_json("missing", "config") assert result is not None @@ -291,8 +314,10 @@ class TestErrorResilience: handler = _import_handler() target = tmp_path / "corrupt_config.json" target.write_text("{invalid json content", encoding="utf-8") - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=target): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=target), + ): result = handler.ensure_json_exists("corrupt", "config") assert result is True @@ -301,8 +326,10 @@ class TestErrorResilience: handler = _import_handler() target = tmp_path / "empty_config.json" target.write_text("", encoding="utf-8") - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=target): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=target), + ): result = handler.ensure_json_exists("empty", "config") assert result is True @@ -311,8 +338,10 @@ class TestErrorResilience: handler = _import_handler() deep_dir = tmp_path / "nonexistent" / "subdir" target = deep_dir / "test_config.json" - with patch.object(handler, "FLOW_JSON_DIR", deep_dir), \ - patch.object(handler, "get_json_path", return_value=target): + with ( + patch.object(handler, "FLOW_JSON_DIR", deep_dir), + patch.object(handler, "get_json_path", return_value=target), + ): result = handler.ensure_json_exists("test", "config") assert result is True assert deep_dir.exists() @@ -322,6 +351,7 @@ class TestErrorResilience: # 9. Return type contracts # ═══════════════════════════════════════════════════════════ + class TestReturnTypeContracts: """Verify return types match contracts.""" @@ -334,8 +364,10 @@ class TestReturnTypeContracts: def test_load_json_returns_correct_type(self, tmp_path): """load_correct_type -- loaded config is a dict.""" handler = _import_handler() - with patch.object(handler, "FLOW_JSON_DIR", tmp_path), \ - patch.object(handler, "get_json_path", return_value=tmp_path / "t_config.json"): + with ( + patch.object(handler, "FLOW_JSON_DIR", tmp_path), + patch.object(handler, "get_json_path", return_value=tmp_path / "t_config.json"), + ): data = handler.load_json("t", "config") assert isinstance(data, dict) @@ -344,6 +376,7 @@ class TestReturnTypeContracts: # 10. Exception contracts # ═══════════════════════════════════════════════════════════ + class TestExceptionContracts: """Verify exception behavior.""" @@ -362,10 +395,7 @@ class TestExceptionContracts: def test_save_json_write_error_raises_handling(self, tmp_path): """pytest.raises contract: save_json handles write errors gracefully.""" handler = _import_handler() - valid_config = { - "module_name": "t", "version": "1.0.0", - "config": {}, "created": "2026-01-01" - } + valid_config = {"module_name": "t", "version": "1.0.0", "config": {}, "created": "2026-01-01"} bad_path = tmp_path / "no_exist_dir" / "sub" / "x.json" with patch.object(handler, "get_json_path", return_value=bad_path): result = handler.save_json("t", "config", valid_config) @@ -376,6 +406,7 @@ class TestExceptionContracts: # 11. Infrastructure mocking -- module reload patterns # ═══════════════════════════════════════════════════════════ + class TestInfrastructureMocking: """Tests demonstrating sys.modules and importlib.reload patterns.""" @@ -395,6 +426,7 @@ class TestInfrastructureMocking: # 12. Output capture # ═══════════════════════════════════════════════════════════ + class TestOutputCapture: """Tests using capsys for output verification.""" diff --git a/src/aipass/flow/tests/test_list_plans.py b/src/aipass/flow/tests/test_list_plans.py index feb81603..fd566eeb 100644 --- a/src/aipass/flow/tests/test_list_plans.py +++ b/src/aipass/flow/tests/test_list_plans.py @@ -87,9 +87,7 @@ class TestHandleCommandRouting: def test_unknown_filter_defaults_to_open_with_warning(self): """Unknown filter arg should default to 'open' and emit a warning.""" - with patch(f"{_MOD}.list_plans") as mock_lp, \ - patch(f"{_MOD}.warning") as mock_warn, \ - patch(f"{_MOD}.console"): + with patch(f"{_MOD}.list_plans") as mock_lp, patch(f"{_MOD}.warning") as mock_warn, patch(f"{_MOD}.console"): from aipass.flow.apps.modules.list_plans import handle_command result = handle_command("list", ["garbage"]) @@ -101,8 +99,7 @@ class TestHandleCommandRouting: def test_json_handler_called_on_filter_commands(self): """json_handler.log_operation should be called for filter commands.""" - with patch(f"{_MOD}.list_plans"), \ - patch(f"{_MOD}.json_handler") as mock_jh: + with patch(f"{_MOD}.list_plans"), patch(f"{_MOD}.json_handler") as mock_jh: from aipass.flow.apps.modules.list_plans import handle_command result = handle_command("list", ["open"]) @@ -132,8 +129,10 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result) as mock_impl, \ - patch(f"{_MOD}.console") as mock_console: + with ( + patch(f"{_MOD}.list_plans_impl", return_value=mock_result) as mock_impl, + patch(f"{_MOD}.console") as mock_console, + ): from aipass.flow.apps.modules.list_plans import list_plans result = list_plans("open") @@ -155,8 +154,7 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), \ - patch(f"{_MOD}.warning") as mock_warn: + with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), patch(f"{_MOD}.warning") as mock_warn: from aipass.flow.apps.modules.list_plans import list_plans result = list_plans("all") @@ -175,8 +173,7 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), \ - patch(f"{_MOD}.error") as mock_error: + with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), patch(f"{_MOD}.error") as mock_error: from aipass.flow.apps.modules.list_plans import list_plans result = list_plans("open") @@ -195,8 +192,7 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), \ - patch(f"{_MOD}.error") as mock_error: + with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), patch(f"{_MOD}.error") as mock_error: from aipass.flow.apps.modules.list_plans import list_plans result = list_plans("open") @@ -214,11 +210,13 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result) as mock_impl, \ - patch(f"{_MOD}.load_registry") as mock_lr, \ - patch(f"{_MOD}.get_registry_statistics") as mock_gs, \ - patch(f"{_MOD}.format_plans_list") as mock_fpl, \ - patch(f"{_MOD}.format_statistics_summary") as mock_fss: + with ( + patch(f"{_MOD}.list_plans_impl", return_value=mock_result) as mock_impl, + patch(f"{_MOD}.load_registry") as mock_lr, + patch(f"{_MOD}.get_registry_statistics") as mock_gs, + patch(f"{_MOD}.format_plans_list") as mock_fpl, + patch(f"{_MOD}.format_statistics_summary") as mock_fss, + ): from aipass.flow.apps.modules.list_plans import list_plans list_plans("closed") @@ -241,8 +239,7 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), \ - patch(f"{_MOD}.console") as mock_console: + with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), patch(f"{_MOD}.console") as mock_console: mock_console.print.side_effect = BrokenPipeError("pipe closed") from aipass.flow.apps.modules.list_plans import list_plans @@ -262,8 +259,7 @@ class TestListPlansOrchestrator: "filter_type": "open", } - with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), \ - patch(f"{_MOD}.error") as mock_error: + with patch(f"{_MOD}.list_plans_impl", return_value=mock_result), patch(f"{_MOD}.error") as mock_error: mock_error.side_effect = BrokenPipeError("pipe closed") from aipass.flow.apps.modules.list_plans import list_plans diff --git a/src/aipass/flow/tests/test_mbank_template.py b/src/aipass/flow/tests/test_mbank_template.py index f9d18e9f..7718ae4c 100644 --- a/src/aipass/flow/tests/test_mbank_template.py +++ b/src/aipass/flow/tests/test_mbank_template.py @@ -13,7 +13,6 @@ import pytest class TestLoadFlowRegistry: - def test_loads_valid_registry(self, tmp_path): """Load a valid JSON registry file and return its contents.""" registry_data = { @@ -24,10 +23,9 @@ class TestLoadFlowRegistry: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry_data), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), ): from aipass.flow.apps.handlers.mbank.process import load_flow_registry @@ -40,13 +38,9 @@ class TestLoadFlowRegistry: def test_loads_named_registry_file(self, tmp_path): """When registry_file is given, load from FLOW_JSON_DIR / registry_file.""" data = {"plans": {}, "next_number": 1} - (tmp_path / "dplan_registry.json").write_text( - json.dumps(data), encoding="utf-8" - ) + (tmp_path / "dplan_registry.json").write_text(json.dumps(data), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ): + with patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path): from aipass.flow.apps.handlers.mbank.process import load_flow_registry result = load_flow_registry(registry_file="dplan_registry.json") @@ -57,10 +51,9 @@ class TestLoadFlowRegistry: """Raise Exception when registry file does not exist.""" missing = tmp_path / "nonexistent.json" - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", missing + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", missing), ): from aipass.flow.apps.handlers.mbank.process import load_flow_registry @@ -72,10 +65,9 @@ class TestLoadFlowRegistry: bad_file = tmp_path / "fplan_registry.json" bad_file.write_text("{not valid json", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", bad_file + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", bad_file), ): from aipass.flow.apps.handlers.mbank.process import load_flow_registry @@ -89,16 +81,14 @@ class TestLoadFlowRegistry: class TestSaveFlowRegistry: - def test_saves_registry_with_last_updated(self, tmp_path): """Save registry and verify last_updated is set.""" reg_file = tmp_path / "fplan_registry.json" data = {"next_number": 5, "plans": {}} - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), ): from aipass.flow.apps.handlers.mbank.process import save_flow_registry @@ -114,27 +104,24 @@ class TestSaveFlowRegistry: """When registry_file arg is given, save to that filename inside FLOW_JSON_DIR.""" data = {"next_number": 1, "plans": {"1": {"status": "open"}}} - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ): + with patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path): from aipass.flow.apps.handlers.mbank.process import save_flow_registry save_flow_registry(data, registry_file="dplan_registry.json") - saved = json.loads( - (tmp_path / "dplan_registry.json").read_text(encoding="utf-8") - ) + saved = json.loads((tmp_path / "dplan_registry.json").read_text(encoding="utf-8")) assert saved["plans"]["1"]["status"] == "open" def test_raises_on_write_failure(self, tmp_path): """Raise Exception when the target path is not writable.""" bad_path = tmp_path / "no_such_dir" / "sub" / "fplan_registry.json" - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", - tmp_path / "no_such_dir" / "sub", - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", bad_path + with ( + patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", + tmp_path / "no_such_dir" / "sub", + ), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", bad_path), ): from aipass.flow.apps.handlers.mbank.process import save_flow_registry @@ -148,7 +135,6 @@ class TestSaveFlowRegistry: class TestGetClosedPlans: - def test_returns_closed_unprocessed_plans(self, tmp_path): """Return only closed, unprocessed plans whose files exist.""" plan_file = tmp_path / "FPLAN-0002.md" @@ -166,17 +152,17 @@ class TestGetClosedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], - ), patch( - "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", - return_value={"orphans_found": 0, "successfully_healed": 0, - "failed_to_heal": 0, "orphans": []}, + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), + patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + return_value={"orphans_found": 0, "successfully_healed": 0, "failed_to_heal": 0, "orphans": []}, + ), ): from aipass.flow.apps.handlers.mbank.process import get_closed_plans @@ -203,17 +189,17 @@ class TestGetClosedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], - ), patch( - "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", - return_value={"orphans_found": 0, "successfully_healed": 0, - "failed_to_heal": 0, "orphans": []}, + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), + patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + return_value={"orphans_found": 0, "successfully_healed": 0, "failed_to_heal": 0, "orphans": []}, + ), ): from aipass.flow.apps.handlers.mbank.process import get_closed_plans @@ -234,17 +220,17 @@ class TestGetClosedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], - ), patch( - "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", - return_value={"orphans_found": 0, "successfully_healed": 0, - "failed_to_heal": 0, "orphans": []}, + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), + patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + return_value={"orphans_found": 0, "successfully_healed": 0, "failed_to_heal": 0, "orphans": []}, + ), ): from aipass.flow.apps.handlers.mbank.process import get_closed_plans @@ -259,20 +245,20 @@ class TestGetClosedPlans: reg_file.write_text(json.dumps(registry), encoding="utf-8") mock_heal = MagicMock( - return_value={"orphans_found": 0, "successfully_healed": 0, - "failed_to_heal": 0, "orphans": []} + return_value={"orphans_found": 0, "successfully_healed": 0, "failed_to_heal": 0, "orphans": []} ) - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], - ), patch( - "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", - mock_heal, + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), + patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + mock_heal, + ), ): from aipass.flow.apps.handlers.mbank.process import get_closed_plans @@ -287,7 +273,6 @@ class TestGetClosedPlans: class TestCleanupTempFiles: - def test_deletes_temp_files(self, tmp_path): """Delete -TEMP- files from MEMORY_PATH and report counts.""" memory_dir = tmp_path / "memory" @@ -296,9 +281,7 @@ class TestCleanupTempFiles: (memory_dir / "other-TEMP-20260302.md").write_text("t", encoding="utf-8") (memory_dir / "real-plan-20260303.md").write_text("keep", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir - ): + with patch("aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir): from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files result = cleanup_temp_files() @@ -315,9 +298,7 @@ class TestCleanupTempFiles: memory_dir.mkdir() (memory_dir / "normal-plan.md").write_text("ok", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir - ): + with patch("aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir): from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files result = cleanup_temp_files() @@ -329,9 +310,7 @@ class TestCleanupTempFiles: """Return zeros when MEMORY_PATH does not exist.""" nonexistent = tmp_path / "no_such_dir" - with patch( - "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", nonexistent - ): + with patch("aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", nonexistent): from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files result = cleanup_temp_files() @@ -346,10 +325,9 @@ class TestCleanupTempFiles: temp_file = memory_dir / "broken-TEMP-20260301.md" temp_file.write_text("t", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir - ), patch.object( - Path, "unlink", side_effect=PermissionError("denied") + with ( + patch("aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir), + patch.object(Path, "unlink", side_effect=PermissionError("denied")), ): from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files @@ -366,7 +344,6 @@ class TestCleanupTempFiles: class TestVerifyAndHealOrphanedPlans: - def test_heals_orphaned_closed_plan(self, tmp_path): """Move a closed plan file to processed_plans and report as healed.""" plan_file = tmp_path / "FPLAN-0010.md" @@ -384,16 +361,17 @@ class TestVerifyAndHealOrphanedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", - processed_dir, - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + processed_dir, + ), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), ): from aipass.flow.apps.handlers.mbank.process import ( verify_and_heal_orphaned_plans, @@ -418,16 +396,17 @@ class TestVerifyAndHealOrphanedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", - tmp_path / "processed", - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + tmp_path / "processed", + ), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), ): from aipass.flow.apps.handlers.mbank.process import ( verify_and_heal_orphaned_plans, @@ -450,16 +429,17 @@ class TestVerifyAndHealOrphanedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", - tmp_path / "processed", - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + tmp_path / "processed", + ), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), ): from aipass.flow.apps.handlers.mbank.process import ( verify_and_heal_orphaned_plans, @@ -486,16 +466,17 @@ class TestVerifyAndHealOrphanedPlans: reg_file = tmp_path / "fplan_registry.json" reg_file.write_text(json.dumps(registry), encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file - ), patch( - "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", - processed_dir, - ), patch( - "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", - return_value=["fplan_registry.json"], + with ( + patch("aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), + patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + processed_dir, + ), + patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), ): from aipass.flow.apps.handlers.mbank.process import ( verify_and_heal_orphaned_plans, @@ -519,18 +500,22 @@ class TestVerifyAndHealOrphanedPlans: class TestProcessClosedPlans: - def test_process_no_closed_plans(self, tmp_path): """When no closed plans exist, return success with zero processed.""" - with patch( - "aipass.flow.apps.handlers.mbank.process.get_closed_plans", - return_value=[], - ), patch( - "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", - return_value={ - "files_found": 0, "files_deleted": 0, - "failed_deletes": 0, "details": [], - }, + with ( + patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=[], + ), + patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + return_value={ + "files_found": 0, + "files_deleted": 0, + "failed_deletes": 0, + "details": [], + }, + ), ): from aipass.flow.apps.handlers.mbank.process import process_closed_plans @@ -566,25 +551,32 @@ class TestProcessClosedPlans: } ] - with patch( - "aipass.flow.apps.handlers.mbank.process.get_closed_plans", - return_value=closed_plans, - ), patch( - "aipass.flow.apps.handlers.mbank.process.archive_plan", - return_value=True, - ) as mock_archive, patch( - "aipass.flow.apps.handlers.mbank.process.load_flow_registry", - return_value=registry, - ), patch( - "aipass.flow.apps.handlers.mbank.process.save_flow_registry", - ) as mock_save, patch( - "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", - return_value={ - "files_found": 0, "files_deleted": 0, - "failed_deletes": 0, "details": [], - }, - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + with ( + patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=closed_plans, + ), + patch( + "aipass.flow.apps.handlers.mbank.process.archive_plan", + return_value=True, + ) as mock_archive, + patch( + "aipass.flow.apps.handlers.mbank.process.load_flow_registry", + return_value=registry, + ), + patch( + "aipass.flow.apps.handlers.mbank.process.save_flow_registry", + ) as mock_save, + patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + return_value={ + "files_found": 0, + "files_deleted": 0, + "failed_deletes": 0, + "details": [], + }, + ), + patch("aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file), ): from aipass.flow.apps.handlers.mbank.process import process_closed_plans @@ -622,26 +614,35 @@ class TestProcessClosedPlans: } ] - with patch( - "aipass.flow.apps.handlers.mbank.process.get_closed_plans", - return_value=closed_plans, - ), patch( - "aipass.flow.apps.handlers.mbank.process.archive_plan", - return_value=False, - ), patch( - "aipass.flow.apps.handlers.mbank.process.load_flow_registry", - return_value=registry, - ), patch( - "aipass.flow.apps.handlers.mbank.process.save_flow_registry", - ), patch( - "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", - return_value={ - "files_found": 0, "files_deleted": 0, - "failed_deletes": 0, "details": [], - }, - ), patch( - "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", - tmp_path / "fplan_registry.json", + with ( + patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=closed_plans, + ), + patch( + "aipass.flow.apps.handlers.mbank.process.archive_plan", + return_value=False, + ), + patch( + "aipass.flow.apps.handlers.mbank.process.load_flow_registry", + return_value=registry, + ), + patch( + "aipass.flow.apps.handlers.mbank.process.save_flow_registry", + ), + patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + return_value={ + "files_found": 0, + "files_deleted": 0, + "failed_deletes": 0, + "details": [], + }, + ), + patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", + tmp_path / "fplan_registry.json", + ), ): from aipass.flow.apps.handlers.mbank.process import process_closed_plans @@ -667,17 +668,24 @@ class TestProcessClosedPlans: def test_process_calls_cleanup(self, tmp_path): """cleanup_temp_files is called after processing closed plans.""" - mock_cleanup = MagicMock(return_value={ - "files_found": 1, "files_deleted": 1, - "failed_deletes": 0, "details": [], - }) + mock_cleanup = MagicMock( + return_value={ + "files_found": 1, + "files_deleted": 1, + "failed_deletes": 0, + "details": [], + } + ) - with patch( - "aipass.flow.apps.handlers.mbank.process.get_closed_plans", - return_value=[], - ), patch( - "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", - mock_cleanup, + with ( + patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=[], + ), + patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + mock_cleanup, + ), ): from aipass.flow.apps.handlers.mbank.process import process_closed_plans @@ -693,14 +701,12 @@ class TestProcessClosedPlans: class TestGetTemplate: - def test_loads_template_by_name(self, tmp_path): """Load a template by name from the templates directory.""" templates_dir = tmp_path / "templates" templates_dir.mkdir() (templates_dir / "default.md").write_text( - "# Plan {plan_number}\nSubject: {subject}\nDate: {today}\n" - "Location: {location}\nTag: {tag}", + "# Plan {plan_number}\nSubject: {subject}\nDate: {today}\nLocation: {location}\nTag: {tag}", encoding="utf-8", ) @@ -710,9 +716,7 @@ class TestGetTemplate: ): from aipass.flow.apps.handlers.template.get_template import get_template - result = get_template( - "default", number=1, location="flow", subject="Test" - ) + result = get_template("default", number=1, location="flow", subject="Test") assert "FPLAN-0001" in result assert "Test" in result @@ -728,9 +732,7 @@ class TestGetTemplate: from aipass.flow.apps.handlers.template.get_template import get_template - result = get_template( - template_path=custom, number=7, subject="Override", prefix="DPLAN" - ) + result = get_template(template_path=custom, number=7, subject="Override", prefix="DPLAN") assert "DPLAN-0007" in result assert "Override" in result @@ -742,9 +744,7 @@ class TestGetTemplate: from aipass.flow.apps.handlers.template.get_template import get_template - result = get_template( - template_path=tpl, number=3, prefix="XPLAN", digits=6 - ) + result = get_template(template_path=tpl, number=3, prefix="XPLAN", digits=6) assert result == "XPLAN-000003" @@ -785,7 +785,6 @@ class TestGetTemplate: class TestDiscoverPlanTypes: - def test_discovers_plan_types_from_filesystem(self, tmp_path): """Discover plan types from subdirectories with .md files.""" templates_dir = tmp_path / "templates" @@ -800,12 +799,15 @@ class TestDiscoverPlanTypes: prefix_map = {"flow_plans": "FPLAN", "dev_plans": "DPLAN"} - with patch( - "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", - templates_dir, - ), patch( - "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", - return_value=prefix_map, + with ( + patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), + patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value=prefix_map, + ), ): from aipass.flow.apps.handlers.template.plan_type_loader import ( discover_plan_types, @@ -813,6 +815,7 @@ class TestDiscoverPlanTypes: # Reset cache to force fresh scan import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None result = discover_plan_types() @@ -836,18 +839,22 @@ class TestDiscoverPlanTypes: underscore.mkdir() (underscore / "default.md").write_text("nope", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", - templates_dir, - ), patch( - "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", - return_value={}, + with ( + patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), + patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value={}, + ), ): from aipass.flow.apps.handlers.template.plan_type_loader import ( discover_plan_types, ) import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None result = discover_plan_types() @@ -862,18 +869,22 @@ class TestDiscoverPlanTypes: empty_type.mkdir(parents=True) (empty_type / "readme.txt").write_text("not a template", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", - templates_dir, - ), patch( - "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", - return_value={"empty_plans": "EPLAN"}, + with ( + patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), + patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value={"empty_plans": "EPLAN"}, + ), ): from aipass.flow.apps.handlers.template.plan_type_loader import ( discover_plan_types, ) import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None result = discover_plan_types() @@ -887,18 +898,22 @@ class TestDiscoverPlanTypes: unknown.mkdir(parents=True) (unknown / "default.md").write_text("tpl", encoding="utf-8") - with patch( - "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", - templates_dir, - ), patch( - "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", - return_value={}, + with ( + patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), + patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value={}, + ), ): from aipass.flow.apps.handlers.template.plan_type_loader import ( discover_plan_types, ) import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None result = discover_plan_types() @@ -916,6 +931,7 @@ class TestDiscoverPlanTypes: ) import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None result = discover_plan_types() @@ -929,15 +945,13 @@ class TestDiscoverPlanTypes: class TestPrefixExists: - def test_existing_prefix_returns_true(self, tmp_path): """prefix_exists returns True for a registered prefix.""" registry = { "types": { "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, }, - "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", - "type_count": 1}, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 1}, } reg_path = tmp_path / "template_registry.json" reg_path.write_text(json.dumps(registry), encoding="utf-8") @@ -946,11 +960,12 @@ class TestPrefixExists: templates_dir = tmp_path / "templates" / "flow_plans" templates_dir.mkdir(parents=True) - with patch( - "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", - reg_path, - ), patch( - "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + with ( + patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), + patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path), ): from aipass.flow.apps.handlers.template.registry_ops import prefix_exists @@ -962,8 +977,7 @@ class TestPrefixExists: "types": { "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, }, - "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", - "type_count": 1}, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 1}, } reg_path = tmp_path / "template_registry.json" reg_path.write_text(json.dumps(registry), encoding="utf-8") @@ -971,11 +985,12 @@ class TestPrefixExists: templates_dir = tmp_path / "templates" / "flow_plans" templates_dir.mkdir(parents=True) - with patch( - "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", - reg_path, - ), patch( - "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + with ( + patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), + patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path), ): from aipass.flow.apps.handlers.template.registry_ops import prefix_exists @@ -987,8 +1002,7 @@ class TestPrefixExists: "types": { "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, }, - "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", - "type_count": 1}, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 1}, } reg_path = tmp_path / "template_registry.json" reg_path.write_text(json.dumps(registry), encoding="utf-8") @@ -996,11 +1010,12 @@ class TestPrefixExists: templates_dir = tmp_path / "templates" / "flow_plans" templates_dir.mkdir(parents=True) - with patch( - "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", - reg_path, - ), patch( - "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + with ( + patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), + patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path), ): from aipass.flow.apps.handlers.template.registry_ops import prefix_exists @@ -1013,7 +1028,6 @@ class TestPrefixExists: class TestGetPrefixMap: - def test_returns_correct_mapping(self, tmp_path): """get_prefix_map returns {dir_name: prefix} for all registered types.""" registry = { @@ -1021,8 +1035,7 @@ class TestGetPrefixMap: "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, "dev_plans": {"prefix": "DPLAN", "shorthand": "dplan"}, }, - "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", - "type_count": 2}, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 2}, } reg_path = tmp_path / "template_registry.json" reg_path.write_text(json.dumps(registry), encoding="utf-8") @@ -1031,11 +1044,12 @@ class TestGetPrefixMap: for name in ("flow_plans", "dev_plans"): (tmp_path / "templates" / name).mkdir(parents=True) - with patch( - "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", - reg_path, - ), patch( - "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + with ( + patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), + patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path), ): from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map @@ -1050,8 +1064,7 @@ class TestGetPrefixMap: "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, "broken_type": {"shorthand": "broken"}, }, - "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", - "type_count": 2}, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 2}, } reg_path = tmp_path / "template_registry.json" reg_path.write_text(json.dumps(registry), encoding="utf-8") @@ -1059,11 +1072,12 @@ class TestGetPrefixMap: for name in ("flow_plans", "broken_type"): (tmp_path / "templates" / name).mkdir(parents=True) - with patch( - "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", - reg_path, - ), patch( - "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + with ( + patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), + patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path), ): from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map @@ -1080,11 +1094,12 @@ class TestGetPrefixMap: for name in ("flow_plans", "dev_plans"): (tmp_path / "templates" / name).mkdir(parents=True) - with patch( - "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", - reg_path, - ), patch( - "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + with ( + patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), + patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path), ): from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map diff --git a/src/aipass/flow/tests/test_monitor_registry.py b/src/aipass/flow/tests/test_monitor_registry.py index dec3624b..2b02e473 100644 --- a/src/aipass/flow/tests/test_monitor_registry.py +++ b/src/aipass/flow/tests/test_monitor_registry.py @@ -9,19 +9,20 @@ from pathlib import Path from unittest.mock import MagicMock, patch - # ─── Import helpers ─────────────────────────────────────── def _import_monitor_ops(): """Import monitor_ops module and return it.""" import aipass.flow.apps.handlers.registry.monitor_ops as mod + return mod def _import_registry_monitor(): """Import registry_monitor module and return it.""" import aipass.flow.apps.modules.registry_monitor as mod + return mod @@ -590,9 +591,14 @@ class TestRegistryMonitorStartMonitoring: """Successful start should print success message and return True.""" mod = _import_registry_monitor() mock_con = MagicMock() - with patch.object(mod, "start_monitoring_impl", - return_value={"success": True, "status": "started", "message": "Monitor started"}, - ), patch.object(mod, "console", mock_con): + with ( + patch.object( + mod, + "start_monitoring_impl", + return_value={"success": True, "status": "started", "message": "Monitor started"}, + ), + patch.object(mod, "console", mock_con), + ): result = mod.start_monitoring() assert result is True mock_con.print.assert_called() @@ -601,9 +607,14 @@ class TestRegistryMonitorStartMonitoring: """Already-running should trigger warning and return False.""" mod = _import_registry_monitor() mock_warn = MagicMock() - with patch.object(mod, "start_monitoring_impl", - return_value={"success": False, "status": "already_running", "message": "Already running"}, - ), patch.object(mod, "warning", mock_warn): + with ( + patch.object( + mod, + "start_monitoring_impl", + return_value={"success": False, "status": "already_running", "message": "Already running"}, + ), + patch.object(mod, "warning", mock_warn), + ): result = mod.start_monitoring() assert result is False mock_warn.assert_called_once_with("Monitor is already running") @@ -612,9 +623,14 @@ class TestRegistryMonitorStartMonitoring: """Error status should trigger error display and return False.""" mod = _import_registry_monitor() mock_err = MagicMock() - with patch.object(mod, "start_monitoring_impl", - return_value={"success": False, "status": "error", "message": "Observer failed"}, - ), patch.object(mod, "error", mock_err): + with ( + patch.object( + mod, + "start_monitoring_impl", + return_value={"success": False, "status": "error", "message": "Observer failed"}, + ), + patch.object(mod, "error", mock_err), + ): result = mod.start_monitoring() assert result is False mock_err.assert_called_once_with("Observer failed") @@ -627,9 +643,14 @@ class TestRegistryMonitorStopMonitoring: """Successful stop should print message and return True.""" mod = _import_registry_monitor() mock_con = MagicMock() - with patch.object(mod, "stop_monitoring_impl", - return_value={"success": True, "status": "stopped", "message": "Monitor stopped"}, - ), patch.object(mod, "console", mock_con): + with ( + patch.object( + mod, + "stop_monitoring_impl", + return_value={"success": True, "status": "stopped", "message": "Monitor stopped"}, + ), + patch.object(mod, "console", mock_con), + ): result = mod.stop_monitoring() assert result is True mock_con.print.assert_called() @@ -638,9 +659,14 @@ class TestRegistryMonitorStopMonitoring: """Stopping when not running should trigger warning and return False.""" mod = _import_registry_monitor() mock_warn = MagicMock() - with patch.object(mod, "stop_monitoring_impl", - return_value={"success": False, "status": "not_running", "message": "Not running"}, - ), patch.object(mod, "warning", mock_warn): + with ( + patch.object( + mod, + "stop_monitoring_impl", + return_value={"success": False, "status": "not_running", "message": "Not running"}, + ), + patch.object(mod, "warning", mock_warn), + ): result = mod.stop_monitoring() assert result is False mock_warn.assert_called_once_with("Monitor is not running") diff --git a/src/aipass/flow/tests/test_plan_handlers.py b/src/aipass/flow/tests/test_plan_handlers.py index e346c4f6..244db915 100644 --- a/src/aipass/flow/tests/test_plan_handlers.py +++ b/src/aipass/flow/tests/test_plan_handlers.py @@ -29,6 +29,7 @@ from aipass.flow.apps.handlers.json.json_handler import update_data_metrics # slugify_subject # ========================================================================= + class TestSlugifySubject: """Tests for slugify_subject().""" @@ -74,6 +75,7 @@ class TestSlugifySubject: # create_plan_file # ========================================================================= + class TestCreatePlanFile: """Tests for create_plan_file().""" @@ -116,6 +118,7 @@ class TestCreatePlanFile: # build_plan_registry_entry # ========================================================================= + class TestBuildPlanRegistryEntry: """Tests for build_plan_registry_entry().""" @@ -169,6 +172,7 @@ class TestBuildPlanRegistryEntry: # calculate_relative_location # ========================================================================= + class TestCalculateRelativeLocation: """Tests for calculate_relative_location().""" @@ -207,6 +211,7 @@ class TestCalculateRelativeLocation: # resolve_plan_location # ========================================================================= + class TestResolvePlanLocation: """Tests for resolve_plan_location().""" @@ -273,6 +278,7 @@ class TestResolvePlanLocation: # auto_close_orphaned_plans # ========================================================================= + class TestAutoCloseOrphanedPlans: """Tests for auto_close_orphaned_plans().""" @@ -389,6 +395,7 @@ class TestAutoCloseOrphanedPlans: # get_closed_plans # ========================================================================= + class TestGetClosedPlans: """Tests for get_closed_plans().""" @@ -398,8 +405,7 @@ class TestGetClosedPlans: def test_returns_only_closed_plans(self, mock_registry): _, registry = mock_registry - with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \ - patch(self._LOAD_PATH, return_value=registry): + with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), patch(self._LOAD_PATH, return_value=registry): result = get_closed_plans() assert len(result) == 1 @@ -413,15 +419,16 @@ class TestGetClosedPlans: "1": {"status": "open", "subject": "active"}, } } - with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \ - patch(self._LOAD_PATH, return_value=registry): + with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), patch(self._LOAD_PATH, return_value=registry): result = get_closed_plans() assert result == [] def test_returns_empty_on_empty_registry(self): - with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \ - patch(self._LOAD_PATH, return_value={"plans": {}}): + with ( + patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), + patch(self._LOAD_PATH, return_value={"plans": {}}), + ): result = get_closed_plans() assert result == [] @@ -434,8 +441,7 @@ class TestGetClosedPlans: "3": {"status": "open", "subject": "still going"}, } } - with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \ - patch(self._LOAD_PATH, return_value=registry): + with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), patch(self._LOAD_PATH, return_value=registry): result = get_closed_plans() assert len(result) == 2 @@ -444,8 +450,7 @@ class TestGetClosedPlans: def test_result_tuples_contain_plan_num_and_info(self, mock_registry): _, registry = mock_registry - with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \ - patch(self._LOAD_PATH, return_value=registry): + with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), patch(self._LOAD_PATH, return_value=registry): result = get_closed_plans() for plan_num, plan_info in result: @@ -458,6 +463,7 @@ class TestGetClosedPlans: # update_data_metrics (json_handler) # ========================================================================= + class TestUpdateDataMetrics: """Tests for update_data_metrics() in json_handler.""" @@ -499,12 +505,15 @@ class TestUpdateDataMetrics: assert saved["total"] == 8 def test_returns_false_when_data_load_fails(self, tmp_path: Path): - with patch( - "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", - tmp_path / "nonexistent", - ), patch( - "aipass.flow.apps.handlers.json.json_handler.load_json", - return_value=None, + with ( + patch( + "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", + tmp_path / "nonexistent", + ), + patch( + "aipass.flow.apps.handlers.json.json_handler.load_json", + return_value=None, + ), ): result = update_data_metrics("broken_mod", x=1) @@ -517,11 +526,13 @@ class TestUpdateDataMetrics: ): data_file = tmp_path / "testmod_data.json" data_file.write_text( - json.dumps({ - "created": "2026-01-01", - "last_updated": "2026-01-01", - "counter": 10, - }), + json.dumps( + { + "created": "2026-01-01", + "last_updated": "2026-01-01", + "counter": 10, + } + ), encoding="utf-8", ) @@ -551,6 +562,7 @@ class TestUpdateDataMetrics: # create_plan_impl # ========================================================================= + class TestCreatePlanImpl: """Tests for create_plan_impl().""" @@ -600,9 +612,7 @@ class TestCreatePlanImpl: deps = self._make_deps() deps["get_template"] = None # Missing dep - success, plan_num, loc, tmpl, err, msgs = create_plan_impl( - subject="anything", **deps - ) + success, plan_num, loc, tmpl, err, msgs = create_plan_impl(subject="anything", **deps) assert success is False assert "Missing required dependency" in err @@ -617,9 +627,7 @@ class TestCreatePlanImpl: ), ) - success, _, _, _, err, _ = create_plan_impl( - location="/bad/path", subject="test", **deps - ) + success, _, _, _, err, _ = create_plan_impl(location="/bad/path", subject="test", **deps) assert success is False assert err == "Dir not found" @@ -631,9 +639,7 @@ class TestCreatePlanImpl: create_plan_file=MagicMock(return_value=(False, "File exists")), ) - success, _, _, _, err, _ = create_plan_impl( - subject="test", **deps - ) + success, _, _, _, err, _ = create_plan_impl(subject="test", **deps) assert success is False assert err == "File exists" @@ -650,9 +656,7 @@ class TestCreatePlanImpl: ), ) - success, plan_num, _, _, _, msgs = create_plan_impl( - subject="test", **deps - ) + success, plan_num, _, _, _, msgs = create_plan_impl(subject="test", **deps) assert success is True assert plan_num == 5 @@ -692,9 +696,7 @@ class TestCreatePlanImpl: get_template=MagicMock(side_effect=ValueError("bad template")), ) - success, _, _, _, err, _ = create_plan_impl( - subject="test", **deps - ) + success, _, _, _, err, _ = create_plan_impl(subject="test", **deps) assert success is False assert "Failed to load template" in err @@ -708,9 +710,7 @@ class TestCreatePlanImpl: push_flow_to_branch_dashboard=MagicMock(return_value=False), ) - success, _, _, _, err, msgs = create_plan_impl( - subject="test", **deps - ) + success, _, _, _, err, msgs = create_plan_impl(subject="test", **deps) assert success is True assert err == "" @@ -720,9 +720,7 @@ class TestCreatePlanImpl: def test_empty_subject_produces_filename_without_slug(self, mock_log, mock_jh): deps = self._make_deps() - success, _, _, _, _, _ = create_plan_impl( - subject="", **deps - ) + success, _, _, _, _, _ = create_plan_impl(subject="", **deps) assert success is True call_args = deps["create_plan_file"].call_args @@ -745,9 +743,7 @@ class TestCreatePlanImpl: save_registry=save_mock, ) - success, _, _, _, _, msgs = create_plan_impl( - subject="test", **deps - ) + success, _, _, _, _, msgs = create_plan_impl(subject="test", **deps) assert success is True warning_msgs = [m for m in msgs if m.get("type") == "warning"] diff --git a/src/aipass/flow/tests/test_restore_ops.py b/src/aipass/flow/tests/test_restore_ops.py index fd078a7e..e671019b 100644 --- a/src/aipass/flow/tests/test_restore_ops.py +++ b/src/aipass/flow/tests/test_restore_ops.py @@ -4,18 +4,20 @@ from pathlib import Path from unittest.mock import MagicMock, patch - # ─── Helpers ───────────────────────────────────────────── + def _import_restore_plan_impl(): """Import restore_plan_impl inside test scope.""" from aipass.flow.apps.handlers.plan.restore_ops import restore_plan_impl + return restore_plan_impl def _import_recover_plan_from_backup(): """Import recover_plan_from_backup inside test scope.""" from aipass.flow.apps.handlers.plan.restore_ops import recover_plan_from_backup + return recover_plan_from_backup @@ -23,22 +25,24 @@ def _make_deps(**overrides): """Build a default set of injected dependencies, with optional overrides.""" deps = { "normalize_plan_number": MagicMock(side_effect=lambda x: x.zfill(4)), - "load_registry": MagicMock(return_value={ - "plans": { - "0001": { - "status": "closed", - "file_path": "/tmp/FPLAN-0001.md", - "location": "/tmp", - "relative_path": "flow", - "subject": "Test plan", - "closed": "2026-03-19", - "closed_reason": "completed", - "memory_created": True, - "memory_created_date": "2026-03-19", - "memory_file": "/tmp/memory.md", - }, + "load_registry": MagicMock( + return_value={ + "plans": { + "0001": { + "status": "closed", + "file_path": "/tmp/FPLAN-0001.md", + "location": "/tmp", + "relative_path": "flow", + "subject": "Test plan", + "closed": "2026-03-19", + "closed_reason": "completed", + "memory_created": True, + "memory_created_date": "2026-03-19", + "memory_file": "/tmp/memory.md", + }, + } } - }), + ), "save_registry": MagicMock(), "validate_plan_exists": MagicMock(return_value=(True, "")), "recover_plan_from_backup_fn": MagicMock(return_value=(False, "not found")), @@ -54,8 +58,8 @@ def _make_deps(**overrides): # 1. restore_plan_impl -- no plan number # ═══════════════════════════════════════════════════════════ -class TestRestoreNoPlanNumber: +class TestRestoreNoPlanNumber: def test_none_returns_error(self): fn = _import_restore_plan_impl() result = fn(plan_num=None, **_make_deps()) @@ -72,8 +76,8 @@ class TestRestoreNoPlanNumber: # 2. restore_plan_impl -- success path # ═══════════════════════════════════════════════════════════ -class TestRestoreSuccess: +class TestRestoreSuccess: def test_successful_restore(self, tmp_path): fn = _import_restore_plan_impl() plan_file = tmp_path / "FPLAN-0001.md" @@ -186,8 +190,8 @@ class TestRestoreSuccess: # 3. restore_plan_impl -- plan already open # ═══════════════════════════════════════════════════════════ -class TestRestoreAlreadyOpen: +class TestRestoreAlreadyOpen: def test_open_plan_returns_error(self, tmp_path): fn = _import_restore_plan_impl() plan_file = tmp_path / "FPLAN-0001.md" @@ -215,8 +219,8 @@ class TestRestoreAlreadyOpen: # 4. restore_plan_impl -- plan not found + recovery # ═══════════════════════════════════════════════════════════ -class TestRestoreNotFound: +class TestRestoreNotFound: def test_not_found_no_backup(self): fn = _import_restore_plan_impl() deps = _make_deps( @@ -246,10 +250,12 @@ class TestRestoreNotFound: }, } } - load_mock = MagicMock(side_effect=[ - {"plans": {}}, # first load: empty - recovered_registry, # second load: after recovery - ]) + load_mock = MagicMock( + side_effect=[ + {"plans": {}}, # first load: empty + recovered_registry, # second load: after recovery + ] + ) deps = _make_deps( validate_plan_exists=MagicMock(return_value=(False, "not found")), recover_plan_from_backup_fn=MagicMock(return_value=(True, "Recovered FPLAN-9999")), @@ -267,8 +273,8 @@ class TestRestoreNotFound: # 5. restore_plan_impl -- file missing # ═══════════════════════════════════════════════════════════ -class TestRestoreFileMissing: +class TestRestoreFileMissing: def test_file_not_at_location(self): fn = _import_restore_plan_impl() registry = { @@ -293,8 +299,8 @@ class TestRestoreFileMissing: # 6. restore_plan_impl -- ValueError (invalid number) # ═══════════════════════════════════════════════════════════ -class TestRestoreValueError: +class TestRestoreValueError: def test_invalid_plan_number_raises_value_error(self): fn = _import_restore_plan_impl() deps = _make_deps( @@ -318,8 +324,8 @@ class TestRestoreValueError: # 7. restore_plan_impl -- generic exception # ═══════════════════════════════════════════════════════════ -class TestRestoreGenericException: +class TestRestoreGenericException: def test_unexpected_error(self): fn = _import_restore_plan_impl() deps = _make_deps( @@ -336,8 +342,8 @@ class TestRestoreGenericException: # 8. restore_plan_impl -- dashboard failures # ═══════════════════════════════════════════════════════════ -class TestRestoreDashboardFailures: +class TestRestoreDashboardFailures: def test_dashboard_failure_does_not_block_success(self, tmp_path): fn = _import_restore_plan_impl() plan_file = tmp_path / "FPLAN-0001.md" @@ -396,8 +402,8 @@ class TestRestoreDashboardFailures: # 9. recover_plan_from_backup # ═══════════════════════════════════════════════════════════ -class TestRecoverPlanFromBackup: +class TestRecoverPlanFromBackup: def test_no_backup_dir(self): fn = _import_recover_plan_from_backup() load = MagicMock(return_value={"plans": {}}) @@ -422,9 +428,11 @@ class TestRecoverPlanFromBackup: load = MagicMock(return_value=registry) save = MagicMock() - with patch("aipass.flow.apps.handlers.plan.restore_ops.PROCESSED_PLANS_DIR", backup_dir), \ - patch("aipass.flow.apps.handlers.plan.restore_ops._PKG_ROOT", tmp_path), \ - patch("aipass.flow.apps.handlers.plan.restore_ops.FLOW_ROOT", tmp_path / "flow"): + with ( + patch("aipass.flow.apps.handlers.plan.restore_ops.PROCESSED_PLANS_DIR", backup_dir), + patch("aipass.flow.apps.handlers.plan.restore_ops._PKG_ROOT", tmp_path), + patch("aipass.flow.apps.handlers.plan.restore_ops.FLOW_ROOT", tmp_path / "flow"), + ): ok, msg = fn("0042", load_registry=load, save_registry=save) assert ok is True @@ -449,9 +457,11 @@ class TestRecoverPlanFromBackup: load = MagicMock(return_value=registry) save = MagicMock() - with patch("aipass.flow.apps.handlers.plan.restore_ops.PROCESSED_PLANS_DIR", backup_dir), \ - patch("aipass.flow.apps.handlers.plan.restore_ops._PKG_ROOT", tmp_path), \ - patch("aipass.flow.apps.handlers.plan.restore_ops.FLOW_ROOT", flow_root): + with ( + patch("aipass.flow.apps.handlers.plan.restore_ops.PROCESSED_PLANS_DIR", backup_dir), + patch("aipass.flow.apps.handlers.plan.restore_ops._PKG_ROOT", tmp_path), + patch("aipass.flow.apps.handlers.plan.restore_ops.FLOW_ROOT", flow_root), + ): ok, msg = fn("0010", load_registry=load, save_registry=save) assert ok is True @@ -470,6 +480,7 @@ class TestRecoverPlanFromBackup: old_file.write_text("# Old\n**Location**: " + str(tmp_path) + "\n", encoding="utf-8") import time + time.sleep(0.05) new_file = backup_dir / "DPLAN-0005.md" @@ -479,9 +490,11 @@ class TestRecoverPlanFromBackup: load = MagicMock(return_value=registry) save = MagicMock() - with patch("aipass.flow.apps.handlers.plan.restore_ops.PROCESSED_PLANS_DIR", backup_dir), \ - patch("aipass.flow.apps.handlers.plan.restore_ops._PKG_ROOT", tmp_path), \ - patch("aipass.flow.apps.handlers.plan.restore_ops.FLOW_ROOT", tmp_path / "flow"): + with ( + patch("aipass.flow.apps.handlers.plan.restore_ops.PROCESSED_PLANS_DIR", backup_dir), + patch("aipass.flow.apps.handlers.plan.restore_ops._PKG_ROOT", tmp_path), + patch("aipass.flow.apps.handlers.plan.restore_ops.FLOW_ROOT", tmp_path / "flow"), + ): ok, msg = fn("0005", load_registry=load, save_registry=save) assert ok is True diff --git a/src/aipass/flow/tests/test_template_manager.py b/src/aipass/flow/tests/test_template_manager.py index 736425e1..9cc1d0f7 100644 --- a/src/aipass/flow/tests/test_template_manager.py +++ b/src/aipass/flow/tests/test_template_manager.py @@ -103,8 +103,10 @@ class TestHandleCommandRouting: """'templates list' should load registry and display types.""" mock_registry = {"types": {"flow_plans": {"prefix": "FPLAN"}}} - with patch(f"{_MOD}.load_registry", return_value=mock_registry) as mock_lr, \ - patch(f"{_MOD}._display_registered_types") as mock_display: + with ( + patch(f"{_MOD}.load_registry", return_value=mock_registry) as mock_lr, + patch(f"{_MOD}._display_registered_types") as mock_display, + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("templates", ["list"]) @@ -117,8 +119,7 @@ class TestHandleCommandRouting: def test_register_no_args_shows_error(self): """'register' with insufficient args should show usage error.""" - with patch(f"{_MOD}.error") as mock_error, \ - patch(f"{_MOD}.console"): + with patch(f"{_MOD}.error") as mock_error, patch(f"{_MOD}.console"): from aipass.flow.apps.modules.template_manager import handle_command # Note: empty args triggers introspection gate first, @@ -131,10 +132,12 @@ class TestHandleCommandRouting: def test_register_valid_calls_add_type(self): """'register testing TPLAN' should call add_type.""" - with patch(f"{_MOD}.add_type", return_value=True) as mock_add, \ - patch(f"{_MOD}.success") as mock_success, \ - patch(f"{_MOD}.console"), \ - patch(f"{_MOD}.json_handler"): + with ( + patch(f"{_MOD}.add_type", return_value=True) as mock_add, + patch(f"{_MOD}.success") as mock_success, + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler"), + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("register", ["testing", "TPLAN"]) @@ -145,10 +148,12 @@ class TestHandleCommandRouting: def test_register_add_type_failure(self): """add_type returning False should show error message.""" - with patch(f"{_MOD}.add_type", return_value=False) as mock_add, \ - patch(f"{_MOD}.error") as mock_error, \ - patch(f"{_MOD}.console"), \ - patch(f"{_MOD}.json_handler"): + with ( + patch(f"{_MOD}.add_type", return_value=False) as mock_add, + patch(f"{_MOD}.error") as mock_error, + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler"), + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("register", ["testing", "TPLAN"]) @@ -160,8 +165,7 @@ class TestHandleCommandRouting: def test_register_invalid_prefix_not_uppercase(self): """Prefix that is not uppercase should be rejected.""" - with patch(f"{_MOD}.error") as mock_error, \ - patch(f"{_MOD}.console"): + with patch(f"{_MOD}.error") as mock_error, patch(f"{_MOD}.console"): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("register", ["testing", "bad"]) @@ -172,8 +176,7 @@ class TestHandleCommandRouting: def test_register_invalid_prefix_no_plan_suffix(self): """Prefix that doesn't end with PLAN should be rejected.""" - with patch(f"{_MOD}.error") as mock_error, \ - patch(f"{_MOD}.console"): + with patch(f"{_MOD}.error") as mock_error, patch(f"{_MOD}.console"): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("register", ["testing", "TFIX"]) @@ -196,10 +199,12 @@ class TestHandleCommandRouting: def test_unregister_valid_calls_remove_type(self): """'unregister testing' should call remove_type.""" - with patch(f"{_MOD}.remove_type", return_value=True) as mock_rm, \ - patch(f"{_MOD}.success") as mock_success, \ - patch(f"{_MOD}.console"), \ - patch(f"{_MOD}.json_handler"): + with ( + patch(f"{_MOD}.remove_type", return_value=True) as mock_rm, + patch(f"{_MOD}.success") as mock_success, + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler"), + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("unregister", ["testing"]) @@ -210,10 +215,12 @@ class TestHandleCommandRouting: def test_unregister_failure_shows_error(self): """remove_type returning False should show error.""" - with patch(f"{_MOD}.remove_type", return_value=False) as mock_rm, \ - patch(f"{_MOD}.error") as mock_error, \ - patch(f"{_MOD}.console"), \ - patch(f"{_MOD}.json_handler"): + with ( + patch(f"{_MOD}.remove_type", return_value=False) as mock_rm, + patch(f"{_MOD}.error") as mock_error, + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler"), + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("unregister", ["testing"]) @@ -227,9 +234,11 @@ class TestHandleCommandRouting: def test_scan_no_unregistered_dirs(self): """scan with all dirs registered should show success message.""" - with patch(f"{_MOD}.scan_unregistered", return_value=[]) as mock_scan, \ - patch(f"{_MOD}.console") as mock_console, \ - patch(f"{_MOD}.json_handler"): + with ( + patch(f"{_MOD}.scan_unregistered", return_value=[]) as mock_scan, + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler"), + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("scan", ["run"]) @@ -247,10 +256,12 @@ class TestHandleCommandRouting: {"dir_name": "skills_plans", "template_count": 1}, ] - with patch(f"{_MOD}.scan_unregistered", return_value=unregistered) as mock_scan, \ - patch(f"{_MOD}.warning") as mock_warn, \ - patch(f"{_MOD}.console") as mock_console, \ - patch(f"{_MOD}.json_handler"): + with ( + patch(f"{_MOD}.scan_unregistered", return_value=unregistered) as mock_scan, + patch(f"{_MOD}.warning") as mock_warn, + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler"), + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("scan", ["run"]) @@ -278,9 +289,11 @@ class TestHandleCommandRouting: """json_handler.log_operation should be called for templates command.""" mock_registry = {"types": {}} - with patch(f"{_MOD}.load_registry", return_value=mock_registry), \ - patch(f"{_MOD}._display_registered_types"), \ - patch(f"{_MOD}.json_handler") as mock_jh: + with ( + patch(f"{_MOD}.load_registry", return_value=mock_registry), + patch(f"{_MOD}._display_registered_types"), + patch(f"{_MOD}.json_handler") as mock_jh, + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("templates", ["list"]) @@ -293,9 +306,11 @@ class TestHandleCommandRouting: def test_json_handler_called_on_scan(self): """json_handler.log_operation should be called for scan command.""" - with patch(f"{_MOD}.scan_unregistered", return_value=[]), \ - patch(f"{_MOD}.console"), \ - patch(f"{_MOD}.json_handler") as mock_jh: + with ( + patch(f"{_MOD}.scan_unregistered", return_value=[]), + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler") as mock_jh, + ): from aipass.flow.apps.modules.template_manager import handle_command result = handle_command("scan", ["run"]) diff --git a/src/aipass/memory/apps/handlers/__init__.py b/src/aipass/memory/apps/handlers/__init__.py index db19c0bb..0fdd02d6 100644 --- a/src/aipass/memory/apps/handlers/__init__.py +++ b/src/aipass/memory/apps/handlers/__init__.py @@ -62,8 +62,10 @@ def _guard_branch_access(): # DEBUG: Print what we found import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) @@ -89,9 +91,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -105,7 +107,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/memory/apps/handlers/archive/indexer.py b/src/aipass/memory/apps/handlers/archive/indexer.py index 37b02fd1..c09fec90 100644 --- a/src/aipass/memory/apps/handlers/archive/indexer.py +++ b/src/aipass/memory/apps/handlers/archive/indexer.py @@ -45,7 +45,7 @@ def extract_file_info(file_path: Path) -> Dict[str, Any]: Dict with filename, docstring, functions, classes """ try: - content = file_path.read_text(encoding='utf-8') + content = file_path.read_text(encoding="utf-8") # Parse AST try: @@ -53,12 +53,12 @@ def extract_file_info(file_path: Path) -> Dict[str, Any]: except SyntaxError: logger.info(f"[indexer] Syntax error parsing {file_path.name}, skipping AST extraction") return { - 'filename': file_path.name, - 'path': str(file_path.relative_to(CODE_ARCHIVE_PATH)), - 'docstring': None, - 'error': 'Syntax error - could not parse', - 'size': file_path.stat().st_size, - 'indexed_at': datetime.now().isoformat() + "filename": file_path.name, + "path": str(file_path.relative_to(CODE_ARCHIVE_PATH)), + "docstring": None, + "error": "Syntax error - could not parse", + "size": file_path.stat().st_size, + "indexed_at": datetime.now().isoformat(), } # Get module docstring @@ -75,22 +75,22 @@ def extract_file_info(file_path: Path) -> Dict[str, Any]: classes.append(node.name) return { - 'filename': file_path.name, - 'path': str(file_path.relative_to(CODE_ARCHIVE_PATH)), - 'docstring': docstring[:200] + '...' if docstring and len(docstring) > 200 else docstring, - 'functions': functions[:10], # Limit to first 10 - 'classes': classes[:10], - 'size': file_path.stat().st_size, - 'lines': len(content.splitlines()), - 'indexed_at': datetime.now().isoformat() + "filename": file_path.name, + "path": str(file_path.relative_to(CODE_ARCHIVE_PATH)), + "docstring": docstring[:200] + "..." if docstring and len(docstring) > 200 else docstring, + "functions": functions[:10], # Limit to first 10 + "classes": classes[:10], + "size": file_path.stat().st_size, + "lines": len(content.splitlines()), + "indexed_at": datetime.now().isoformat(), } except Exception as e: logger.warning(f"[indexer] Failed to extract file info from {file_path.name}: {e}") return { - 'filename': file_path.name, - 'path': str(file_path), - 'error': str(e), - 'indexed_at': datetime.now().isoformat() + "filename": file_path.name, + "path": str(file_path), + "error": str(e), + "indexed_at": datetime.now().isoformat(), } @@ -104,9 +104,9 @@ def get_archive_files() -> List[Path]: if not CODE_ARCHIVE_PATH.exists(): return [] - files = list(CODE_ARCHIVE_PATH.rglob('*.py')) + files = list(CODE_ARCHIVE_PATH.rglob("*.py")) # Exclude __init__.py files - files = [f for f in files if f.name != '__init__.py'] + files = [f for f in files if f.name != "__init__.py"] return sorted(files) @@ -125,15 +125,15 @@ def load_index() -> Dict[str, Any]: logger.warning(f"[indexer] Failed to load index file: {e}") return { - 'metadata': { - 'name': 'Code Archive Index', - 'description': 'Catalog of archived Python modules from old system', - 'created': datetime.now().isoformat(), - 'last_updated': None, - 'total_files': 0 + "metadata": { + "name": "Code Archive Index", + "description": "Catalog of archived Python modules from old system", + "created": datetime.now().isoformat(), + "last_updated": None, + "total_files": 0, }, - 'categories': {}, - 'files': {} + "categories": {}, + "files": {}, } @@ -148,16 +148,16 @@ def save_index(index: Dict[str, Any]) -> Dict[str, Any]: Dict with success status """ try: - index['metadata']['last_updated'] = datetime.now().isoformat() - index['metadata']['total_files'] = len(index['files']) + index["metadata"]["last_updated"] = datetime.now().isoformat() + index["metadata"]["total_files"] = len(index["files"]) - with open(INDEX_PATH, 'w') as f: + with open(INDEX_PATH, "w") as f: json.dump(index, f, indent=2) - return {'success': True} + return {"success": True} except Exception as e: logger.error(f"[indexer] Failed to save index: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} def build_index() -> Dict[str, Any]: @@ -172,11 +172,7 @@ def build_index() -> Dict[str, Any]: files = get_archive_files() if not files: - return { - 'success': True, - 'message': 'No files to index', - 'files_indexed': 0 - } + return {"success": True, "message": "No files to index", "files_indexed": 0} index = load_index() categories = {} @@ -185,28 +181,24 @@ def build_index() -> Dict[str, Any]: info = extract_file_info(file_path) # Use relative path as key - key = info['path'] - index['files'][key] = info + key = info["path"] + index["files"][key] = info # Track categories (subdirectories) category = file_path.parent.name - if category != 'code_archive': + if category != "code_archive": if category not in categories: categories[category] = [] - categories[category].append(info['filename']) + categories[category].append(info["filename"]) - index['categories'] = categories + index["categories"] = categories save_result = save_index(index) - if not save_result['success']: + if not save_result["success"]: return save_result - return { - 'success': True, - 'files_indexed': len(files), - 'categories': list(categories.keys()) - } + return {"success": True, "files_indexed": len(files), "categories": list(categories.keys())} def check_for_new_files() -> Dict[str, Any]: @@ -221,68 +213,65 @@ def check_for_new_files() -> Dict[str, Any]: index = load_index() current_files = get_archive_files() - indexed_paths = set(index.get('files', {}).keys()) + indexed_paths = set(index.get("files", {}).keys()) current_paths = {str(f.relative_to(CODE_ARCHIVE_PATH)) for f in current_files} new_files = current_paths - indexed_paths deleted_files = indexed_paths - current_paths if not new_files and not deleted_files: - return { - 'success': True, - 'new_files': 0, - 'deleted_files': 0, - 'action': 'none' - } + return {"success": True, "new_files": 0, "deleted_files": 0, "action": "none"} # Index new files for rel_path in new_files: file_path = CODE_ARCHIVE_PATH / rel_path if file_path.exists(): info = extract_file_info(file_path) - index['files'][rel_path] = info + index["files"][rel_path] = info # Update category category = file_path.parent.name - if category != 'code_archive': - if category not in index['categories']: - index['categories'][category] = [] - if info['filename'] not in index['categories'][category]: - index['categories'][category].append(info['filename']) + if category != "code_archive": + if category not in index["categories"]: + index["categories"][category] = [] + if info["filename"] not in index["categories"][category]: + index["categories"][category].append(info["filename"]) # Remove deleted files from index for rel_path in deleted_files: - if rel_path in index['files']: - filename = index['files'][rel_path].get('filename') - del index['files'][rel_path] + if rel_path in index["files"]: + filename = index["files"][rel_path].get("filename") + del index["files"][rel_path] # Clean up category - for cat, files in index['categories'].items(): + for cat, files in index["categories"].items(): if filename in files: files.remove(filename) # Rebuild categories from current files - index['categories'] = {} - for rel_path, info in index['files'].items(): + index["categories"] = {} + for rel_path, info in index["files"].items(): file_path = CODE_ARCHIVE_PATH / rel_path category = file_path.parent.name - if category != 'code_archive': - if category not in index['categories']: - index['categories'][category] = [] - if info['filename'] not in index['categories'][category]: - index['categories'][category].append(info['filename']) + if category != "code_archive": + if category not in index["categories"]: + index["categories"][category] = [] + if info["filename"] not in index["categories"][category]: + index["categories"][category].append(info["filename"]) save_index(index) - json_handler.log_operation("index_sync", {"new_files": len(new_files), "deleted_files": len(deleted_files), "success": True}) + json_handler.log_operation( + "index_sync", {"new_files": len(new_files), "deleted_files": len(deleted_files), "success": True} + ) return { - 'success': True, - 'new_files': len(new_files), - 'deleted_files': len(deleted_files), - 'files_added': list(new_files) if new_files else None, - 'files_removed': list(deleted_files) if deleted_files else None, - 'action': 'synced' + "success": True, + "new_files": len(new_files), + "deleted_files": len(deleted_files), + "files_added": list(new_files) if new_files else None, + "files_removed": list(deleted_files) if deleted_files else None, + "action": "synced", } @@ -296,15 +285,15 @@ def get_index_status() -> Dict[str, Any]: index = load_index() current_files = get_archive_files() - indexed_count = len(index.get('files', {})) + indexed_count = len(index.get("files", {})) current_count = len(current_files) return { - 'indexed_files': indexed_count, - 'current_files': current_count, - 'unindexed': current_count - indexed_count if current_count > indexed_count else 0, - 'categories': list(index.get('categories', {}).keys()), - 'last_updated': index.get('metadata', {}).get('last_updated') + "indexed_files": indexed_count, + "current_files": current_count, + "unindexed": current_count - indexed_count if current_count > indexed_count else 0, + "categories": list(index.get("categories", {}).keys()), + "last_updated": index.get("metadata", {}).get("last_updated"), } @@ -315,16 +304,16 @@ if __name__ == "__main__": if len(sys.argv) > 1: cmd = sys.argv[1] - if cmd == 'status': + if cmd == "status": status = get_index_status() print(json.dumps(status, indent=2)) - elif cmd == 'build': + elif cmd == "build": print("Building index...") result = build_index() print(json.dumps(result, indent=2)) - elif cmd == 'check': + elif cmd == "check": result = check_for_new_files() print(json.dumps(result, indent=2)) diff --git a/src/aipass/memory/apps/handlers/central_writer.py b/src/aipass/memory/apps/handlers/central_writer.py index cd81f9e5..4809a626 100644 --- a/src/aipass/memory/apps/handlers/central_writer.py +++ b/src/aipass/memory/apps/handlers/central_writer.py @@ -55,6 +55,7 @@ ARCHIVE_DIR = _MEMORY_ROOT / ".archive" # STATS COLLECTION # ============================================================================= + def count_chroma_vectors() -> int: """ Count total vectors across all ChromaDB collections @@ -162,7 +163,7 @@ def collect_stats() -> Dict[str, Any]: return { "total_vectors": count_chroma_vectors(), "total_archives": count_archive_files(), - "last_rollover": get_last_rollover_timestamp() + "last_rollover": get_last_rollover_timestamp(), } @@ -170,6 +171,7 @@ def collect_stats() -> Dict[str, Any]: # CENTRAL FILE OPERATIONS # ============================================================================= + def read_central_file() -> Dict[str, Any]: """ Read current central file contents @@ -186,14 +188,10 @@ def read_central_file() -> Dict[str, Any]: return { "service": "memory", "last_updated": "", - "stats": { - "total_vectors": 0, - "total_archives": 0, - "last_rollover": "" - } + "stats": {"total_vectors": 0, "total_archives": 0, "last_rollover": ""}, } - with open(CENTRAL_FILE, 'r', encoding='utf-8') as f: + with open(CENTRAL_FILE, "r", encoding="utf-8") as f: return json_load(f) except Exception as e: @@ -215,7 +213,7 @@ def write_central_file(data: Dict[str, Any]) -> None: # Ensure directory exists CENTRAL_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(CENTRAL_FILE, 'w', encoding='utf-8') as f: + with open(CENTRAL_FILE, "w", encoding="utf-8") as f: json_dump(data, f, indent=2, ensure_ascii=False) except Exception as e: @@ -227,6 +225,7 @@ def write_central_file(data: Dict[str, Any]) -> None: # PUBLIC API # ============================================================================= + def update_central(verbose: bool = False) -> Dict[str, Any]: """ Update memory central.json with current statistics @@ -267,10 +266,7 @@ def update_central(verbose: bool = False) -> Dict[str, Any]: # Write updated file write_central_file(central_data) - result = { - "success": True, - "updated": CENTRAL_FILE.as_posix() - } + result = {"success": True, "updated": CENTRAL_FILE.as_posix()} if verbose: result["stats"] = stats @@ -281,10 +277,7 @@ def update_central(verbose: bool = False) -> Dict[str, Any]: except Exception as e: logger.error(f"[central_writer] Failed to update central: {e}") - return { - "success": False, - "error": str(e) - } + return {"success": False, "error": str(e)} def get_current_stats() -> Dict[str, Any]: @@ -302,17 +295,11 @@ def get_current_stats() -> Dict[str, Any]: """ try: stats = collect_stats() - return { - "success": True, - **stats - } + return {"success": True, **stats} except Exception as e: logger.warning(f"[central_writer] Failed to get current stats: {e}") - return { - "success": False, - "error": str(e) - } + return {"success": False, "error": str(e)} # ============================================================================= @@ -326,7 +313,7 @@ if __name__ == "__main__": print("Collecting statistics...") stats_result = get_current_stats() - if stats_result['success']: + if stats_result["success"]: print(f" Total Vectors: {stats_result.get('total_vectors', 0)}") print(f" Total Archives: {stats_result.get('total_archives', 0)}") print(f" Last Rollover: {stats_result.get('last_rollover', 'Never')}") @@ -339,9 +326,9 @@ if __name__ == "__main__": print("Updating central file...") result = update_central(verbose=True) - if result['success']: + if result["success"]: print(f"Updated: {result['updated']}") - if 'stats' in result: + if "stats" in result: print(f" Vectors: {result['stats']['total_vectors']}") print(f" Archives: {result['stats']['total_archives']}") else: diff --git a/src/aipass/memory/apps/handlers/dashboard_push.py b/src/aipass/memory/apps/handlers/dashboard_push.py index f6462ff2..6a497618 100644 --- a/src/aipass/memory/apps/handlers/dashboard_push.py +++ b/src/aipass/memory/apps/handlers/dashboard_push.py @@ -61,6 +61,7 @@ NEAR_ROLLOVER_THRESHOLD = 100 # DATA COLLECTION # ============================================================================= + def _read_central_stats() -> Dict[str, Any]: """ Read total_vectors and related stats from memory central.json. @@ -77,7 +78,7 @@ def _read_central_stats() -> Dict[str, Any]: return { "total_vectors": stats.get("total_vectors", 0), "total_archives": stats.get("total_archives", 0), - "last_rollover": stats.get("last_rollover", "") + "last_rollover": stats.get("last_rollover", ""), } except Exception as e: logger.warning(f"[dashboard_push] Failed to read central stats: {e}") @@ -124,7 +125,7 @@ def _get_rollover_config() -> Dict[str, Any]: rollover = data.get("rollover", {}) return { "defaults": rollover.get("defaults", {"max_lines": 600, "buffer": 100}), - "per_branch": rollover.get("per_branch", {}) + "per_branch": rollover.get("per_branch", {}), } except Exception as e: logger.warning(f"[dashboard_push] Failed to load rollover config: {e}") @@ -200,27 +201,31 @@ def _find_branches_near_rollover() -> List[Dict[str, Any]]: sessions = data.get("sessions", []) remaining_sessions = max_sessions - len(sessions) if remaining_sessions < 3: - near_rollover.append({ - "branch": branch_name, - "file_type": suffix, - "lines_remaining": remaining_sessions, - "current_lines": len(sessions), - "max_lines": max_sessions, - "v2_field": "sessions", - }) + near_rollover.append( + { + "branch": branch_name, + "file_type": suffix, + "lines_remaining": remaining_sessions, + "current_lines": len(sessions), + "max_lines": max_sessions, + "v2_field": "sessions", + } + ) max_kl = limits.get("max_key_learnings") if max_kl is not None: kl = data.get("key_learnings", {}) remaining_kl = max_kl - len(kl) if remaining_kl < 3: - near_rollover.append({ - "branch": branch_name, - "file_type": suffix, - "lines_remaining": remaining_kl, - "current_lines": len(kl), - "max_lines": max_kl, - "v2_field": "key_learnings", - }) + near_rollover.append( + { + "branch": branch_name, + "file_type": suffix, + "lines_remaining": remaining_kl, + "current_lines": len(kl), + "max_lines": max_kl, + "v2_field": "key_learnings", + } + ) continue # v1: line-count based @@ -232,13 +237,15 @@ def _find_branches_near_rollover() -> List[Dict[str, Any]]: remaining = max_lines - current_lines if remaining < NEAR_ROLLOVER_THRESHOLD: - near_rollover.append({ - "branch": branch_name, - "file_type": suffix, - "lines_remaining": max(remaining, 0), - "current_lines": current_lines, - "max_lines": max_lines - }) + near_rollover.append( + { + "branch": branch_name, + "file_type": suffix, + "lines_remaining": max(remaining, 0), + "current_lines": current_lines, + "max_lines": max_lines, + } + ) except Exception as e: # Skip files that can't be read logger.warning(f"[dashboard_push] Failed to read memory file {memory_file}: {e}") @@ -324,6 +331,7 @@ def _get_all_branch_paths() -> List[Path]: # PUBLIC API # ============================================================================= + def build_memory_bank_section() -> Dict[str, Any]: """ Build the memory_bank dashboard section data. @@ -346,12 +354,11 @@ def build_memory_bank_section() -> Dict[str, Any]: "collections_count": collections_count, "branches_near_rollover": near_rollover, "last_rollover": last_rollover, - "template_version": template_version + "template_version": template_version, } -def _write_section_to_all_branches(section_name: str, section_data: Dict, - branch_paths: List[Path]) -> int: +def _write_section_to_all_branches(section_name: str, section_data: Dict, branch_paths: List[Path]) -> int: """ Write a dashboard section to multiple branches via a single subprocess. @@ -391,18 +398,12 @@ def _write_section_to_all_branches(section_name: str, section_data: Dict, "print(ok)\n" ) - input_data = json_dumps({ - "section_name": section_name, - "section_data": section_data, - "branch_paths": [str(p) for p in branch_paths] - }) + input_data = json_dumps( + {"section_name": section_name, "section_data": section_data, "branch_paths": [str(p) for p in branch_paths]} + ) result = subprocess.run( - [sys.executable, "-c", script], - input=input_data, - capture_output=True, - text=True, - timeout=60 + [sys.executable, "-c", script], input=input_data, capture_output=True, text=True, timeout=60 ) if result.returncode == 0 and result.stdout.strip().isdigit(): @@ -435,9 +436,7 @@ def push_memory_bank_dashboard() -> bool: # Push to all branch dashboards via single subprocess branch_paths = _get_all_branch_paths() - success_count = _write_section_to_all_branches( - "memory_bank", section_data, branch_paths - ) + success_count = _write_section_to_all_branches("memory_bank", section_data, branch_paths) json_handler.log_operation("dashboard_push", {"branches_updated": success_count, "success": success_count > 0}) diff --git a/src/aipass/memory/apps/handlers/intake/plans_processor.py b/src/aipass/memory/apps/handlers/intake/plans_processor.py index ebdf1446..5798cad5 100644 --- a/src/aipass/memory/apps/handlers/intake/plans_processor.py +++ b/src/aipass/memory/apps/handlers/intake/plans_processor.py @@ -70,6 +70,7 @@ MAX_CHUNK_CHARS = 1500 # ~375 tokens, fits well with all-MiniLM-L6-v2 # CHUNKING # ============================================================================= + def _chunk_plan_text(text: str, filename: str) -> List[Dict[str, str]]: """ Chunk plan text into sections for vectorization. @@ -87,51 +88,48 @@ def _chunk_plan_text(text: str, filename: str) -> List[Dict[str, str]]: chunks = [] # Split by markdown headers - lines = text.split('\n') + lines = text.split("\n") current_section = filename current_lines = [] for line in lines: - if line.startswith('## ') or line.startswith('### '): + if line.startswith("## ") or line.startswith("### "): # Flush previous section if current_lines: - section_text = '\n'.join(current_lines).strip() + section_text = "\n".join(current_lines).strip() if section_text and len(section_text) > 30: - chunks.append({'text': section_text, 'section': current_section}) - current_section = line.lstrip('#').strip() + chunks.append({"text": section_text, "section": current_section}) + current_section = line.lstrip("#").strip() current_lines = [line] else: current_lines.append(line) # Flush last section if current_lines: - section_text = '\n'.join(current_lines).strip() + section_text = "\n".join(current_lines).strip() if section_text and len(section_text) > 30: - chunks.append({'text': section_text, 'section': current_section}) + chunks.append({"text": section_text, "section": current_section}) # If no headers found, chunk by size if not chunks: full_text = text.strip() if len(full_text) > MAX_CHUNK_CHARS: for i in range(0, len(full_text), MAX_CHUNK_CHARS): - chunk_text = full_text[i:i + MAX_CHUNK_CHARS].strip() + chunk_text = full_text[i : i + MAX_CHUNK_CHARS].strip() if chunk_text and len(chunk_text) > 30: - chunks.append({'text': chunk_text, 'section': f'{filename}_part{i // MAX_CHUNK_CHARS}'}) + chunks.append({"text": chunk_text, "section": f"{filename}_part{i // MAX_CHUNK_CHARS}"}) elif len(full_text) > 30: - chunks.append({'text': full_text, 'section': filename}) + chunks.append({"text": full_text, "section": filename}) # Split oversized chunks final_chunks = [] for chunk in chunks: - if len(chunk['text']) > MAX_CHUNK_CHARS * 2: - text_content = chunk['text'] + if len(chunk["text"]) > MAX_CHUNK_CHARS * 2: + text_content = chunk["text"] for i in range(0, len(text_content), MAX_CHUNK_CHARS): - part = text_content[i:i + MAX_CHUNK_CHARS].strip() + part = text_content[i : i + MAX_CHUNK_CHARS].strip() if part and len(part) > 30: - final_chunks.append({ - 'text': part, - 'section': f"{chunk['section']}_part{i // MAX_CHUNK_CHARS}" - }) + final_chunks.append({"text": part, "section": f"{chunk['section']}_part{i // MAX_CHUNK_CHARS}"}) else: final_chunks.append(chunk) @@ -142,11 +140,12 @@ def _chunk_plan_text(text: str, filename: str) -> List[Dict[str, str]]: # PROCESSED MANIFEST # ============================================================================= + def _load_manifest() -> Dict[str, str]: """Load processed files manifest.""" if _PROCESSED_MANIFEST.exists(): try: - return json.loads(_PROCESSED_MANIFEST.read_text(encoding='utf-8')) + return json.loads(_PROCESSED_MANIFEST.read_text(encoding="utf-8")) except Exception as e: logger.warning(f"[plans_processor] Failed to load processed manifest: {e}") return {} @@ -156,59 +155,65 @@ def _load_manifest() -> Dict[str, str]: def _save_manifest(manifest: Dict[str, str]) -> None: """Save processed files manifest.""" _PROCESSED_MANIFEST.parent.mkdir(parents=True, exist_ok=True) - _PROCESSED_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding='utf-8') + _PROCESSED_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8") # ============================================================================= # SUBPROCESS WRAPPERS # ============================================================================= + def _embed_texts(texts: List[str]) -> dict: """Encode texts via subprocess.""" - input_data = json.dumps({'texts': texts}) + input_data = json.dumps({"texts": texts}) try: result = subprocess.run( [str(MEMORY_PYTHON), str(EMBED_SUBPROCESS_SCRIPT)], input=input_data, - capture_output=True, text=True, timeout=120 + capture_output=True, + text=True, + timeout=120, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Embedding failed'} + return {"success": False, "error": result.stderr or "Embedding failed"} return json.loads(result.stdout) except Exception as e: logger.warning(f"[plans_processor] Embedding subprocess failed: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} def _store_vectors(embeddings, documents, metadatas, collection_name="flow_plans") -> dict: """Store vectors via subprocess.""" input_data = { - 'operation': 'store_vectors', - 'branch': 'FLOW', - 'memory_type': collection_name, - 'embeddings': embeddings, - 'documents': documents, - 'metadatas': metadatas, - 'db_path': None # global + "operation": "store_vectors", + "branch": "FLOW", + "memory_type": collection_name, + "embeddings": embeddings, + "documents": documents, + "metadatas": metadatas, + "db_path": None, # global } try: result = subprocess.run( [str(MEMORY_PYTHON), str(CHROMA_SUBPROCESS_SCRIPT)], input=json.dumps(input_data), - capture_output=True, text=True, timeout=60 + capture_output=True, + text=True, + timeout=60, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Storage failed'} + return {"success": False, "error": result.stderr or "Storage failed"} return json.loads(result.stdout) except Exception as e: logger.warning(f"[plans_processor] Vector storage subprocess failed: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # PUBLIC API # ============================================================================= + def process_plans() -> Dict[str, Any]: """ Process plan files from flow/processed_plans/ into vector storage. @@ -227,39 +232,39 @@ def process_plans() -> Dict[str, Any]: # Load config config_path = _MEMORY_ROOT / "config" / "memory_bank.config.json" try: - config = json.loads(config_path.read_text(encoding='utf-8')) - plans_config = config.get('plans', {}) + config = json.loads(config_path.read_text(encoding="utf-8")) + plans_config = config.get("plans", {}) except Exception as e: logger.warning(f"[plans_processor] Config load failed: {e}") - return {'success': False, 'error': f'Config load failed: {e}'} + return {"success": False, "error": f"Config load failed: {e}"} - if not plans_config.get('enabled', False): - return {'success': True, 'skipped': True, 'reason': 'plans disabled'} + if not plans_config.get("enabled", False): + return {"success": True, "skipped": True, "reason": "plans disabled"} # Resolve plans directory (relative to repo root) - plans_dir = plans_config.get('path', 'src/aipass/flow/processed_plans') + plans_dir = plans_config.get("path", "src/aipass/flow/processed_plans") repo_root = _find_repo_root() plans_path = Path(plans_dir) if Path(plans_dir).is_absolute() else repo_root / plans_dir - extensions = plans_config.get('supported_extensions', ['.md']) - collection_name = plans_config.get('collection_name', 'flow_plans') + extensions = plans_config.get("supported_extensions", [".md"]) + collection_name = plans_config.get("collection_name", "flow_plans") if not plans_path.exists(): - return {'success': True, 'files_processed': 0, 'total_chunks': 0, 'reason': 'plans dir not found'} + return {"success": True, "files_processed": 0, "total_chunks": 0, "reason": "plans dir not found"} # Get plan files files = [] for ext in extensions: - files.extend(plans_path.glob(f'*{ext}')) + files.extend(plans_path.glob(f"*{ext}")) if not files: - return {'success': True, 'files_processed': 0, 'total_chunks': 0} + return {"success": True, "files_processed": 0, "total_chunks": 0} # Load manifest to skip already-processed files manifest = _load_manifest() unprocessed = [f for f in files if f.name not in manifest] if not unprocessed: - return {'success': True, 'files_processed': 0, 'total_chunks': 0, 'reason': 'all files already processed'} + return {"success": True, "files_processed": 0, "total_chunks": 0, "reason": "all files already processed"} logger.info(f"[plans] Found {len(unprocessed)} unprocessed plan files") @@ -275,10 +280,10 @@ def process_plans() -> Dict[str, Any]: for plan_file in unprocessed: try: - text = plan_file.read_text(encoding='utf-8') + text = plan_file.read_text(encoding="utf-8") except Exception as e: logger.warning(f"[plans_processor] Failed to read plan file {plan_file.name}: {e}") - errors.append(f'{plan_file.name}: read error: {e}') + errors.append(f"{plan_file.name}: read error: {e}") continue chunks = _chunk_plan_text(text, plan_file.name) @@ -288,13 +293,15 @@ def process_plans() -> Dict[str, Any]: files_with_chunks.append(plan_file) for c in chunks: - all_texts.append(c['text']) - all_metadatas.append({ - 'source_file': plan_file.name, - 'section': c['section'], - 'processed_at': datetime.now().isoformat(), - 'type': 'plan' - }) + all_texts.append(c["text"]) + all_metadatas.append( + { + "source_file": plan_file.name, + "section": c["section"], + "processed_at": datetime.now().isoformat(), + "type": "plan", + } + ) total_chunks = len(all_texts) files_processed = 0 @@ -308,17 +315,17 @@ def process_plans() -> Dict[str, Any]: logger.info(f"[plans] Batch embedding {total_chunks} chunks from {len(files_with_chunks)} files") embed_result = _embed_texts(all_texts) - if not embed_result.get('success'): + if not embed_result.get("success"): error_msg = f"batch embed error: {embed_result.get('error')}" logger.error(f"[plans] {error_msg}") errors.append(error_msg) else: - embeddings = embed_result.get('embeddings', []) + embeddings = embed_result.get("embeddings", []) if not embeddings: - errors.append('batch embed returned no embeddings') + errors.append("batch embed returned no embeddings") else: store_result = _store_vectors(embeddings, all_texts, all_metadatas, collection_name) - if not store_result.get('success'): + if not store_result.get("success"): error_msg = f"batch store error: {store_result.get('error')}" logger.error(f"[plans] {error_msg}") errors.append(error_msg) @@ -333,13 +340,16 @@ def process_plans() -> Dict[str, Any]: _save_manifest(manifest) result: Dict[str, Any] = { - 'success': files_processed > 0 or (not errors and not files_with_chunks), - 'files_processed': files_processed, - 'total_chunks': total_chunks if files_processed > 0 else 0, + "success": files_processed > 0 or (not errors and not files_with_chunks), + "files_processed": files_processed, + "total_chunks": total_chunks if files_processed > 0 else 0, } if errors: - result['errors'] = errors + result["errors"] = errors - json_handler.log_operation("process_plans", {"files_processed": files_processed, "total_chunks": result['total_chunks'], "success": result['success']}) + json_handler.log_operation( + "process_plans", + {"files_processed": files_processed, "total_chunks": result["total_chunks"], "success": result["success"]}, + ) return result diff --git a/src/aipass/memory/apps/handlers/intake/pool_processor.py b/src/aipass/memory/apps/handlers/intake/pool_processor.py index 27bc9250..37552088 100644 --- a/src/aipass/memory/apps/handlers/intake/pool_processor.py +++ b/src/aipass/memory/apps/handlers/intake/pool_processor.py @@ -46,7 +46,7 @@ def _notify_failure(subject: str, message: str) -> None: capture_output=True, text=True, timeout=30, - cwd=str(_MEMORY_ROOT) + cwd=str(_MEMORY_ROOT), ) except Exception as e: logger.warning(f"[pool_processor] Failed to send failure notification: {e}") @@ -64,10 +64,14 @@ def _update_central_and_dashboard() -> None: # Update central stats try: subprocess.run( - [sys.executable, "-c", - "from aipass.memory.apps.handlers.central_writer import update_central;" - "update_central()"], - capture_output=True, text=True, timeout=30 + [ + sys.executable, + "-c", + "from aipass.memory.apps.handlers.central_writer import update_central;update_central()", + ], + capture_output=True, + text=True, + timeout=30, ) except Exception as e: logger.warning(f"[pool_processor] Central stats update failed: {e}") @@ -75,10 +79,15 @@ def _update_central_and_dashboard() -> None: # Push dashboard to all branches try: subprocess.run( - [sys.executable, "-c", - "from aipass.memory.apps.handlers.dashboard_push import push_memory_bank_dashboard;" - "push_memory_bank_dashboard()"], - capture_output=True, text=True, timeout=60 + [ + sys.executable, + "-c", + "from aipass.memory.apps.handlers.dashboard_push import push_memory_bank_dashboard;" + "push_memory_bank_dashboard()", + ], + capture_output=True, + text=True, + timeout=60, ) except Exception as e: logger.warning(f"[pool_processor] Dashboard push failed: {e}") @@ -114,10 +123,10 @@ def load_config() -> dict: try: with open(CONFIG_PATH) as f: config = json.load(f) - return config.get('memory_pool', {}) + return config.get("memory_pool", {}) except Exception as e: logger.warning(f"[pool_processor] Failed to load config: {e}") - return {'enabled': False, 'error': str(e)} + return {"enabled": False, "error": str(e)} def get_pool_files(extensions: List[str] | None = None) -> List[Path]: @@ -131,14 +140,14 @@ def get_pool_files(extensions: List[str] | None = None) -> List[Path]: List of Path objects sorted newest to oldest """ if extensions is None: - extensions = ['.md', '.txt'] + extensions = [".md", ".txt"] if not MEMORY_POOL_PATH.exists(): return [] files = [] for ext in extensions: - files.extend(MEMORY_POOL_PATH.glob(f'*{ext}')) + files.extend(MEMORY_POOL_PATH.glob(f"*{ext}")) # Sort by modification time, newest first files.sort(key=lambda f: f.stat().st_mtime, reverse=True) @@ -153,23 +162,23 @@ def read_file_content(file_path: Path) -> dict: dict with 'success', 'content', 'metadata' """ try: - content = file_path.read_text(encoding='utf-8') + content = file_path.read_text(encoding="utf-8") stat = file_path.stat() return { - 'success': True, - 'content': content, - 'metadata': { - 'filename': file_path.name, - 'path': str(file_path), - 'size': stat.st_size, - 'modified': datetime.fromtimestamp(stat.st_mtime).isoformat(), - 'extension': file_path.suffix - } + "success": True, + "content": content, + "metadata": { + "filename": file_path.name, + "path": str(file_path), + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "extension": file_path.suffix, + }, } except Exception as e: logger.warning(f"[pool_processor] Failed to read file content: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} def chunk_content(content: str, chunk_size: int = 1000, overlap: int = 100) -> List[Dict[str, Any]]: @@ -185,7 +194,7 @@ def chunk_content(content: str, chunk_size: int = 1000, overlap: int = 100) -> L List of dicts with 'text' and 'chunk_index' """ if len(content) <= chunk_size: - return [{'text': content, 'chunk_index': 0}] + return [{"text": content, "chunk_index": 0}] chunks = [] start = 0 @@ -197,21 +206,18 @@ def chunk_content(content: str, chunk_size: int = 1000, overlap: int = 100) -> L # Try to break at paragraph or sentence if end < len(content): # Look for paragraph break - para_break = content.rfind('\n\n', start, end) + para_break = content.rfind("\n\n", start, end) if para_break > start + chunk_size // 2: end = para_break + 2 else: # Look for sentence break - sentence_break = content.rfind('. ', start, end) + sentence_break = content.rfind(". ", start, end) if sentence_break > start + chunk_size // 2: end = sentence_break + 2 chunk_text = content[start:end].strip() if chunk_text: - chunks.append({ - 'text': chunk_text, - 'chunk_index': chunk_index - }) + chunks.append({"text": chunk_text, "chunk_index": chunk_index}) chunk_index += 1 start = end - overlap if end < len(content) else len(content) @@ -219,7 +225,9 @@ def chunk_content(content: str, chunk_size: int = 1000, overlap: int = 100) -> L return chunks -def process_file_to_vectors(file_path: Path, collection_name: str, chunk_size: int = 1000, chunk_overlap: int = 100) -> dict: +def process_file_to_vectors( + file_path: Path, collection_name: str, chunk_size: int = 1000, chunk_overlap: int = 100 +) -> dict: """ Process a single file: read, chunk, and store vectors. @@ -234,11 +242,11 @@ def process_file_to_vectors(file_path: Path, collection_name: str, chunk_size: i """ # Read file read_result = read_file_content(file_path) - if not read_result['success']: + if not read_result["success"]: return read_result - content = read_result['content'] - metadata = read_result['metadata'] + content = read_result["content"] + metadata = read_result["metadata"] # Chunk content chunks = chunk_content(content, chunk_size, chunk_overlap) @@ -250,7 +258,7 @@ def process_file_to_vectors(file_path: Path, collection_name: str, chunk_size: i client = chromadb.PersistentClient(path=str(CHROMA_PATH)) collection = client.get_or_create_collection(name=collection_name) - model = SentenceTransformer('all-MiniLM-L6-v2') + model = SentenceTransformer("all-MiniLM-L6-v2") # Generate embeddings and store documents = [] @@ -259,40 +267,37 @@ def process_file_to_vectors(file_path: Path, collection_name: str, chunk_size: i for chunk in chunks: doc_id = f"{file_path.stem}_{chunk['chunk_index']}" - documents.append(chunk['text']) + documents.append(chunk["text"]) ids.append(doc_id) - metadatas.append({ - 'source': metadata['filename'], - 'chunk_index': chunk['chunk_index'], - 'total_chunks': len(chunks), - 'processed_at': datetime.now().isoformat(), - 'type': 'memory_pool' - }) + metadatas.append( + { + "source": metadata["filename"], + "chunk_index": chunk["chunk_index"], + "total_chunks": len(chunks), + "processed_at": datetime.now().isoformat(), + "type": "memory_pool", + } + ) # Batch encode embeddings = model.encode(documents).tolist() # Upsert (update if exists, insert if not) - collection.upsert( - documents=documents, - embeddings=embeddings, - ids=ids, - metadatas=metadatas - ) + collection.upsert(documents=documents, embeddings=embeddings, ids=ids, metadatas=metadatas) return { - 'success': True, - 'file': metadata['filename'], - 'chunks_stored': len(chunks), - 'collection': collection_name + "success": True, + "file": metadata["filename"], + "chunks_stored": len(chunks), + "collection": collection_name, } except Exception as e: logger.warning(f"[pool_processor] Failed to process file to vectors: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} -def archive_old_files(keep_recent: int, archive_path: str = 'memory_pool_archive') -> dict: +def archive_old_files(keep_recent: int, archive_path: str = "memory_pool_archive") -> dict: """ Archive files beyond the keep_recent limit. @@ -304,17 +309,12 @@ def archive_old_files(keep_recent: int, archive_path: str = 'memory_pool_archive dict with 'success', 'archived_count', 'kept_count' """ config = load_config() - extensions = config.get('supported_extensions', ['.md', '.txt']) + extensions = config.get("supported_extensions", [".md", ".txt"]) files = get_pool_files(extensions) if len(files) <= keep_recent: - return { - 'success': True, - 'archived_count': 0, - 'kept_count': len(files), - 'message': 'No files need archiving' - } + return {"success": True, "archived_count": 0, "kept_count": len(files), "message": "No files need archiving"} # Files to keep (most recent) keep_files = files[:keep_recent] @@ -332,7 +332,7 @@ def archive_old_files(keep_recent: int, archive_path: str = 'memory_pool_archive dest = archive_dir / file_path.name # If file exists in archive, add timestamp if dest.exists(): - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") dest = archive_dir / f"{file_path.stem}_{timestamp}{file_path.suffix}" shutil.move(str(file_path), str(dest)) @@ -342,17 +342,16 @@ def archive_old_files(keep_recent: int, archive_path: str = 'memory_pool_archive errors.append(f"{file_path.name}: {e}") result = { - 'success': len(errors) == 0, - 'archived_count': archived_count, - 'kept_count': len(keep_files), - 'errors': errors if errors else None + "success": len(errors) == 0, + "archived_count": archived_count, + "kept_count": len(keep_files), + "errors": errors if errors else None, } if errors: _notify_failure( f"Pool Archive Failed: {len(errors)} files", - f"Failed to archive {len(errors)} memory pool files.\n\n" - f"Errors:\n" + '\n'.join(errors) + f"Failed to archive {len(errors)} memory pool files.\n\nErrors:\n" + "\n".join(errors), ) return result @@ -372,78 +371,76 @@ def process_memory_pool() -> dict: config = load_config() - if not config.get('enabled', False): - return {'success': False, 'error': 'memory_pool processing is disabled in config'} + if not config.get("enabled", False): + return {"success": False, "error": "memory_pool processing is disabled in config"} - keep_recent = config.get('keep_recent', 10) - collection_name = config.get('collection_name', 'memory_pool_docs') - chunk_size = config.get('chunk_size', 1000) - chunk_overlap = config.get('chunk_overlap', 100) - extensions = config.get('supported_extensions', ['.md', '.txt']) - archive_path = config.get('archive_path', 'memory_pool_archive') + keep_recent = config.get("keep_recent", 10) + collection_name = config.get("collection_name", "memory_pool_docs") + chunk_size = config.get("chunk_size", 1000) + chunk_overlap = config.get("chunk_overlap", 100) + extensions = config.get("supported_extensions", [".md", ".txt"]) + archive_path = config.get("archive_path", "memory_pool_archive") # Get all files files = get_pool_files(extensions) if not files: - return { - 'success': True, - 'message': 'No files in memory_pool to process', - 'files_processed': 0 - } + return {"success": True, "message": "No files in memory_pool to process", "files_processed": 0} results = { - 'success': True, - 'files_found': len(files), - 'files_processed': 0, - 'total_chunks': 0, - 'errors': [], - 'processed_files': [] + "success": True, + "files_found": len(files), + "files_processed": 0, + "total_chunks": 0, + "errors": [], + "processed_files": [], } # Process each file for file_path in files: - result = process_file_to_vectors( - file_path, - collection_name, - chunk_size, - chunk_overlap - ) + result = process_file_to_vectors(file_path, collection_name, chunk_size, chunk_overlap) - if result['success']: - results['files_processed'] += 1 - results['total_chunks'] += result.get('chunks_stored', 0) - results['processed_files'].append(result['file']) + if result["success"]: + results["files_processed"] += 1 + results["total_chunks"] += result.get("chunks_stored", 0) + results["processed_files"].append(result["file"]) else: - results['errors'].append(f"{file_path.name}: {result.get('error')}") + results["errors"].append(f"{file_path.name}: {result.get('error')}") # Archive old files archive_result = archive_old_files(keep_recent, archive_path) - results['archive'] = archive_result + results["archive"] = archive_result - if results['errors']: - results['success'] = False + if results["errors"]: + results["success"] = False # Notify @devpulse about processing failures - error_count = len(results['errors']) - total_files = results['files_found'] - error_summary = '\n'.join(results['errors'][:10]) # Cap at 10 for readability + error_count = len(results["errors"]) + total_files = results["files_found"] + error_summary = "\n".join(results["errors"][:10]) # Cap at 10 for readability if error_count > 10: - error_summary += f'\n... and {error_count - 10} more errors' + error_summary += f"\n... and {error_count - 10} more errors" _notify_failure( f"Pool Processing Failed: {error_count}/{total_files} files", f"Memory pool processing encountered {error_count} failures " f"out of {total_files} files.\n\n" f"Errors:\n{error_summary}\n\n" - f"Successfully processed: {results['files_processed']}/{total_files}" + f"Successfully processed: {results['files_processed']}/{total_files}", ) # Update central stats and push dashboard after processing vectors - if results['files_processed'] > 0: + if results["files_processed"] > 0: _update_central_and_dashboard() - json_handler.log_operation("process_memory_pool", {"files_processed": results['files_processed'], "total_chunks": results['total_chunks'], "success": results['success']}) + json_handler.log_operation( + "process_memory_pool", + { + "files_processed": results["files_processed"], + "total_chunks": results["total_chunks"], + "success": results["success"], + }, + ) return results @@ -456,15 +453,16 @@ def get_pool_status() -> dict: dict with file counts, config, and collection info """ config = load_config() - extensions = config.get('supported_extensions', ['.md', '.txt']) + extensions = config.get("supported_extensions", [".md", ".txt"]) files = get_pool_files(extensions) # Get collection count collection_count = 0 try: import chromadb + client = chromadb.PersistentClient(path=str(CHROMA_PATH)) - collection_name = config.get('collection_name', 'memory_pool_docs') + collection_name = config.get("collection_name", "memory_pool_docs") if collection_name in [c.name for c in client.list_collections()]: collection = client.get_collection(name=collection_name) collection_count = collection.count() @@ -472,13 +470,13 @@ def get_pool_status() -> dict: logger.warning(f"[pool_processor] Failed to get collection count: {e}") return { - 'enabled': config.get('enabled', False), - 'files_in_pool': len(files), - 'keep_recent': config.get('keep_recent', 10), - 'vectors_stored': collection_count, - 'collection_name': config.get('collection_name', 'memory_pool_docs'), - 'newest_file': files[0].name if files else None, - 'oldest_file': files[-1].name if files else None + "enabled": config.get("enabled", False), + "files_in_pool": len(files), + "keep_recent": config.get("keep_recent", 10), + "vectors_stored": collection_count, + "collection_name": config.get("collection_name", "memory_pool_docs"), + "newest_file": files[0].name if files else None, + "oldest_file": files[-1].name if files else None, } @@ -486,7 +484,7 @@ def get_pool_status() -> dict: if __name__ == "__main__": import sys - if len(sys.argv) > 1 and sys.argv[1] == 'status': + if len(sys.argv) > 1 and sys.argv[1] == "status": status = get_pool_status() print(json.dumps(status, indent=2)) else: diff --git a/src/aipass/memory/apps/handlers/json/__init__.py b/src/aipass/memory/apps/handlers/json/__init__.py index eb06bced..90cf8b12 100644 --- a/src/aipass/memory/apps/handlers/json/__init__.py +++ b/src/aipass/memory/apps/handlers/json/__init__.py @@ -23,14 +23,14 @@ from .memory_files import ( __all__ = [ # json_handler (three-JSON standard) - 'log_operation', - 'read_json', - 'write_json', + "log_operation", + "read_json", + "write_json", # memory_files (memory file I/O) - 'read_memory_file', - 'write_memory_file', - 'update_metadata', - 'read_memory_file_data', - 'write_memory_file_simple', - 'validate_memory_file_structure', + "read_memory_file", + "write_memory_file", + "update_metadata", + "read_memory_file_data", + "write_memory_file_simple", + "validate_memory_file_structure", ] diff --git a/src/aipass/memory/apps/handlers/json/json_handler.py b/src/aipass/memory/apps/handlers/json/json_handler.py index ee6e394d..dcc368e5 100644 --- a/src/aipass/memory/apps/handlers/json/json_handler.py +++ b/src/aipass/memory/apps/handlers/json/json_handler.py @@ -50,7 +50,7 @@ def _get_caller_module_name() -> str: if len(stack) > 2: caller_path = Path(stack[2].filename) module_name = caller_path.stem - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name return "unknown" diff --git a/src/aipass/memory/apps/handlers/json/memory_files.py b/src/aipass/memory/apps/handlers/json/memory_files.py index 907f4274..db4751d0 100644 --- a/src/aipass/memory/apps/handlers/json/memory_files.py +++ b/src/aipass/memory/apps/handlers/json/memory_files.py @@ -50,6 +50,7 @@ _TEMPLATES_DIR = _MEMORY_ROOT / "apps" / "json_templates" # CORE READ/WRITE OPERATIONS # ============================================================================= + def read_memory_file(file_path: Path) -> Dict[str, Any]: """ Safe read of memory JSON file @@ -69,41 +70,25 @@ def read_memory_file(file_path: Path) -> Dict[str, Any]: sessions = data.get('sessions', []) """ if not file_path.exists(): - return { - 'success': False, - 'error': f"File not found: {file_path}" - } + return {"success": False, "error": f"File not found: {file_path}"} try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) - return { - 'success': True, - 'file': str(file_path), - 'data': data - } + return {"success": True, "file": str(file_path), "data": data} except json.JSONDecodeError as e: logger.warning(f"[memory_files] Corrupt JSON in {file_path.name}: {e}") - return { - 'success': False, - 'error': f"Corrupt JSON in {file_path.name}: {e}" - } + return {"success": False, "error": f"Corrupt JSON in {file_path.name}: {e}"} except PermissionError: logger.warning(f"[memory_files] Permission denied reading {file_path.name}") - return { - 'success': False, - 'error': f"Permission denied reading {file_path.name}" - } + return {"success": False, "error": f"Permission denied reading {file_path.name}"} except Exception as e: logger.warning(f"[memory_files] Failed to read {file_path.name}: {e}") - return { - 'success': False, - 'error': f"Failed to read {file_path.name}: {e}" - } + return {"success": False, "error": f"Failed to read {file_path.name}: {e}"} def write_memory_file(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: @@ -133,34 +118,24 @@ def write_memory_file(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: - Original file unchanged if write fails """ if not isinstance(data, dict): - return { - 'success': False, - 'error': f"Data must be dict, got {type(data).__name__}" - } + return {"success": False, "error": f"Data must be dict, got {type(data).__name__}"} try: # Create temp file in same directory (for atomic rename) - temp_fd, temp_path = tempfile.mkstemp( - dir=file_path.parent, - prefix=f".{file_path.name}.", - suffix=".tmp" - ) + temp_fd, temp_path = tempfile.mkstemp(dir=file_path.parent, prefix=f".{file_path.name}.", suffix=".tmp") try: # Write to temp file - with open(temp_fd, 'w', encoding='utf-8') as f: + with open(temp_fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') # Add final newline + f.write("\n") # Add final newline # Atomic replace (os.replace overwrites on both Linux and Windows) os.replace(temp_path, file_path) json_handler.log_operation("write_memory_file", {"file": file_path.name, "success": True}) - return { - 'success': True, - 'file': str(file_path) - } + return {"success": True, "file": str(file_path)} except Exception as e: # Clean up temp file on failure @@ -170,23 +145,18 @@ def write_memory_file(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: except PermissionError: logger.warning(f"[memory_files] Permission denied writing {file_path.name}") - return { - 'success': False, - 'error': f"Permission denied writing {file_path.name}" - } + return {"success": False, "error": f"Permission denied writing {file_path.name}"} except Exception as e: logger.warning(f"[memory_files] Failed to write {file_path.name}: {e}") - return { - 'success': False, - 'error': f"Failed to write {file_path.name}: {e}" - } + return {"success": False, "error": f"Failed to write {file_path.name}: {e}"} # ============================================================================= # METADATA HELPERS # ============================================================================= + def update_metadata( file_path: Path, **updates: Any, @@ -220,20 +190,20 @@ def update_metadata( """ # Read current data read_result = read_memory_file(file_path) - if not read_result['success']: + if not read_result["success"]: return read_result - data = read_result['data'] + data = read_result["data"] # Ensure metadata structure exists - if 'document_metadata' not in data: - data['document_metadata'] = {} + if "document_metadata" not in data: + data["document_metadata"] = {} - if 'status' not in data['document_metadata']: - data['document_metadata']['status'] = {} + if "status" not in data["document_metadata"]: + data["document_metadata"]["status"] = {} # Apply updates - status = data['document_metadata']['status'] + status = data["document_metadata"]["status"] for key, value in updates.items(): status[key] = value @@ -247,6 +217,7 @@ def update_metadata( # CONVENIENCE FUNCTIONS # ============================================================================= + def read_memory_file_data(file_path: Path) -> Optional[Dict[str, Any]]: """ Read memory file and return data directly (no dict wrapper) @@ -266,8 +237,8 @@ def read_memory_file_data(file_path: Path) -> Optional[Dict[str, Any]]: sessions = data.get('sessions', []) """ result = read_memory_file(file_path) - if result['success']: - return result.get('data') + if result["success"]: + return result.get("data") return None @@ -290,13 +261,14 @@ def write_memory_file_simple(file_path: Path, data: Dict[str, Any]) -> bool: print("Success!") """ result = write_memory_file(file_path, data) - return result['success'] + return result["success"] # ============================================================================= # VALIDATION HELPERS # ============================================================================= + def validate_memory_file_structure(data: Dict[str, Any]) -> tuple[bool, str]: """ Validate memory file has required structure @@ -318,16 +290,16 @@ def validate_memory_file_structure(data: Dict[str, Any]) -> tuple[bool, str]: if not isinstance(data, dict): return False, "Data is not a dictionary" - if 'document_metadata' not in data: + if "document_metadata" not in data: return False, "Missing 'document_metadata' field" - metadata = data['document_metadata'] + metadata = data["document_metadata"] if not isinstance(metadata, dict): return False, "'document_metadata' is not a dictionary" # Check for expected fields - expected = ['document_type', 'document_name', 'version'] + expected = ["document_type", "document_name", "version"] missing = [field for field in expected if field not in metadata] if missing: @@ -357,11 +329,11 @@ if __name__ == "__main__": print(f"[TEST] Reading {test_file.name}...") result = read_memory_file(test_file) - if result['success']: - file_data = result['data'] + if result["success"]: + file_data = result["data"] print("+ Read successful") print(f" Document type: {file_data.get('document_metadata', {}).get('document_type')}") - file_status = file_data.get('document_metadata', {}).get('status', {}).get('health') + file_status = file_data.get("document_metadata", {}).get("status", {}).get("health") print(f" Status: {file_status}") # Validate structure diff --git a/src/aipass/memory/apps/handlers/learnings/manager.py b/src/aipass/memory/apps/handlers/learnings/manager.py index 624b45cf..6a769a30 100644 --- a/src/aipass/memory/apps/handlers/learnings/manager.py +++ b/src/aipass/memory/apps/handlers/learnings/manager.py @@ -38,15 +38,13 @@ from aipass.memory.apps.handlers.json import json_handler logger = get_system_logger() # Handler imports (relative within package) -from aipass.memory.apps.handlers.json.memory_files import ( - read_memory_file_data, - write_memory_file_simple -) +from aipass.memory.apps.handlers.json.memory_files import read_memory_file_data, write_memory_file_simple # ChromaDB subprocess for vectorization (resolved relative to handler location) _MEMORY_ROOT = Path(__file__).resolve().parents[3] CHROMA_SUBPROCESS_SCRIPT = _MEMORY_ROOT / "apps" / "handlers" / "storage" / "chroma_subprocess.py" + def _find_repo_root() -> Path: """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -66,6 +64,7 @@ TIMESTAMP_PATTERN = r"\[(\d{4}-\d{2}-\d{2})\]$" # TIMESTAMP OPERATIONS # ============================================================================= + def parse_timestamp(value: str) -> Tuple[str, str | None]: """ Parse timestamp from key_learnings value @@ -83,7 +82,7 @@ def parse_timestamp(value: str) -> Tuple[str, str | None]: match = re.search(TIMESTAMP_PATTERN, value.strip()) if match: timestamp = match.group(1) - clean_value = value[:match.start()].strip() + clean_value = value[: match.start()].strip() return (clean_value, timestamp) return (value, None) @@ -136,6 +135,7 @@ def get_entry_age(value: str) -> int: # KEY_LEARNINGS LOCATION # ============================================================================= + def _find_learnings_location(data: Dict[str, Any]) -> Tuple[Dict[str, Any] | None, str]: """ Find key_learnings in data structure @@ -150,15 +150,15 @@ def _find_learnings_location(data: Dict[str, Any]) -> Tuple[Dict[str, Any] | Non Returns (None, '') if not found """ # Check root level first - if 'key_learnings' in data: - return (data, 'root') + if "key_learnings" in data: + return (data, "root") # Check inside active_tasks (legacy devpulse structure) - if 'active_tasks' in data and isinstance(data['active_tasks'], dict): - if 'key_learnings' in data['active_tasks']: - return (data['active_tasks'], 'active_tasks') + if "active_tasks" in data and isinstance(data["active_tasks"], dict): + if "key_learnings" in data["active_tasks"]: + return (data["active_tasks"], "active_tasks") - return (None, '') + return (None, "") def _get_learnings(data: Dict[str, Any]) -> Dict[str, str]: @@ -174,7 +174,7 @@ def _get_learnings(data: Dict[str, Any]) -> Dict[str, str]: parent, _ = _find_learnings_location(data) if parent is None: return {} - return parent.get('key_learnings', {}) + return parent.get("key_learnings", {}) def _set_learnings(data: Dict[str, Any], learnings: Dict[str, str]) -> bool: @@ -191,9 +191,9 @@ def _set_learnings(data: Dict[str, Any], learnings: Dict[str, str]) -> bool: parent, _ = _find_learnings_location(data) if parent is None: # Create at root level if not exists - data['key_learnings'] = learnings + data["key_learnings"] = learnings return True - parent['key_learnings'] = learnings + parent["key_learnings"] = learnings return True @@ -201,6 +201,7 @@ def _set_learnings(data: Dict[str, Any], learnings: Dict[str, str]) -> bool: # CONFIG OPERATIONS # ============================================================================= + def get_max_learnings(data: Dict[str, Any]) -> int: """ Get max_entries limit from file metadata @@ -215,14 +216,15 @@ def get_max_learnings(data: Dict[str, Any]) -> int: Returns: Maximum allowed key_learnings entries """ - limits = data.get('document_metadata', {}).get('limits', {}) - return limits.get('max_learnings', DEFAULT_MAX_LEARNINGS) + limits = data.get("document_metadata", {}).get("limits", {}) + return limits.get("max_learnings", DEFAULT_MAX_LEARNINGS) # ============================================================================= # RECENTLY_COMPLETED LOCATION # ============================================================================= + def _find_recently_completed_location(data: Dict[str, Any]) -> Tuple[Dict[str, Any] | None, str]: """ Find recently_completed in data structure. @@ -237,15 +239,15 @@ def _find_recently_completed_location(data: Dict[str, Any]) -> Tuple[Dict[str, A Returns (None, '') if not found """ # Check root level first - if 'recently_completed' in data: - return (data, 'root') + if "recently_completed" in data: + return (data, "root") # Check inside active_tasks (devpulse structure) - if 'active_tasks' in data and isinstance(data['active_tasks'], dict): - if 'recently_completed' in data['active_tasks']: - return (data['active_tasks'], 'active_tasks') + if "active_tasks" in data and isinstance(data["active_tasks"], dict): + if "recently_completed" in data["active_tasks"]: + return (data["active_tasks"], "active_tasks") - return (None, '') + return (None, "") def _get_recently_completed(data: Dict[str, Any]) -> List[str]: @@ -261,7 +263,7 @@ def _get_recently_completed(data: Dict[str, Any]) -> List[str]: parent, _ = _find_recently_completed_location(data) if parent is None: return [] - return parent.get('recently_completed', []) + return parent.get("recently_completed", []) def _set_recently_completed(data: Dict[str, Any], completed: List[str]) -> bool: @@ -278,9 +280,9 @@ def _set_recently_completed(data: Dict[str, Any], completed: List[str]) -> bool: parent, _ = _find_recently_completed_location(data) if parent is None: # Create at root level if not exists - data['recently_completed'] = completed + data["recently_completed"] = completed return True - parent['recently_completed'] = completed + parent["recently_completed"] = completed return True @@ -298,18 +300,16 @@ def get_max_recently_completed(data: Dict[str, Any]) -> int: Returns: Maximum allowed recently_completed entries """ - limits = data.get('document_metadata', {}).get('limits', {}) - return limits.get('max_recently_completed', DEFAULT_MAX_RECENTLY_COMPLETED) + limits = data.get("document_metadata", {}).get("limits", {}) + return limits.get("max_recently_completed", DEFAULT_MAX_RECENTLY_COMPLETED) # ============================================================================= # VECTORIZATION # ============================================================================= -def _vectorize_learnings( - branch: str, - learnings: List[Tuple[str, str]] -) -> Dict[str, Any]: + +def _vectorize_learnings(branch: str, learnings: List[Tuple[str, str]]) -> Dict[str, Any]: """ Vectorize key_learnings entries to memory @@ -321,7 +321,7 @@ def _vectorize_learnings( Dict with success status """ if not learnings: - return {'success': True, 'message': 'No learnings to vectorize'} + return {"success": True, "message": "No learnings to vectorize"} # Prepare texts and metadata for vectorization texts = [] @@ -331,47 +331,44 @@ def _vectorize_learnings( clean_value, timestamp = parse_timestamp(value) text = f"{key}: {clean_value}" texts.append(text) - metadatas.append({ - 'branch': branch, - 'type': 'key_learning', - 'key': key, - 'timestamp': timestamp or 'unknown', - 'archived_at': datetime.now().isoformat() - }) + metadatas.append( + { + "branch": branch, + "type": "key_learning", + "key": key, + "timestamp": timestamp or "unknown", + "archived_at": datetime.now().isoformat(), + } + ) # Generate embeddings via subprocess try: from aipass.memory.apps.handlers.vector import embedder + embed_result = embedder.encode_batch(texts) - if not embed_result['success']: - return { - 'success': False, - 'error': f"Embedding failed: {embed_result.get('error')}" - } + if not embed_result["success"]: + return {"success": False, "error": f"Embedding failed: {embed_result.get('error')}"} - embeddings = embed_result.get('embeddings', []) + embeddings = embed_result.get("embeddings", []) if not embeddings: - return {'success': False, 'error': 'No embeddings generated'} + return {"success": False, "error": "No embeddings generated"} except Exception as e: logger.warning(f"[learnings_manager] Embedding error for key_learnings: {e}") - return {'success': False, 'error': f"Embedding error: {e}"} + return {"success": False, "error": f"Embedding error: {e}"} # Store in ChromaDB via subprocess - embeddings_serializable = [ - emb.tolist() if hasattr(emb, 'tolist') else emb - for emb in embeddings - ] + embeddings_serializable = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings] input_data = { - 'operation': 'store_vectors', - 'branch': branch, - 'memory_type': 'key_learnings', - 'embeddings': embeddings_serializable, - 'documents': texts, - 'metadatas': metadatas, - 'db_path': None # Global memory + "operation": "store_vectors", + "branch": branch, + "memory_type": "key_learnings", + "embeddings": embeddings_serializable, + "documents": texts, + "metadatas": metadatas, + "db_path": None, # Global memory } try: @@ -380,29 +377,26 @@ def _vectorize_learnings( input=json.dumps(input_data), capture_output=True, text=True, - timeout=60 + timeout=60, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Subprocess failed'} + return {"success": False, "error": result.stderr or "Subprocess failed"} return json.loads(result.stdout) except subprocess.TimeoutExpired: logger.warning("[learnings_manager] Key learnings vectorization timed out") - return {'success': False, 'error': 'Vectorization timed out'} + return {"success": False, "error": "Vectorization timed out"} except json.JSONDecodeError as e: logger.warning(f"[learnings_manager] Invalid JSON from key_learnings vectorization: {e}") - return {'success': False, 'error': f'Invalid JSON response: {e}'} + return {"success": False, "error": f"Invalid JSON response: {e}"} except Exception as e: logger.warning(f"[learnings_manager] Key learnings vectorization error: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} -def _vectorize_completed_tasks( - branch: str, - tasks: List[str] -) -> Dict[str, Any]: +def _vectorize_completed_tasks(branch: str, tasks: List[str]) -> Dict[str, Any]: """ Vectorize recently_completed entries to memory. @@ -414,7 +408,7 @@ def _vectorize_completed_tasks( Dict with success status """ if not tasks: - return {'success': True, 'message': 'No tasks to vectorize'} + return {"success": True, "message": "No tasks to vectorize"} # Prepare document_texts and document_metadatas for vectorization document_texts: List[str] = [] @@ -423,46 +417,43 @@ def _vectorize_completed_tasks( for task in tasks: clean_value, timestamp = parse_timestamp(task) document_texts.append(clean_value) - document_metadatas.append({ - 'branch': branch, - 'type': 'recently_completed', - 'timestamp': timestamp or 'unknown', - 'archived_at': datetime.now().isoformat() - }) + document_metadatas.append( + { + "branch": branch, + "type": "recently_completed", + "timestamp": timestamp or "unknown", + "archived_at": datetime.now().isoformat(), + } + ) # Generate embeddings via subprocess try: from aipass.memory.apps.handlers.vector import embedder + embed_result = embedder.encode_batch(document_texts) - if not embed_result['success']: - return { - 'success': False, - 'error': f"Embedding failed: {embed_result.get('error')}" - } + if not embed_result["success"]: + return {"success": False, "error": f"Embedding failed: {embed_result.get('error')}"} - embeddings = embed_result.get('embeddings', []) + embeddings = embed_result.get("embeddings", []) if not embeddings: - return {'success': False, 'error': 'No embeddings generated'} + return {"success": False, "error": "No embeddings generated"} except Exception as e: logger.warning(f"[learnings_manager] Embedding error for recently_completed: {e}") - return {'success': False, 'error': f"Embedding error: {e}"} + return {"success": False, "error": f"Embedding error: {e}"} # Store in ChromaDB via subprocess - embeddings_serializable = [ - emb.tolist() if hasattr(emb, 'tolist') else emb - for emb in embeddings - ] + embeddings_serializable = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings] input_data = { - 'operation': 'store_vectors', - 'branch': branch, - 'memory_type': 'recently_completed', - 'embeddings': embeddings_serializable, - 'documents': document_texts, - 'metadatas': document_metadatas, - 'db_path': None # Global memory + "operation": "store_vectors", + "branch": branch, + "memory_type": "recently_completed", + "embeddings": embeddings_serializable, + "documents": document_texts, + "metadatas": document_metadatas, + "db_path": None, # Global memory } try: @@ -471,29 +462,30 @@ def _vectorize_completed_tasks( input=json.dumps(input_data), capture_output=True, text=True, - timeout=60 + timeout=60, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Subprocess failed'} + return {"success": False, "error": result.stderr or "Subprocess failed"} return json.loads(result.stdout) except subprocess.TimeoutExpired: logger.warning("[learnings_manager] Completed tasks vectorization timed out") - return {'success': False, 'error': 'Vectorization timed out'} + return {"success": False, "error": "Vectorization timed out"} except json.JSONDecodeError as e: logger.warning(f"[learnings_manager] Invalid JSON from completed tasks vectorization: {e}") - return {'success': False, 'error': f'Invalid JSON response: {e}'} + return {"success": False, "error": f"Invalid JSON response: {e}"} except Exception as e: logger.warning(f"[learnings_manager] Completed tasks vectorization error: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # CORE OPERATIONS # ============================================================================= + def ensure_timestamps(file_path: Path) -> Dict[str, Any]: """ Ensure all key_learnings entries have timestamps @@ -507,19 +499,19 @@ def ensure_timestamps(file_path: Path) -> Dict[str, Any]: Dict with update status and count """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} try: data = read_memory_file_data(file_path) if data is None: - return {'success': False, 'error': f'Failed to parse file: {file_path.name}'} + return {"success": False, "error": f"Failed to parse file: {file_path.name}"} except Exception as e: logger.warning(f"[learnings_manager] Failed to read file: {e}") - return {'success': False, 'error': f'Failed to read file: {e}'} + return {"success": False, "error": f"Failed to read file: {e}"} learnings = _get_learnings(data) if not learnings: - return {'success': True, 'updated': 0, 'message': 'No key_learnings found'} + return {"success": True, "updated": 0, "message": "No key_learnings found"} updated_count = 0 today = datetime.now().strftime("%Y-%m-%d") @@ -536,13 +528,9 @@ def ensure_timestamps(file_path: Path) -> Dict[str, Any]: write_memory_file_simple(file_path, data) except Exception as e: logger.warning(f"[learnings_manager] Failed to write file: {e}") - return {'success': False, 'error': f'Failed to write file: {e}'} + return {"success": False, "error": f"Failed to write file: {e}"} - return { - 'success': True, - 'updated': updated_count, - 'total': len(learnings) - } + return {"success": True, "updated": updated_count, "total": len(learnings)} def enforce_limit(file_path: Path) -> Dict[str, Any]: @@ -561,37 +549,37 @@ def enforce_limit(file_path: Path) -> Dict[str, Any]: Dict with enforcement status """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} try: data = read_memory_file_data(file_path) if data is None: - return {'success': False, 'error': f'Failed to parse file: {file_path.name}'} + return {"success": False, "error": f"Failed to parse file: {file_path.name}"} except Exception as e: logger.warning(f"[learnings_manager] Failed to read file: {e}") - return {'success': False, 'error': f'Failed to read file: {e}'} + return {"success": False, "error": f"Failed to read file: {e}"} learnings = _get_learnings(data) if not learnings: - return {'success': True, 'removed': 0, 'message': 'No key_learnings found'} + return {"success": True, "removed": 0, "message": "No key_learnings found"} max_entries = get_max_learnings(data) current_count = len(learnings) if current_count <= max_entries: return { - 'success': True, - 'removed': 0, - 'current': current_count, - 'max': max_entries, - 'message': 'Under limit, no action needed' + "success": True, + "removed": 0, + "current": current_count, + "max": max_entries, + "message": "Under limit, no action needed", } # Sort by age (oldest first) sorted_entries = sorted( learnings.items(), key=lambda x: get_entry_age(x[1]), - reverse=True # Oldest first + reverse=True, # Oldest first ) # Calculate how many to remove @@ -600,8 +588,8 @@ def enforce_limit(file_path: Path) -> Dict[str, Any]: to_keep = sorted_entries[to_remove_count:] # Extract branch name from filename - parts = file_path.stem.split('.') - branch_name = parts[0] if parts else 'UNKNOWN' + parts = file_path.stem.split(".") + branch_name = parts[0] if parts else "UNKNOWN" # Vectorize before removing vectorize_result = _vectorize_learnings(branch_name, to_remove) @@ -616,17 +604,19 @@ def enforce_limit(file_path: Path) -> Dict[str, Any]: write_memory_file_simple(file_path, data) except Exception as e: logger.warning(f"[learnings_manager] Failed to write file: {e}") - return {'success': False, 'error': f'Failed to write file: {e}'} + return {"success": False, "error": f"Failed to write file: {e}"} - json_handler.log_operation("enforce_limit", {"removed": to_remove_count, "remaining": len(to_keep), "success": True}) + json_handler.log_operation( + "enforce_limit", {"removed": to_remove_count, "remaining": len(to_keep), "success": True} + ) return { - 'success': True, - 'removed': to_remove_count, - 'vectorized': vectorize_result.get('success', False), - 'remaining': len(to_keep), - 'max': max_entries, - 'removed_keys': [k for k, _ in to_remove] + "success": True, + "removed": to_remove_count, + "vectorized": vectorize_result.get("success", False), + "remaining": len(to_keep), + "max": max_entries, + "removed_keys": [k for k, _ in to_remove], } @@ -634,6 +624,7 @@ def enforce_limit(file_path: Path) -> Dict[str, Any]: # RECENTLY_COMPLETED OPERATIONS # ============================================================================= + def ensure_timestamps_completed(file_path: Path) -> Dict[str, Any]: """ Ensure all recently_completed entries have timestamps. @@ -647,19 +638,19 @@ def ensure_timestamps_completed(file_path: Path) -> Dict[str, Any]: Dict with update status and count """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} try: data = read_memory_file_data(file_path) if data is None: - return {'success': False, 'error': f'Failed to parse file: {file_path.name}'} + return {"success": False, "error": f"Failed to parse file: {file_path.name}"} except Exception as e: logger.warning(f"[learnings_manager] Failed to read file: {e}") - return {'success': False, 'error': f'Failed to read file: {e}'} + return {"success": False, "error": f"Failed to read file: {e}"} completed = _get_recently_completed(data) if not completed: - return {'success': True, 'updated': 0, 'message': 'No recently_completed found'} + return {"success": True, "updated": 0, "message": "No recently_completed found"} updated_count = 0 today = datetime.now().strftime("%Y-%m-%d") @@ -679,13 +670,9 @@ def ensure_timestamps_completed(file_path: Path) -> Dict[str, Any]: write_memory_file_simple(file_path, data) except Exception as e: logger.warning(f"[learnings_manager] Failed to write file: {e}") - return {'success': False, 'error': f'Failed to write file: {e}'} + return {"success": False, "error": f"Failed to write file: {e}"} - return { - 'success': True, - 'updated': updated_count, - 'total': len(completed) - } + return {"success": True, "updated": updated_count, "total": len(completed)} def enforce_limit_completed(file_path: Path) -> Dict[str, Any]: @@ -704,30 +691,30 @@ def enforce_limit_completed(file_path: Path) -> Dict[str, Any]: Dict with enforcement status """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} try: data = read_memory_file_data(file_path) if data is None: - return {'success': False, 'error': f'Failed to parse file: {file_path.name}'} + return {"success": False, "error": f"Failed to parse file: {file_path.name}"} except Exception as e: logger.warning(f"[learnings_manager] Failed to read file: {e}") - return {'success': False, 'error': f'Failed to read file: {e}'} + return {"success": False, "error": f"Failed to read file: {e}"} completed = _get_recently_completed(data) if not completed: - return {'success': True, 'removed': 0, 'message': 'No recently_completed found'} + return {"success": True, "removed": 0, "message": "No recently_completed found"} max_entries = get_max_recently_completed(data) current_count = len(completed) if current_count <= max_entries: return { - 'success': True, - 'removed': 0, - 'current': current_count, - 'max': max_entries, - 'message': 'Under limit, no action needed' + "success": True, + "removed": 0, + "current": current_count, + "max": max_entries, + "message": "Under limit, no action needed", } # Sort by age (oldest first) - for lists, we use index as proxy @@ -735,7 +722,7 @@ def enforce_limit_completed(file_path: Path) -> Dict[str, Any]: sorted_entries = sorted( completed, key=lambda x: get_entry_age(x), - reverse=True # Oldest first + reverse=True, # Oldest first ) # Calculate how many to remove @@ -744,8 +731,8 @@ def enforce_limit_completed(file_path: Path) -> Dict[str, Any]: to_keep = sorted_entries[to_remove_count:] # Extract branch name from filename - parts = file_path.stem.split('.') - branch_name = parts[0] if parts else 'UNKNOWN' + parts = file_path.stem.split(".") + branch_name = parts[0] if parts else "UNKNOWN" # Vectorize before removing vectorize_result = _vectorize_completed_tasks(branch_name, to_remove) @@ -757,23 +744,19 @@ def enforce_limit_completed(file_path: Path) -> Dict[str, Any]: write_memory_file_simple(file_path, data) except Exception as e: logger.warning(f"[learnings_manager] Failed to write file: {e}") - return {'success': False, 'error': f'Failed to write file: {e}'} + return {"success": False, "error": f"Failed to write file: {e}"} return { - 'success': True, - 'removed': to_remove_count, - 'vectorized': vectorize_result.get('success', False), - 'remaining': len(to_keep), - 'max': max_entries, - 'removed_tasks': to_remove + "success": True, + "removed": to_remove_count, + "vectorized": vectorize_result.get("success", False), + "remaining": len(to_keep), + "max": max_entries, + "removed_tasks": to_remove, } -def add_learning( - file_path: Path, - key: str, - value: str -) -> Dict[str, Any]: +def add_learning(file_path: Path, key: str, value: str) -> Dict[str, Any]: """ Add or update a key_learning entry. @@ -788,15 +771,15 @@ def add_learning( Dict with add status """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} try: data = read_memory_file_data(file_path) if data is None: - return {'success': False, 'error': f'Failed to parse file: {file_path.name}'} + return {"success": False, "error": f"Failed to parse file: {file_path.name}"} except Exception as e: logger.warning(f"[learnings_manager] Failed to read file: {e}") - return {'success': False, 'error': f'Failed to read file: {e}'} + return {"success": False, "error": f"Failed to read file: {e}"} # Get existing learnings or create empty dict learnings = _get_learnings(data) @@ -813,19 +796,21 @@ def add_learning( write_memory_file_simple(file_path, data) except Exception as e: logger.warning(f"[learnings_manager] Failed to write file: {e}") - return {'success': False, 'error': f'Failed to write file: {e}'} + return {"success": False, "error": f"Failed to write file: {e}"} # Enforce limit after adding enforce_result = enforce_limit(file_path) - json_handler.log_operation("add_learning", {"key": key, "action": "updated" if is_update else "added", "success": True}) + json_handler.log_operation( + "add_learning", {"key": key, "action": "updated" if is_update else "added", "success": True} + ) return { - 'success': True, - 'action': 'updated' if is_update else 'added', - 'key': key, - 'value': timestamped_value, - 'limit_enforced': enforce_result.get('removed', 0) > 0 + "success": True, + "action": "updated" if is_update else "added", + "key": key, + "value": timestamped_value, + "limit_enforced": enforce_result.get("removed", 0) > 0, } @@ -833,6 +818,7 @@ def add_learning( # STATUS COUNT UPDATES # ============================================================================= + def update_status_counts(file_path: Path) -> Dict[str, Any]: """ Update status.current_key_learnings and status.current_recently_completed. @@ -846,15 +832,15 @@ def update_status_counts(file_path: Path) -> Dict[str, Any]: Dict with update status """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} try: data = read_memory_file_data(file_path) if data is None: - return {'success': False, 'error': f'Failed to parse file: {file_path.name}'} + return {"success": False, "error": f"Failed to parse file: {file_path.name}"} except Exception as e: logger.warning(f"[learnings_manager] Failed to read file: {e}") - return {'success': False, 'error': f'Failed to read file: {e}'} + return {"success": False, "error": f"Failed to read file: {e}"} # Get actual counts learnings = _get_learnings(data) @@ -863,19 +849,19 @@ def update_status_counts(file_path: Path) -> Dict[str, Any]: completed_count = len(completed) # Ensure document_metadata.status exists - if 'document_metadata' not in data: - data['document_metadata'] = {} - if 'status' not in data['document_metadata']: - data['document_metadata']['status'] = {} + if "document_metadata" not in data: + data["document_metadata"] = {} + if "status" not in data["document_metadata"]: + data["document_metadata"]["status"] = {} - status = data['document_metadata']['status'] - old_learnings = status.get('current_key_learnings', 0) - old_completed = status.get('current_recently_completed', 0) + status = data["document_metadata"]["status"] + old_learnings = status.get("current_key_learnings", 0) + old_completed = status.get("current_recently_completed", 0) # Update counts - status['current_key_learnings'] = learnings_count - status['current_recently_completed'] = completed_count - status['last_health_check'] = datetime.now().strftime("%Y-%m-%d") + status["current_key_learnings"] = learnings_count + status["current_recently_completed"] = completed_count + status["last_health_check"] = datetime.now().strftime("%Y-%m-%d") # Only write if changed if old_learnings != learnings_count or old_completed != completed_count: @@ -883,13 +869,13 @@ def update_status_counts(file_path: Path) -> Dict[str, Any]: write_memory_file_simple(file_path, data) except Exception as e: logger.warning(f"[learnings_manager] Failed to write file: {e}") - return {'success': False, 'error': f'Failed to write file: {e}'} + return {"success": False, "error": f"Failed to write file: {e}"} return { - 'success': True, - 'current_key_learnings': learnings_count, - 'current_recently_completed': completed_count, - 'changed': old_learnings != learnings_count or old_completed != completed_count + "success": True, + "current_key_learnings": learnings_count, + "current_recently_completed": completed_count, + "changed": old_learnings != learnings_count or old_completed != completed_count, } @@ -897,6 +883,7 @@ def update_status_counts(file_path: Path) -> Dict[str, Any]: # BATCH OPERATIONS # ============================================================================= + def process_file(file_path: Path) -> Dict[str, Any]: """ Process a single .local.json file for both key_learnings and recently_completed. @@ -915,56 +902,51 @@ def process_file(file_path: Path) -> Dict[str, Any]: Dict with processing summary """ if not file_path.exists(): - return {'success': False, 'error': f'File not found: {file_path}'} + return {"success": False, "error": f"File not found: {file_path}"} - results: Dict[str, Any] = { - 'success': True, - 'key_learnings': {}, - 'recently_completed': {}, - 'status': {} - } + results: Dict[str, Any] = {"success": True, "key_learnings": {}, "recently_completed": {}, "status": {}} # Process key_learnings timestamp_result = ensure_timestamps(file_path) limit_result = enforce_limit(file_path) - results['key_learnings'] = { - 'timestamps_added': timestamp_result.get('updated', 0), - 'removed': limit_result.get('removed', 0), - 'vectorized': limit_result.get('vectorized', False), - 'remaining': limit_result.get('remaining', 0) + results["key_learnings"] = { + "timestamps_added": timestamp_result.get("updated", 0), + "removed": limit_result.get("removed", 0), + "vectorized": limit_result.get("vectorized", False), + "remaining": limit_result.get("remaining", 0), } # Process recently_completed timestamp_completed = ensure_timestamps_completed(file_path) limit_completed = enforce_limit_completed(file_path) - results['recently_completed'] = { - 'timestamps_added': timestamp_completed.get('updated', 0), - 'removed': limit_completed.get('removed', 0), - 'vectorized': limit_completed.get('vectorized', False), - 'remaining': limit_completed.get('remaining', 0), - 'removed_tasks': limit_completed.get('removed_tasks', []) + results["recently_completed"] = { + "timestamps_added": timestamp_completed.get("updated", 0), + "removed": limit_completed.get("removed", 0), + "vectorized": limit_completed.get("vectorized", False), + "remaining": limit_completed.get("remaining", 0), + "removed_tasks": limit_completed.get("removed_tasks", []), } # Update status counts status_result = update_status_counts(file_path) - results['status'] = { - 'current_key_learnings': status_result.get('current_key_learnings', 0), - 'current_recently_completed': status_result.get('current_recently_completed', 0) + results["status"] = { + "current_key_learnings": status_result.get("current_key_learnings", 0), + "current_recently_completed": status_result.get("current_recently_completed", 0), } # Check for errors - if not timestamp_result['success']: - results['success'] = False - results['error'] = timestamp_result.get('error') - if not limit_result['success']: - results['success'] = False - results['error'] = limit_result.get('error') - if not timestamp_completed['success']: - results['success'] = False - results['error'] = timestamp_completed.get('error') - if not limit_completed['success']: - results['success'] = False - results['error'] = limit_completed.get('error') + if not timestamp_result["success"]: + results["success"] = False + results["error"] = timestamp_result.get("error") + if not limit_result["success"]: + results["success"] = False + results["error"] = limit_result.get("error") + if not timestamp_completed["success"]: + results["success"] = False + results["error"] = timestamp_completed.get("error") + if not limit_completed["success"]: + results["success"] = False + results["error"] = limit_completed.get("error") return results @@ -984,46 +966,40 @@ def process_all_branches() -> Dict[str, Any]: registry_path = _find_repo_root() / "AIPASS_REGISTRY.json" if not registry_path.exists(): - return {'success': False, 'error': 'AIPASS_REGISTRY.json not found'} + return {"success": False, "error": "AIPASS_REGISTRY.json not found"} try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) except Exception as e: logger.warning(f"[learnings_manager] Failed to read registry: {e}") - return {'success': False, 'error': f'Failed to read registry: {e}'} + return {"success": False, "error": f"Failed to read registry: {e}"} - branches = registry.get('branches', []) - results: Dict[str, Any] = { - 'success': True, - 'processed': 0, - 'skipped': 0, - 'errors': [], - 'details': {} - } + branches = registry.get("branches", []) + results: Dict[str, Any] = {"success": True, "processed": 0, "skipped": 0, "errors": [], "details": {}} for branch in branches: - branch_name = branch.get('name', 'UNKNOWN') - branch_path = Path(branch.get('path', '')) + branch_name = branch.get("name", "UNKNOWN") + branch_path = Path(branch.get("path", "")) if not branch_path.exists(): - results['skipped'] += 1 + results["skipped"] += 1 continue # Find .local.json file local_file = branch_path / f"{branch_name.upper()}.local.json" if not local_file.exists(): - results['skipped'] += 1 + results["skipped"] += 1 continue # Process this branch branch_result = process_file(local_file) - results['processed'] += 1 - results['details'][branch_name] = branch_result + results["processed"] += 1 + results["details"][branch_name] = branch_result - if not branch_result['success']: - results['errors'].append(f"{branch_name}: {branch_result.get('error')}") + if not branch_result["success"]: + results["errors"].append(f"{branch_name}: {branch_result.get('error')}") return results @@ -1035,65 +1011,64 @@ def process_all_branches() -> Dict[str, Any]: if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser( - description="Memory Sections Management Handler" - ) + parser = argparse.ArgumentParser(description="Memory Sections Management Handler") parser.add_argument( - 'command', + "command", choices=[ - 'timestamps', 'enforce', 'process-all', 'process-file', - 'timestamps-completed', 'enforce-completed', 'update-status' + "timestamps", + "enforce", + "process-all", + "process-file", + "timestamps-completed", + "enforce-completed", + "update-status", ], - help='Command to execute' - ) - parser.add_argument( - '--file', - type=Path, - help='Path to .local.json file' + help="Command to execute", ) + parser.add_argument("--file", type=Path, help="Path to .local.json file") args = parser.parse_args() - if args.command == 'process-all': + if args.command == "process-all": result = process_all_branches() print(json.dumps(result, indent=2)) - elif args.command == 'process-file': + elif args.command == "process-file": if not args.file: print("Error: --file required for process-file command") sys.exit(1) result = process_file(args.file) print(json.dumps(result, indent=2)) - elif args.command == 'timestamps': + elif args.command == "timestamps": if not args.file: print("Error: --file required for timestamps command") sys.exit(1) result = ensure_timestamps(args.file) print(json.dumps(result, indent=2)) - elif args.command == 'enforce': + elif args.command == "enforce": if not args.file: print("Error: --file required for enforce command") sys.exit(1) result = enforce_limit(args.file) print(json.dumps(result, indent=2)) - elif args.command == 'timestamps-completed': + elif args.command == "timestamps-completed": if not args.file: print("Error: --file required for timestamps-completed command") sys.exit(1) result = ensure_timestamps_completed(args.file) print(json.dumps(result, indent=2)) - elif args.command == 'enforce-completed': + elif args.command == "enforce-completed": if not args.file: print("Error: --file required for enforce-completed command") sys.exit(1) result = enforce_limit_completed(args.file) print(json.dumps(result, indent=2)) - elif args.command == 'update-status': + elif args.command == "update-status": if not args.file: print("Error: --file required for update-status command") sys.exit(1) diff --git a/src/aipass/memory/apps/handlers/monitor/detector.py b/src/aipass/memory/apps/handlers/monitor/detector.py index b16a3119..7c392270 100644 --- a/src/aipass/memory/apps/handlers/monitor/detector.py +++ b/src/aipass/memory/apps/handlers/monitor/detector.py @@ -50,9 +50,11 @@ _REPO_ROOT = _find_repo_root() # DATA STRUCTURES # ============================================================================= + @dataclass class RolloverTrigger: """Represents a file that needs rollover""" + branch: str memory_type: str # 'observations' or 'local' file_path: Path @@ -71,6 +73,7 @@ class RolloverTrigger: # REGISTRY OPERATIONS # ============================================================================= + def _read_registry() -> List[Dict[str, Any]]: """ Read AIPASS_REGISTRY.json from repo root. @@ -86,17 +89,17 @@ def _read_registry() -> List[Dict[str, Any]]: return [] try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: data = json.load(f) - branches = data.get('branches', []) + branches = data.get("branches", []) # Resolve relative paths against repo root for branch in branches: - raw_path = branch.get('path', '') + raw_path = branch.get("path", "") resolved = Path(raw_path) if not resolved.is_absolute(): resolved = _REPO_ROOT / raw_path - branch['path'] = str(resolved) + branch["path"] = str(resolved) return branches except Exception as e: @@ -118,7 +121,7 @@ def _get_memory_file_path(branch: Dict, memory_type: str) -> Path | None: Returns: Path to memory file, or None if not found """ - raw_path = branch.get('path', '') + raw_path = branch.get("path", "") if not raw_path: return None branch_path = Path(raw_path) @@ -126,7 +129,7 @@ def _get_memory_file_path(branch: Dict, memory_type: str) -> Path | None: return None # Memory files are in .trinity/ subdirectory - file_path = branch_path / '.trinity' / f'{memory_type}.json' + file_path = branch_path / ".trinity" / f"{memory_type}.json" return file_path if file_path.exists() else None @@ -135,6 +138,7 @@ def _get_memory_file_path(branch: Dict, memory_type: str) -> Path | None: # CONFIG LOADING # ============================================================================= + def _load_config() -> Dict[str, Any]: """ Load memory_bank.config.json @@ -149,7 +153,7 @@ def _load_config() -> Dict[str, Any]: return {} try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.warning(f"[detector] Failed to load config: {e}") @@ -160,6 +164,7 @@ def _load_config() -> Dict[str, Any]: # LINE COUNTING # ============================================================================= + def _count_file_lines(file_path: Path) -> int: """ Count physical lines in memory file @@ -171,7 +176,7 @@ def _count_file_lines(file_path: Path) -> int: Number of physical lines in file """ try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: return len(f.readlines()) except Exception as e: logger.warning(f"[detector] Failed to count lines in {file_path}: {e}") @@ -191,11 +196,11 @@ def _get_max_lines(file_path: Path, branch_name: str | None = None) -> int: """ # 1. Try file-level metadata first (highest priority) try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) - metadata = data.get('document_metadata', {}) - limits = metadata.get('limits', {}) - file_limit = limits.get('max_lines') + metadata = data.get("document_metadata", {}) + limits = metadata.get("limits", {}) + file_limit = limits.get("max_lines") if file_limit is not None: return file_limit except Exception as e: @@ -204,18 +209,18 @@ def _get_max_lines(file_path: Path, branch_name: str | None = None) -> int: # 2. Try branch-level config (if branch_name provided or can be extracted) if branch_name is None: # Extract from filename (e.g., SEEDGO.local.json -> SEEDGO) - parts = file_path.stem.split('.') + parts = file_path.stem.split(".") branch_name = parts[0] if parts else None if branch_name: config = _load_config() - branch_limits = config.get('rollover', {}).get('per_branch', {}).get(branch_name, {}) - if 'max_lines' in branch_limits: - return branch_limits['max_lines'] + branch_limits = config.get("rollover", {}).get("per_branch", {}).get(branch_name, {}) + if "max_lines" in branch_limits: + return branch_limits["max_lines"] # 3. Fall back to global default from config config = _load_config() - default_limit = config.get('rollover', {}).get('defaults', {}).get('max_lines') + default_limit = config.get("rollover", {}).get("defaults", {}).get("max_lines") if default_limit is not None: return default_limit @@ -227,6 +232,7 @@ def _get_max_lines(file_path: Path, branch_name: str | None = None) -> int: # ROLLOVER DETECTION # ============================================================================= + def _should_rollover(file_path: Path) -> tuple[bool, int, int, str, str]: """ Check if file should rollover (supports v1 line-based and v2 entry-count based). @@ -242,48 +248,48 @@ def _should_rollover(file_path: Path) -> tuple[bool, int, int, str, str]: # Read file data once for schema detection + limit checks try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) except Exception as e: # Can't parse — fall back to line-based with hardcoded default logger.warning(f"[detector] Failed to parse {file_path} for rollover check: {e}") - return (current_lines >= 600, current_lines, 600, '1.0.0', '') + return (current_lines >= 600, current_lines, 600, "1.0.0", "") - metadata = data.get('document_metadata', {}) - schema_version = metadata.get('schema_version', '1.0.0') - limits = metadata.get('limits', {}) + metadata = data.get("document_metadata", {}) + schema_version = metadata.get("schema_version", "1.0.0") + limits = metadata.get("limits", {}) # v2: entry-count based limits - if schema_version.startswith('2'): + if schema_version.startswith("2"): reasons = [] - max_sessions = limits.get('max_sessions') + max_sessions = limits.get("max_sessions") if max_sessions is not None: - sessions = data.get('sessions', []) + sessions = data.get("sessions", []) if isinstance(sessions, list) and len(sessions) > max_sessions: reasons.append(f"{len(sessions)}/{max_sessions} sessions") - max_key_learnings = limits.get('max_key_learnings') + max_key_learnings = limits.get("max_key_learnings") if max_key_learnings is not None: - key_learnings = data.get('key_learnings', {}) + key_learnings = data.get("key_learnings", {}) if isinstance(key_learnings, dict) and len(key_learnings) > max_key_learnings: reasons.append(f"{len(key_learnings)}/{max_key_learnings} key_learnings") - max_observations = limits.get('max_observations') + max_observations = limits.get("max_observations") if max_observations is not None: - observations = data.get('observations', []) + observations = data.get("observations", []) if isinstance(observations, list) and len(observations) > max_observations: reasons.append(f"{len(observations)}/{max_observations} observations") triggered = len(reasons) > 0 - return (triggered, current_lines, 0, schema_version, ', '.join(reasons)) + return (triggered, current_lines, 0, schema_version, ", ".join(reasons)) # v1: line-count based - max_lines = limits.get('max_lines') + max_lines = limits.get("max_lines") if max_lines is None: max_lines = _get_max_lines(file_path) - return (current_lines >= max_lines, current_lines, max_lines, '1.0.0', '') + return (current_lines >= max_lines, current_lines, max_lines, "1.0.0", "") def check_all_branches() -> Dict[str, Any]: @@ -301,19 +307,14 @@ def check_all_branches() -> Dict[str, Any]: # Read registry branches = _read_registry() if not branches: - return { - 'success': True, - 'triggers': [], - 'count': 0, - 'message': 'No branches in registry' - } + return {"success": True, "triggers": [], "count": 0, "message": "No branches in registry"} # Check each branch for branch in branches: - branch_name = branch.get('name', 'UNKNOWN') + branch_name = branch.get("name", "UNKNOWN") # Check both memory types - for memory_type in ['observations', 'local']: + for memory_type in ["observations", "local"]: file_path = _get_memory_file_path(branch, memory_type) if file_path is None: @@ -333,13 +334,15 @@ def check_all_branches() -> Dict[str, Any]: ) triggers.append(trigger) - json_handler.log_operation("check_all_branches", {"branches_checked": len(branches), "triggers_found": len(triggers)}) + json_handler.log_operation( + "check_all_branches", {"branches_checked": len(branches), "triggers_found": len(triggers)} + ) return { - 'success': True, - 'triggers': triggers, - 'count': len(triggers), - 'message': f'Found {len(triggers)} rollover triggers' if triggers else 'No rollover triggers detected' + "success": True, + "triggers": triggers, + "count": len(triggers), + "message": f"Found {len(triggers)} rollover triggers" if triggers else "No rollover triggers detected", } @@ -354,16 +357,13 @@ def check_single_file(file_path: Path) -> Dict[str, Any]: Dict with trigger status and details """ if not file_path.exists(): - return { - 'success': False, - 'error': f"File not found: {file_path}" - } + return {"success": False, "error": f"File not found: {file_path}"} should_trigger, current_lines, max_lines, schema_ver, v2_reason = _should_rollover(file_path) if should_trigger: # Extract branch and type from filename (e.g., SEEDGO.observations.json) - parts = file_path.stem.split('.') + parts = file_path.stem.split(".") branch_name = parts[0] if len(parts) > 0 else "UNKNOWN" memory_type = parts[1] if len(parts) > 1 else "unknown" @@ -377,20 +377,16 @@ def check_single_file(file_path: Path) -> Dict[str, Any]: v2_reason=v2_reason, ) - return { - 'success': True, - 'trigger': trigger, - 'should_rollover': True - } + return {"success": True, "trigger": trigger, "should_rollover": True} else: remaining = max_lines - current_lines if max_lines > 0 else 0 return { - 'success': True, - 'should_rollover': False, - 'current_lines': current_lines, - 'max_lines': max_lines, - 'schema_version': schema_ver, - 'remaining': remaining + "success": True, + "should_rollover": False, + "current_lines": current_lines, + "max_lines": max_lines, + "schema_version": schema_ver, + "remaining": remaining, } @@ -398,6 +394,7 @@ def check_single_file(file_path: Path) -> Dict[str, Any]: # STATISTICS # ============================================================================= + def get_rollover_stats() -> Dict[str, Any]: """ Get rollover statistics for all branches @@ -405,46 +402,40 @@ def get_rollover_stats() -> Dict[str, Any]: Returns: Dict with statistics for all branches """ - stats = { - 'success': True, - 'total_branches': 0, - 'files_checked': 0, - 'files_ready': 0, - 'branches': {} - } + stats = {"success": True, "total_branches": 0, "files_checked": 0, "files_ready": 0, "branches": {}} branches = _read_registry() - stats['total_branches'] = len(branches) + stats["total_branches"] = len(branches) for branch in branches: - branch_name = branch.get('name', 'UNKNOWN') + branch_name = branch.get("name", "UNKNOWN") branch_stats = {} - for memory_type in ['observations', 'local']: + for memory_type in ["observations", "local"]: file_path = _get_memory_file_path(branch, memory_type) if file_path is None: continue - stats['files_checked'] += 1 + stats["files_checked"] += 1 should_trigger, current_lines, max_lines, schema_ver, v2_reason = _should_rollover(file_path) stat_entry = { - 'current': current_lines, - 'max': max_lines, - 'ready': should_trigger, - 'remaining': max_lines - current_lines if max_lines > 0 else 0, - 'schema_version': schema_ver, + "current": current_lines, + "max": max_lines, + "ready": should_trigger, + "remaining": max_lines - current_lines if max_lines > 0 else 0, + "schema_version": schema_ver, } if v2_reason: - stat_entry['v2_reason'] = v2_reason + stat_entry["v2_reason"] = v2_reason branch_stats[memory_type] = stat_entry if should_trigger: - stats['files_ready'] += 1 + stats["files_ready"] += 1 if branch_stats: - stats['branches'][branch_name] = branch_stats + stats["branches"][branch_name] = branch_stats return stats diff --git a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py index 5f01c041..542ef974 100644 --- a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py +++ b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py @@ -35,6 +35,7 @@ logger = logging.getLogger(__name__) try: from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler + WATCHDOG_AVAILABLE = True except ImportError: WATCHDOG_AVAILABLE = False @@ -86,16 +87,16 @@ def _get_rollover_threshold(branch_name: str, file_path: Path | None = None) -> # 1. Check file-level metadata first (highest priority) if file_path is not None: try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) - metadata = data.get('document_metadata', {}) + metadata = data.get("document_metadata", {}) # v2 files use entry-count limits, not line limits - schema_version = metadata.get('schema_version', '1.0.0') - if schema_version.startswith('2'): + schema_version = metadata.get("schema_version", "1.0.0") + if schema_version.startswith("2"): return 999999 # Never trigger line-based rollover for v2 - file_limit = metadata.get('limits', {}).get('max_lines') + file_limit = metadata.get("limits", {}).get("max_lines") if file_limit is not None: return file_limit except Exception as e: @@ -108,12 +109,12 @@ def _get_rollover_threshold(branch_name: str, file_path: Path | None = None) -> with open(config_path) as f: config = json.load(f) - branch_limits = config.get('rollover', {}).get('per_branch', {}).get(branch_name, {}) - if 'max_lines' in branch_limits: - return branch_limits['max_lines'] + branch_limits = config.get("rollover", {}).get("per_branch", {}).get(branch_name, {}) + if "max_lines" in branch_limits: + return branch_limits["max_lines"] # 3. Fall back to defaults - default_limit = config.get('rollover', {}).get('defaults', {}).get('max_lines') + default_limit = config.get("rollover", {}).get("defaults", {}).get("max_lines") if default_limit is not None: return default_limit @@ -141,23 +142,23 @@ def check_and_rollover() -> Dict[str, Any]: # Only run once per process if _startup_check_done: - return {'success': True, 'skipped': True, 'reason': 'Already checked this session'} + return {"success": True, "skipped": True, "reason": "Already checked this session"} _startup_check_done = True results = { - 'success': True, - 'files_checked': 0, - 'files_over_limit': [], - 'rollover_triggered': False, - 'memory_pool': None + "success": True, + "files_checked": 0, + "files_over_limit": [], + "rollover_triggered": False, + "memory_pool": None, } # Get all branch paths branch_paths = _get_branch_paths() if not branch_paths: - results['error'] = 'No branch paths found' + results["error"] = "No branch paths found" return results # Check each branch for memory files over limit @@ -169,69 +170,74 @@ def check_and_rollover() -> Dict[str, Any]: branch_name = branch.name.upper() # Find memory files in .trinity/ subdirectory - trinity_dir = branch / '.trinity' + trinity_dir = branch / ".trinity" if not trinity_dir.exists(): continue - for pattern in ['local.json', 'observations.json']: + for pattern in ["local.json", "observations.json"]: for memory_file in trinity_dir.glob(pattern): - - results['files_checked'] += 1 + results["files_checked"] += 1 # Get threshold per file (file metadata > branch config > default) threshold = _get_rollover_threshold(branch_name, memory_file) try: - line_count = len(memory_file.read_text(encoding='utf-8').splitlines()) + line_count = len(memory_file.read_text(encoding="utf-8").splitlines()) # Sync current_lines metadata if stale try: import json as _json - _data = _json.loads(memory_file.read_text(encoding='utf-8')) - meta_lines = _data.get('document_metadata', {}).get('status', {}).get('current_lines') + + _data = _json.loads(memory_file.read_text(encoding="utf-8")) + meta_lines = _data.get("document_metadata", {}).get("status", {}).get("current_lines") if meta_lines != line_count: sync_result = update_line_count(memory_file) - if sync_result.get('success'): + if sync_result.get("success"): lines_synced += 1 # Re-read actual line count after metadata update - line_count = len(memory_file.read_text(encoding='utf-8').splitlines()) + line_count = len(memory_file.read_text(encoding="utf-8").splitlines()) except Exception as e: logger.warning(f"[memory_watcher] Non-critical metadata sync failed for {memory_file}: {e}") if line_count > threshold: - results['files_over_limit'].append({ - 'file': str(memory_file), - 'lines': line_count, - 'threshold': threshold - }) + results["files_over_limit"].append( + {"file": str(memory_file), "lines": line_count, "threshold": threshold} + ) except Exception as e: logger.warning(f"[memory_watcher] Failed to read memory file {memory_file}: {e}") - results['lines_synced'] = lines_synced + results["lines_synced"] = lines_synced # Trigger rollover if any files are over limit - if results['files_over_limit']: - results['rollover_triggered'] = True + if results["files_over_limit"]: + results["rollover_triggered"] = True try: from aipass.memory.apps.handlers.rollover.orchestrator import execute_rollover + execute_rollover() except ImportError: logger.warning("Rollover handler not available") except Exception as e: logger.error(f"[memory_watcher] Rollover execution failed: {e}") - results['rollover_error'] = str(e) - results['success'] = False + results["rollover_error"] = str(e) + results["success"] = False # Check memory_pool for new files to process - results['memory_pool'] = _check_memory_pool() + results["memory_pool"] = _check_memory_pool() # Check plans for new files to vectorize - results['plans'] = _check_plans() + results["plans"] = _check_plans() # Check code_archive for new files to index - results['code_archive'] = _check_code_archive() + results["code_archive"] = _check_code_archive() - json_handler.log_operation("check_and_rollover", {"files_checked": results.get('files_checked', 0), "rollover_triggered": results.get('rollover_triggered', False)}) + json_handler.log_operation( + "check_and_rollover", + { + "files_checked": results.get("files_checked", 0), + "rollover_triggered": results.get("rollover_triggered", False), + }, + ) return results @@ -255,48 +261,44 @@ def _check_memory_pool() -> Dict[str, Any]: try: with open(config_path) as f: config = json.load(f) - pool_config = config.get('memory_pool', {}) + pool_config = config.get("memory_pool", {}) except Exception as exc: logger.warning(f"[memory_watcher] Could not load memory pool config: {exc}") - return {'success': False, 'error': 'Could not load config'} + return {"success": False, "error": "Could not load config"} # Check if enabled - if not pool_config.get('enabled', False): - return {'success': True, 'skipped': True, 'reason': 'memory_pool disabled'} + if not pool_config.get("enabled", False): + return {"success": True, "skipped": True, "reason": "memory_pool disabled"} # Count files in pool (excluding .archive) - extensions = pool_config.get('supported_extensions', ['.md', '.txt']) - keep_recent = pool_config.get('keep_recent', 10) + extensions = pool_config.get("supported_extensions", [".md", ".txt"]) + keep_recent = pool_config.get("keep_recent", 10) files = [] for ext in extensions: - files.extend(pool_path.glob(f'*{ext}')) + files.extend(pool_path.glob(f"*{ext}")) file_count = len(files) # If under limit, nothing to do if file_count <= keep_recent: - return { - 'success': True, - 'files_in_pool': file_count, - 'keep_recent': keep_recent, - 'action': 'none' - } + return {"success": True, "files_in_pool": file_count, "keep_recent": keep_recent, "action": "none"} # Files exceed limit - run processor try: # NOTE: intake module not yet ported to aipass.memory package from aipass.memory.apps.handlers.intake.pool_processor import process_memory_pool # type: ignore[import-not-found] + result = process_memory_pool() return { - 'success': result.get('success', False), - 'files_processed': result.get('files_processed', 0), - 'files_archived': result.get('archive', {}).get('archived_count', 0), - 'action': 'processed' + "success": result.get("success", False), + "files_processed": result.get("files_processed", 0), + "files_archived": result.get("archive", {}).get("archived_count", 0), + "action": "processed", } except Exception as e: logger.warning(f"[memory_watcher] Memory pool processing failed: {e}") - return {'success': False, 'error': str(e), 'action': 'failed'} + return {"success": False, "error": str(e), "action": "failed"} def _check_plans() -> Dict[str, Any]: @@ -316,41 +318,41 @@ def _check_plans() -> Dict[str, Any]: # Load config try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) - plans_config = config.get('plans', {}) + plans_config = config.get("plans", {}) except Exception as exc: logger.warning(f"[memory_watcher] Could not load plans config: {exc}") - return {'success': False, 'error': 'Could not load config'} + return {"success": False, "error": "Could not load config"} # Check if enabled - if not plans_config.get('enabled', False): - return {'success': True, 'skipped': True, 'reason': 'plans disabled'} + if not plans_config.get("enabled", False): + return {"success": True, "skipped": True, "reason": "plans disabled"} # Get plans path and count files (supports absolute paths) - plans_dir = plans_config.get('path', 'plans') + plans_dir = plans_config.get("path", "plans") repo_root = _find_repo_root() plans_path = Path(plans_dir) if Path(plans_dir).is_absolute() else repo_root / plans_dir - extensions = plans_config.get('supported_extensions', ['.md']) + extensions = plans_config.get("supported_extensions", [".md"]) if not plans_path.exists(): - return {'success': True, 'skipped': True, 'reason': 'plans directory does not exist'} + return {"success": True, "skipped": True, "reason": "plans directory does not exist"} files = [] for ext in extensions: - files.extend(plans_path.glob(f'*{ext}')) + files.extend(plans_path.glob(f"*{ext}")) file_count = len(files) if file_count == 0: - return {'success': True, 'pending_files': 0, 'action': 'count_only'} + return {"success": True, "pending_files": 0, "action": "count_only"} # Load manifest to count unprocessed files manifest_path = _MEMORY_ROOT / "config" / ".plans_processed.json" manifest: Dict[str, str] = {} if manifest_path.exists(): try: - manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception as e: logger.warning(f"[memory_watcher] Failed to read plans manifest: {e}") @@ -360,7 +362,7 @@ def _check_plans() -> Dict[str, Any]: if pending_count > 0: logger.info(f"[plans] {pending_count} plans pending vectorization. Run: drone @memory process-plans") - return {'success': True, 'pending_files': pending_count, 'action': 'count_only'} + return {"success": True, "pending_files": pending_count, "action": "count_only"} def _check_code_archive() -> Dict[str, Any]: @@ -372,16 +374,18 @@ def _check_code_archive() -> Dict[str, Any]: """ try: from aipass.memory.apps.handlers.archive.indexer import check_for_new_files + return check_for_new_files() except Exception as e: logger.warning(f"[memory_watcher] Code archive check failed: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # UTILITY FUNCTIONS # ============================================================================= + def _find_repo_root() -> Path: """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -409,13 +413,13 @@ def _get_branch_paths() -> list[Path]: return [] try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: data = json.load(f) - branches = data.get('branches', []) + branches = data.get("branches", []) paths = [] for branch in branches: - raw_path = branch.get('path', '') + raw_path = branch.get("path", "") branch_path = Path(raw_path) if not branch_path.is_absolute(): branch_path = repo_root / raw_path @@ -440,13 +444,14 @@ def _is_memory_file(file_path: Path) -> bool: """ name = file_path.name parent = file_path.parent.name - return parent == '.trinity' and name in ('local.json', 'observations.json') + return parent == ".trinity" and name in ("local.json", "observations.json") # ============================================================================= # FILE SYSTEM EVENT HANDLER # ============================================================================= + class MemoryFileWatcher(FileSystemEventHandler): # type: ignore[misc] """Watch for memory file modifications""" @@ -478,22 +483,24 @@ class MemoryFileWatcher(FileSystemEventHandler): # type: ignore[misc] # Step 1: Update line count metadata update_result = update_line_count(file_path) - if not update_result['success']: - logger.error(f"[memory_watcher] Failed to update line count for {file_path.name}: {update_result.get('error')}") + if not update_result["success"]: + logger.error( + f"[memory_watcher] Failed to update line count for {file_path.name}: {update_result.get('error')}" + ) return - current_lines = update_result.get('lines', 0) + current_lines = update_result.get("lines", 0) logger.info(f"[memory_watcher] Updated {file_path.name}: {current_lines} lines") # Step 2: Check if rollover needed check_result = check_single_file(file_path) - if not check_result['success']: + if not check_result["success"]: logger.error(f"[memory_watcher] Failed to check rollover for {file_path.name}: {check_result.get('error')}") return - if check_result.get('should_rollover', False): - trigger = check_result.get('trigger') + if check_result.get("should_rollover", False): + trigger = check_result.get("trigger") logger.warning(f"[memory_watcher] ROLLOVER TRIGGERED: {trigger}") # Import rollover handler here to avoid circular imports @@ -516,6 +523,7 @@ class MemoryFileWatcher(FileSystemEventHandler): # type: ignore[misc] # WATCHER CONTROL FUNCTIONS # ============================================================================= + def start_memory_watcher() -> Dict[str, Any]: """ Start watching memory files for modifications @@ -529,19 +537,13 @@ def start_memory_watcher() -> Dict[str, Any]: global _observer if _observer and _observer.is_alive(): - return { - 'success': False, - 'error': 'Watcher already running' - } + return {"success": False, "error": "Watcher already running"} # Get all branch paths branch_paths = _get_branch_paths() if not branch_paths: - return { - 'success': False, - 'error': 'No branch paths found in AIPASS_REGISTRY.json' - } + return {"success": False, "error": "No branch paths found in AIPASS_REGISTRY.json"} # Create watcher instance watcher = MemoryFileWatcher() @@ -562,11 +564,7 @@ def start_memory_watcher() -> Dict[str, Any]: new_observer.start() _observer = new_observer - return { - 'success': True, - 'watched_paths': watched_paths, - 'count': len(watched_paths) - } + return {"success": True, "watched_paths": watched_paths, "count": len(watched_paths)} def stop_memory_watcher() -> Dict[str, Any]: @@ -579,10 +577,7 @@ def stop_memory_watcher() -> Dict[str, Any]: global _observer if not _observer or not _observer.is_alive(): - return { - 'success': False, - 'error': 'Watcher not running' - } + return {"success": False, "error": "Watcher not running"} _observer.stop() _observer.join() @@ -590,10 +585,7 @@ def stop_memory_watcher() -> Dict[str, Any]: logger.info("[memory_watcher] Stopped") - return { - 'success': True, - 'message': 'Memory watcher stopped' - } + return {"success": True, "message": "Memory watcher stopped"} def is_memory_watcher_active() -> bool: @@ -616,19 +608,12 @@ def get_watcher_status() -> Dict[str, Any]: active = is_memory_watcher_active() if not active: - return { - 'active': False, - 'message': 'Watcher not running' - } + return {"active": False, "message": "Watcher not running"} # Get watched paths branch_paths = _get_branch_paths() - return { - 'active': True, - 'watched_directories': len(branch_paths), - 'paths': [str(p) for p in branch_paths] - } + return {"active": True, "watched_directories": len(branch_paths), "paths": [str(p) for p in branch_paths]} # ============================================================================= @@ -639,21 +624,15 @@ if __name__ == "__main__": import argparse import time - parser = argparse.ArgumentParser( - description='Memory File Watcher - Monitor memory files for rollover' - ) - parser.add_argument( - 'command', - choices=['start', 'stop', 'status'], - help='Command to execute' - ) + parser = argparse.ArgumentParser(description="Memory File Watcher - Monitor memory files for rollover") + parser.add_argument("command", choices=["start", "stop", "status"], help="Command to execute") args = parser.parse_args() - if args.command == 'start': + if args.command == "start": result = start_memory_watcher() - if result['success']: + if result["success"]: print(f"Started watching {result['count']} directories") print("Press Ctrl+C to stop...") @@ -667,21 +646,21 @@ if __name__ == "__main__": else: print(f"Failed to start: {result.get('error')}") - elif args.command == 'stop': + elif args.command == "stop": result = stop_memory_watcher() - if result['success']: - print(result['message']) + if result["success"]: + print(result["message"]) else: print(f"Failed to stop: {result.get('error')}") - elif args.command == 'status': + elif args.command == "status": status = get_watcher_status() - if status['active']: + if status["active"]: print("Watcher is ACTIVE") print(f"Watching {status['watched_directories']} directories:") - for path in status['paths']: + for path in status["paths"]: print(f" - {path}") else: print("Watcher is INACTIVE") diff --git a/src/aipass/memory/apps/handlers/rollover/extractor.py b/src/aipass/memory/apps/handlers/rollover/extractor.py index f46b2166..92d4ac3f 100644 --- a/src/aipass/memory/apps/handlers/rollover/extractor.py +++ b/src/aipass/memory/apps/handlers/rollover/extractor.py @@ -45,6 +45,7 @@ logger = get_system_logger() # BACKUP OPERATIONS # ============================================================================= + def create_rollover_backup(file_path: Path) -> Dict[str, Any]: """ Create backup before rollover (safety net) @@ -61,31 +62,24 @@ def create_rollover_backup(file_path: Path) -> Dict[str, Any]: try: # Create .backup directory in branch root # For .trinity/ files, go up to branch root; otherwise use file's parent - if file_path.parent.name == '.trinity': - backup_dir = file_path.parent.parent / '.backup' + if file_path.parent.name == ".trinity": + backup_dir = file_path.parent.parent / ".backup" else: - backup_dir = file_path.parent / '.backup' + backup_dir = file_path.parent / ".backup" backup_dir.mkdir(exist_ok=True) # Backup filename: rollover_backup.json (always overwrites) - backup_name = f'rollover_backup_{file_path.name}' + backup_name = f"rollover_backup_{file_path.name}" backup_path = backup_dir / backup_name # Copy file shutil.copy2(file_path, backup_path) - return { - 'success': True, - 'backup_path': str(backup_path), - 'message': f'Backup created: {backup_path.name}' - } + return {"success": True, "backup_path": str(backup_path), "message": f"Backup created: {backup_path.name}"} except Exception as e: logger.error(f"[extractor] Backup failed for {file_path}: {e}") - return { - 'success': False, - 'error': f'Backup failed: {e}' - } + return {"success": False, "error": f"Backup failed: {e}"} def restore_from_backup(file_path: Path) -> Dict[str, Any]: @@ -102,39 +96,31 @@ def restore_from_backup(file_path: Path) -> Dict[str, Any]: """ try: # Match backup location from create_rollover_backup - if file_path.parent.name == '.trinity': - backup_dir = file_path.parent.parent / '.backup' + if file_path.parent.name == ".trinity": + backup_dir = file_path.parent.parent / ".backup" else: - backup_dir = file_path.parent / '.backup' - backup_name = f'rollover_backup_{file_path.name}' + backup_dir = file_path.parent / ".backup" + backup_name = f"rollover_backup_{file_path.name}" backup_path = backup_dir / backup_name if not backup_path.exists(): - return { - 'success': False, - 'error': 'No backup found to restore from' - } + return {"success": False, "error": "No backup found to restore from"} # Restore from backup shutil.copy2(backup_path, file_path) - return { - 'success': True, - 'message': f'Restored from backup: {backup_path.name}' - } + return {"success": True, "message": f"Restored from backup: {backup_path.name}"} except Exception as e: logger.error(f"[extractor] Restore from backup failed for {file_path}: {e}") - return { - 'success': False, - 'error': f'Restore failed: {e}' - } + return {"success": False, "error": f"Restore failed: {e}"} # ============================================================================= # FILE OPERATIONS # ============================================================================= + def _read_memory_file(file_path: Path) -> Dict[str, Any] | None: """Read memory JSON file using memory_files handler.""" return read_memory_file_data(file_path) @@ -147,7 +133,7 @@ def _write_memory_file(file_path: Path, data: Dict[str, Any]) -> None: def _count_file_lines(file_path: Path) -> int: """Count physical lines in file""" - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: return len(f.readlines()) @@ -155,6 +141,7 @@ def _count_file_lines(file_path: Path) -> int: # PATH HELPERS # ============================================================================= + def _derive_branch_and_type(file_path: Path) -> tuple[str, str]: """ Derive branch name and memory type from file path. @@ -166,11 +153,11 @@ def _derive_branch_and_type(file_path: Path) -> tuple[str, str]: Returns: Tuple of (branch_name, memory_type) e.g. ("DEVPULSE", "local") """ - if file_path.parent.name == '.trinity': + if file_path.parent.name == ".trinity": branch_name = file_path.parent.parent.name.upper() memory_type = file_path.stem # "local" or "observations" else: - parts = file_path.stem.split('.') + parts = file_path.stem.split(".") branch_name = parts[0] if len(parts) > 0 else "UNKNOWN" memory_type = parts[1] if len(parts) > 1 else "unknown" return branch_name, memory_type @@ -180,6 +167,7 @@ def _derive_branch_and_type(file_path: Path) -> tuple[str, str]: # STRUCTURE DETECTION # ============================================================================= + def _detect_growing_array(data: Dict[str, Any]) -> str | None: """ Detect which array field is growing in memory file @@ -196,7 +184,7 @@ def _detect_growing_array(data: Dict[str, Any]) -> str | None: Array field name (e.g., 'sessions'), or None if not found """ # Known array fields that grow over time - candidates = ['sessions', 'observations', 'recent_work', 'entries', 'items', 'records'] + candidates = ["sessions", "observations", "recent_work", "entries", "items", "records"] for field in candidates: if field in data and isinstance(data[field], list) and len(data[field]) > 0: @@ -209,12 +197,9 @@ def _detect_growing_array(data: Dict[str, Any]) -> str | None: # EXTRACTION CALCULATION # ============================================================================= + def _calculate_items_to_extract_by_lines( - data: Dict[str, Any], - array_field: str, - file_path: Path, - max_lines: int, - target_buffer: int = 100 + data: Dict[str, Any], array_field: str, file_path: Path, max_lines: int, target_buffer: int = 100 ) -> int: """ Calculate items to extract by SIMULATING line count (accurate) @@ -250,11 +235,11 @@ def _calculate_items_to_extract_by_lines( test_data[array_field] = data[array_field][:-items_to_remove] # Keep newest # Count lines in simulated result - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as tmp: + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as tmp: json.dump(test_data, tmp, indent=2, ensure_ascii=False) tmp_path = Path(tmp.name) - with open(tmp_path, 'r') as f: + with open(tmp_path, "r") as f: line_count = len(f.readlines()) tmp_path.unlink() @@ -271,6 +256,7 @@ def _calculate_items_to_extract_by_lines( # V2 EXTRACTION (ENTRY-COUNT BASED) # ============================================================================= + def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: """ Extract items from v2 format file (entry-count based). @@ -285,49 +271,45 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: Returns: Dict with extracted items and metadata """ - limits = data.get('document_metadata', {}).get('limits', {}) + limits = data.get("document_metadata", {}).get("limits", {}) old_lines = _count_file_lines(file_path) all_extracted = [] # Extract from sessions array (newest first, oldest at end) - max_sessions = limits.get('max_sessions') + max_sessions = limits.get("max_sessions") if max_sessions is not None: - sessions = data.get('sessions', []) + sessions = data.get("sessions", []) if isinstance(sessions, list) and len(sessions) > max_sessions: excess = len(sessions) - max_sessions extracted_sessions = sessions[-excess:] # oldest from end - data['sessions'] = sessions[:-excess] # keep newest + data["sessions"] = sessions[:-excess] # keep newest all_extracted.extend(extracted_sessions) # Extract from key_learnings dict (first keys are oldest in insertion order) - max_key_learnings = limits.get('max_key_learnings') + max_key_learnings = limits.get("max_key_learnings") if max_key_learnings is not None: - key_learnings = data.get('key_learnings', {}) + key_learnings = data.get("key_learnings", {}) if isinstance(key_learnings, dict) and len(key_learnings) > max_key_learnings: excess = len(key_learnings) - max_key_learnings keys_list = list(key_learnings.keys()) keys_to_extract = keys_list[:excess] # oldest (first inserted) for k in keys_to_extract: - all_extracted.append({'_type': 'key_learning', 'key': k, 'value': key_learnings[k]}) - del data['key_learnings'][k] + all_extracted.append({"_type": "key_learning", "key": k, "value": key_learnings[k]}) + del data["key_learnings"][k] # Extract from observations array (if v2 observations file) - max_observations = limits.get('max_observations') + max_observations = limits.get("max_observations") if max_observations is not None: - observations = data.get('observations', []) + observations = data.get("observations", []) if isinstance(observations, list) and len(observations) > max_observations: excess = len(observations) - max_observations extracted_obs = observations[-excess:] - data['observations'] = observations[:-excess] + data["observations"] = observations[:-excess] all_extracted.extend(extracted_obs) if not all_extracted: - return { - 'success': True, - 'skipped': True, - 'message': 'No entries exceed v2 limits' - } + return {"success": True, "skipped": True, "message": "No entries exceed v2 limits"} # Update metadata _update_metadata_after_extraction(data) @@ -338,26 +320,23 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: new_lines = _count_file_lines(file_path) except Exception as e: logger.error(f"[extractor] Failed to write file after v2 extraction: {e}") - return { - 'success': False, - 'error': f"Failed to write file: {e}" - } + return {"success": False, "error": f"Failed to write file: {e}"} # Derive branch and type from path # .trinity/local.json → branch = parent.parent.name, type = stem branch_name, memory_type = _derive_branch_and_type(file_path) return { - 'success': True, - 'file': str(file_path), - 'branch': branch_name, - 'type': memory_type, - 'array_field': 'v2_mixed', - 'extracted': all_extracted, - 'extracted_count': len(all_extracted), - 'remaining_count': 0, - 'old_lines': old_lines, - 'new_lines': new_lines + "success": True, + "file": str(file_path), + "branch": branch_name, + "type": memory_type, + "array_field": "v2_mixed", + "extracted": all_extracted, + "extracted_count": len(all_extracted), + "remaining_count": 0, + "old_lines": old_lines, + "new_lines": new_lines, } @@ -365,10 +344,8 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: # EXTRACTION OPERATIONS # ============================================================================= -def extract_items( - file_path: Path, - percentage: int | None = None -) -> Dict[str, Any]: + +def extract_items(file_path: Path, percentage: int | None = None) -> Dict[str, Any]: """ Extract items from memory file (WITH BACKUP SAFETY) @@ -383,51 +360,35 @@ def extract_items( Dict with extracted items and metadata """ if not file_path.exists(): - return { - 'success': False, - 'error': f"File not found: {file_path}" - } + return {"success": False, "error": f"File not found: {file_path}"} # Read file try: data = _read_memory_file(file_path) if data is None: - return { - 'success': False, - 'error': f"Failed to parse memory file: {file_path.name}" - } + return {"success": False, "error": f"Failed to parse memory file: {file_path.name}"} current_lines = _count_file_lines(file_path) except Exception as e: logger.warning(f"[extractor] Failed to read file {file_path}: {e}") - return { - 'success': False, - 'error': f"Failed to read file: {e}" - } + return {"success": False, "error": f"Failed to read file: {e}"} # v2 schema: delegate to entry-count based extraction - schema_version = data.get('document_metadata', {}).get('schema_version', '1.0.0') - if schema_version.startswith('2'): + schema_version = data.get("document_metadata", {}).get("schema_version", "1.0.0") + if schema_version.startswith("2"): return _extract_items_v2(file_path, data) # v1: line-count based extraction # Detect structure array_field = _detect_growing_array(data) if not array_field: - return { - 'success': False, - 'error': f"No growing array found in {file_path.name}" - } + return {"success": False, "error": f"No growing array found in {file_path.name}"} # Get metadata - max_lines = data.get('document_metadata', {}).get('limits', {}).get('max_lines', 600) + max_lines = data.get("document_metadata", {}).get("limits", {}).get("max_lines", 600) # Check if under limit if current_lines <= max_lines: - return { - 'success': True, - 'skipped': True, - 'message': f"File under limit ({current_lines}/{max_lines} lines)" - } + return {"success": True, "skipped": True, "message": f"File under limit ({current_lines}/{max_lines} lines)"} # Calculate extraction amount (simulate actual line reduction) total_items = len(data[array_field]) @@ -457,27 +418,26 @@ def extract_items( new_line_count = _count_file_lines(file_path) except Exception as e: logger.error(f"[extractor] Failed to write file after v1 extraction: {e}") - return { - 'success': False, - 'error': f"Failed to write file: {e}" - } + return {"success": False, "error": f"Failed to write file: {e}"} # Derive branch and type from path branch_name, memory_type = _derive_branch_and_type(file_path) - json_handler.log_operation("extract_items", {"branch": branch_name, "type": memory_type, "extracted_count": items_to_extract}) + json_handler.log_operation( + "extract_items", {"branch": branch_name, "type": memory_type, "extracted_count": items_to_extract} + ) return { - 'success': True, - 'file': str(file_path), - 'branch': branch_name, - 'type': memory_type, - 'array_field': array_field, - 'extracted': extracted, - 'extracted_count': items_to_extract, - 'remaining_count': len(remaining), - 'old_lines': current_lines, - 'new_lines': new_line_count + "success": True, + "file": str(file_path), + "branch": branch_name, + "type": memory_type, + "array_field": array_field, + "extracted": extracted, + "extracted_count": items_to_extract, + "remaining_count": len(remaining), + "old_lines": current_lines, + "new_lines": new_line_count, } @@ -485,6 +445,7 @@ def extract_items( # METADATA OPERATIONS # ============================================================================= + def _update_metadata_after_extraction(data: Dict[str, Any]) -> None: """ Update document_metadata after extraction. @@ -496,26 +457,24 @@ def _update_metadata_after_extraction(data: Dict[str, Any]) -> None: data: JSON data dict (modified in place) """ # Ensure metadata structure - if 'document_metadata' not in data: - data['document_metadata'] = {} + if "document_metadata" not in data: + data["document_metadata"] = {} - metadata = data['document_metadata'] + metadata = data["document_metadata"] # Update status - if 'status' not in metadata: - metadata['status'] = {} + if "status" not in metadata: + metadata["status"] = {} - metadata['status']['last_health_check'] = datetime.now().strftime("%Y-%m-%d") + metadata["status"]["last_health_check"] = datetime.now().strftime("%Y-%m-%d") # ============================================================================= # VECTORIZATION PREPARATION # ============================================================================= -def extract_with_metadata( - file_path: Path, - percentage: int | None = None -) -> Dict[str, Any]: + +def extract_with_metadata(file_path: Path, percentage: int | None = None) -> Dict[str, Any]: """ Extract items with enriched metadata for vectorization @@ -531,14 +490,14 @@ def extract_with_metadata( # Do standard extraction result = extract_items(file_path, percentage) - if not result['success']: + if not result["success"]: return result # Enrich extracted items with metadata - extracted = result.get('extracted', []) - branch = result.get('branch') - memory_type = result.get('type') - array_field = result.get('array_field') + extracted = result.get("extracted", []) + branch = result.get("branch") + memory_type = result.get("type") + array_field = result.get("array_field") extraction_timestamp = datetime.now().isoformat() @@ -546,25 +505,25 @@ def extract_with_metadata( for item in extracted: enriched_item = { **item, # Preserve original item data - '_metadata': { - 'branch': branch, - 'type': memory_type, - 'array_field': array_field, - 'extracted_at': extraction_timestamp, - 'source_file': file_path.name - } + "_metadata": { + "branch": branch, + "type": memory_type, + "array_field": array_field, + "extracted_at": extraction_timestamp, + "source_file": file_path.name, + }, } enriched.append(enriched_item) # Return enriched version return { - 'success': True, - 'file': str(file_path), - 'branch': branch, - 'type': memory_type, - 'array_field': array_field, - 'entries': enriched, - 'count': len(enriched), - 'old_lines': result.get('old_lines'), - 'new_lines': result.get('new_lines') + "success": True, + "file": str(file_path), + "branch": branch, + "type": memory_type, + "array_field": array_field, + "entries": enriched, + "count": len(enriched), + "old_lines": result.get("old_lines"), + "new_lines": result.get("new_lines"), } diff --git a/src/aipass/memory/apps/handlers/rollover/orchestrator.py b/src/aipass/memory/apps/handlers/rollover/orchestrator.py index db04d971..88cffa13 100644 --- a/src/aipass/memory/apps/handlers/rollover/orchestrator.py +++ b/src/aipass/memory/apps/handlers/rollover/orchestrator.py @@ -44,6 +44,7 @@ EMBED_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "vector" / "embed_subprocess.py" _MEMORY_ROOT = Path(__file__).resolve().parents[3] _MEMORY_VENV_PYTHON = _MEMORY_ROOT / ".venv" / "bin" / "python" + def _get_memory_python() -> str: """Get the Python executable for memory ML operations.""" env_override = os.environ.get("AIPASS_MEMORY_PYTHON") @@ -53,6 +54,7 @@ def _get_memory_python() -> str: return str(_MEMORY_VENV_PYTHON) return sys.executable + MEMORY_PYTHON = _get_memory_python() @@ -60,6 +62,7 @@ MEMORY_PYTHON = _get_memory_python() # REPO ROOT DISCOVERY # ============================================================================= + def _find_repo_root() -> Path: """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -76,8 +79,10 @@ _REPO_ROOT = _find_repo_root() # VECTOR STORAGE (SUBPROCESS) # ============================================================================= -def store_vectors_subprocess(branch: str, memory_type: str, embeddings: list, - documents: list, metadatas: list, db_path: str | Path | None = None) -> dict: + +def store_vectors_subprocess( + branch: str, memory_type: str, embeddings: list, documents: list, metadatas: list, db_path: str | Path | None = None +) -> dict: """ Store vectors via subprocess. @@ -95,19 +100,16 @@ def store_vectors_subprocess(branch: str, memory_type: str, embeddings: list, Dict with success status and storage details """ # Convert numpy arrays to lists for JSON serialization - embeddings_serializable = [ - emb.tolist() if hasattr(emb, 'tolist') else emb - for emb in embeddings - ] + embeddings_serializable = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings] input_data = { - 'operation': 'store_vectors', - 'branch': branch, - 'memory_type': memory_type, - 'embeddings': embeddings_serializable, - 'documents': documents, - 'metadatas': metadatas, - 'db_path': str(db_path) if db_path else None + "operation": "store_vectors", + "branch": branch, + "memory_type": memory_type, + "embeddings": embeddings_serializable, + "documents": documents, + "metadatas": metadatas, + "db_path": str(db_path) if db_path else None, } try: @@ -116,28 +118,29 @@ def store_vectors_subprocess(branch: str, memory_type: str, embeddings: list, input=json.dumps(input_data), capture_output=True, text=True, - timeout=60 + timeout=60, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Subprocess failed'} + return {"success": False, "error": result.stderr or "Subprocess failed"} return json.loads(result.stdout) except subprocess.TimeoutExpired: logger.warning("[orchestrator] Vector storage operation timed out") - return {'success': False, 'error': 'Storage operation timed out'} + return {"success": False, "error": "Storage operation timed out"} except json.JSONDecodeError as e: logger.warning(f"[orchestrator] Invalid JSON from storage subprocess: {e}") - return {'success': False, 'error': f'Invalid JSON response: {e}'} + return {"success": False, "error": f"Invalid JSON response: {e}"} except Exception as e: logger.warning(f"[orchestrator] Vector storage subprocess error: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # EMBEDDING VIA SUBPROCESS # ============================================================================= + def encode_batch_subprocess(texts: list) -> dict: """ Encode texts via subprocess using memory venv's sentence-transformers. @@ -148,7 +151,7 @@ def encode_batch_subprocess(texts: list) -> dict: Returns: Dict with success, embeddings, count, dimension """ - input_data = json.dumps({'texts': texts}) + input_data = json.dumps({"texts": texts}) try: result = subprocess.run( @@ -156,28 +159,29 @@ def encode_batch_subprocess(texts: list) -> dict: input=input_data, capture_output=True, text=True, - timeout=120 + timeout=120, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Embedding subprocess failed'} + return {"success": False, "error": result.stderr or "Embedding subprocess failed"} return json.loads(result.stdout) except subprocess.TimeoutExpired: logger.warning("[orchestrator] Embedding subprocess timed out") - return {'success': False, 'error': 'Embedding timed out'} + return {"success": False, "error": "Embedding timed out"} except json.JSONDecodeError as e: logger.warning(f"[orchestrator] Invalid JSON from embedding subprocess: {e}") - return {'success': False, 'error': f'Invalid JSON from embedder: {e}'} + return {"success": False, "error": f"Invalid JSON from embedder: {e}"} except Exception as e: logger.warning(f"[orchestrator] Embedding subprocess error: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # PATH HELPERS # ============================================================================= + def get_branch_local_chroma_path(branch_name: str) -> Path | None: """ Get local .chroma path for branch @@ -194,10 +198,10 @@ def get_branch_local_chroma_path(branch_name: str) -> Path | None: registry = detector._read_registry() for branch in registry: - if branch.get('name', '').upper() == branch_name.upper(): - branch_path = Path(branch.get('path', '')) + if branch.get("name", "").upper() == branch_name.upper(): + branch_path = Path(branch.get("path", "")) if branch_path.exists(): - chroma_path = branch_path / '.chroma' + chroma_path = branch_path / ".chroma" # Auto-create .chroma directory if missing if not chroma_path.exists(): chroma_path.mkdir(parents=True, exist_ok=True) @@ -212,6 +216,7 @@ def get_branch_local_chroma_path(branch_name: str) -> Path | None: # TEXT EXTRACTION HELPERS # ============================================================================= + def extract_text_from_memories(memories: List[Dict]) -> List[str]: """ Extract text content from memory items for vectorization @@ -231,21 +236,21 @@ def extract_text_from_memories(memories: List[Dict]) -> List[str]: for memory in memories: # Try common text fields - if 'activities' in memory and isinstance(memory['activities'], list): + if "activities" in memory and isinstance(memory["activities"], list): # Sessions type (v1) - join activities - text = '\n'.join(str(a) for a in memory['activities']) - elif 'summary' in memory: + text = "\n".join(str(a) for a in memory["activities"]) + elif "summary" in memory: # Sessions type (v2) - summary field - text = str(memory['summary']) - elif '_type' in memory and memory['_type'] == 'key_learning': + text = str(memory["summary"]) + elif "_type" in memory and memory["_type"] == "key_learning": # Key learnings (v2) - key:value pair text = f"{memory.get('key', '')}: {memory.get('value', '')}" - elif 'content' in memory: - text = str(memory['content']) - elif 'text' in memory: - text = str(memory['text']) - elif 'message' in memory: - text = str(memory['message']) + elif "content" in memory: + text = str(memory["content"]) + elif "text" in memory: + text = str(memory["text"]) + elif "message" in memory: + text = str(memory["message"]) else: # Fallback - convert to string representation text = str(memory) @@ -259,6 +264,7 @@ def extract_text_from_memories(memories: List[Dict]) -> List[str]: # ROLLOVER EXECUTION # ============================================================================= + def execute_rollover() -> Dict[str, Any]: """ Execute rollover workflow for all triggered branches. @@ -279,26 +285,26 @@ def execute_rollover() -> Dict[str, Any]: # Step 1: Detect triggers triggers_result = detector.check_all_branches() - if not triggers_result['success']: - error = triggers_result.get('error', 'Unknown error') + if not triggers_result["success"]: + error = triggers_result.get("error", "Unknown error") logger.error(f"[rollover] Failed to check branches: {error}") return { - 'success': False, - 'error': f'Failed to check for rollover triggers: {error}', - 'triggers_count': 0, - 'success_count': 0, - 'failed': [], + "success": False, + "error": f"Failed to check for rollover triggers: {error}", + "triggers_count": 0, + "success_count": 0, + "failed": [], } - triggers = triggers_result.get('triggers', []) + triggers = triggers_result.get("triggers", []) if not triggers: logger.info("[rollover] No rollover triggers detected") return { - 'success': True, - 'triggers_count': 0, - 'success_count': 0, - 'failed': [], - 'results': [], + "success": True, + "triggers_count": 0, + "success_count": 0, + "failed": [], + "results": [], } logger.info(f"[rollover] Found {len(triggers)} files ready for rollover") @@ -312,10 +318,10 @@ def execute_rollover() -> Dict[str, Any]: # Step 1: CREATE BACKUP (safety net) backup_result = extractor.create_rollover_backup(trigger.file_path) - if not backup_result['success']: - error_msg = backup_result.get('error', 'Backup failed') + if not backup_result["success"]: + error_msg = backup_result.get("error", "Backup failed") logger.error(f"[rollover] Backup failed for {trigger}: {error_msg}") - failed.append({'trigger': str(trigger), 'stage': 'backup', 'error': error_msg}) + failed.append({"trigger": str(trigger), "stage": "backup", "error": error_msg}) continue # Don't proceed without backup logger.info(f"[rollover] {backup_result.get('message')}") @@ -323,27 +329,27 @@ def execute_rollover() -> Dict[str, Any]: # Step 2: Extract memories (auto-calculates percentage) extract_result = extractor.extract_with_metadata(trigger.file_path) - if not extract_result['success']: - error_msg = extract_result.get('error', 'Unknown error') + if not extract_result["success"]: + error_msg = extract_result.get("error", "Unknown error") logger.error(f"[rollover] Extraction failed for {trigger}: {error_msg}") # RESTORE from backup restore_result = extractor.restore_from_backup(trigger.file_path) - if restore_result['success']: + if restore_result["success"]: logger.info("[rollover] Restored from backup after extraction failure") - failed.append({'trigger': str(trigger), 'stage': 'extraction', 'error': error_msg}) + failed.append({"trigger": str(trigger), "stage": "extraction", "error": error_msg}) continue - memories = extract_result.get('entries', []) - branch = extract_result.get('branch', '') or trigger.branch - memory_type = extract_result.get('type', 'unknown') or trigger.memory_type - old_lines = extract_result.get('old_lines', 0) - new_lines = extract_result.get('new_lines', 0) + memories = extract_result.get("entries", []) + branch = extract_result.get("branch", "") or trigger.branch + memory_type = extract_result.get("type", "unknown") or trigger.memory_type + old_lines = extract_result.get("old_lines", 0) + new_lines = extract_result.get("new_lines", 0) if not branch: logger.error(f"[rollover] No branch found in extraction result for {trigger}") - failed.append({'trigger': str(trigger), 'stage': 'extraction', 'error': 'No branch in result'}) + failed.append({"trigger": str(trigger), "stage": "extraction", "error": "No branch in result"}) continue logger.info(f"[rollover] Extracted {len(memories)} items from {trigger} ({old_lines} -> {new_lines} lines)") @@ -354,22 +360,22 @@ def execute_rollover() -> Dict[str, Any]: # Step 3: Generate embeddings (via subprocess in memory venv) embed_result = encode_batch_subprocess(texts) - if not embed_result['success']: - error_msg = embed_result.get('error', 'Unknown error') + if not embed_result["success"]: + error_msg = embed_result.get("error", "Unknown error") logger.error(f"[rollover] Embedding failed for {trigger}: {error_msg}") # RESTORE from backup restore_result = extractor.restore_from_backup(trigger.file_path) - if restore_result['success']: + if restore_result["success"]: logger.info("[rollover] Restored from backup after embedding failure") - failed.append({'trigger': str(trigger), 'stage': 'embedding', 'error': error_msg}) + failed.append({"trigger": str(trigger), "stage": "embedding", "error": error_msg}) continue - embeddings = embed_result.get('embeddings', []) + embeddings = embed_result.get("embeddings", []) if not embeddings: logger.error(f"[rollover] No embeddings generated for {trigger}") - failed.append({'trigger': str(trigger), 'stage': 'embedding', 'error': 'No embeddings in result'}) + failed.append({"trigger": str(trigger), "stage": "embedding", "error": "No embeddings in result"}) continue logger.info(f"[rollover] Generated {len(embeddings)} embeddings for {trigger}") @@ -377,8 +383,8 @@ def execute_rollover() -> Dict[str, Any]: # Step 4: Prepare metadata for vectorization metadatas = [] for memory in memories: - metadata = memory.get('_metadata', {}) - metadata['timestamp'] = memory.get('timestamp', '') + metadata = memory.get("_metadata", {}) + metadata["timestamp"] = memory.get("timestamp", "") metadatas.append(metadata) # Step 5: Store in LOCAL branch Chroma (via subprocess) @@ -396,10 +402,10 @@ def execute_rollover() -> Dict[str, Any]: embeddings=embeddings_list, documents=texts, metadatas=metadatas, - db_path=str(local_chroma_path) + db_path=str(local_chroma_path), ) - if not local_store_result['success']: + if not local_store_result["success"]: logger.warning(f"[rollover] Local storage failed for {branch}: {local_store_result.get('error')}") # Continue anyway - global storage is primary else: @@ -411,50 +417,56 @@ def execute_rollover() -> Dict[str, Any]: memory_type=memory_type_str, embeddings=embeddings_list, documents=texts, - metadatas=metadatas + metadatas=metadatas, # db_path=None means global ) - if not global_store_result['success']: - error_msg = global_store_result.get('error', 'Unknown error') + if not global_store_result["success"]: + error_msg = global_store_result.get("error", "Unknown error") logger.error(f"[rollover] Global storage failed for {trigger}: {error_msg}") # RESTORE from backup (CRITICAL - file was modified but storage failed) restore_result = extractor.restore_from_backup(trigger.file_path) - if restore_result['success']: + if restore_result["success"]: logger.info("[rollover] Restored from backup after storage failure") else: logger.error(f"[rollover] CRITICAL: Failed to restore from backup: {restore_result.get('error')}") - failed.append({'trigger': str(trigger), 'stage': 'global_storage', 'error': error_msg}) + failed.append({"trigger": str(trigger), "stage": "global_storage", "error": error_msg}) continue logger.info(f"[rollover] Stored {len(embeddings)} vectors in global Chroma for {branch}") # Step 7: Update line count metadata update_result = line_counter.update_line_count(trigger.file_path) - if update_result['success']: + if update_result["success"]: logger.info(f"[rollover] Updated line count metadata for {trigger.file_path.name}") else: - logger.warning(f"[rollover] Failed to update line count for {trigger.file_path.name}: {update_result.get('error')}") + logger.warning( + f"[rollover] Failed to update line count for {trigger.file_path.name}: {update_result.get('error')}" + ) # Success! success_count += 1 - global_collection = global_store_result.get('collection') - global_total = global_store_result.get('total_vectors') + global_collection = global_store_result.get("collection") + global_total = global_store_result.get("total_vectors") - local_ok = local_store_result and local_store_result['success'] - results.append({ - 'trigger': str(trigger), - 'memories_count': len(memories), - 'old_lines': old_lines, - 'new_lines': new_lines, - 'global_collection': global_collection, - 'global_total': global_total, - 'local_stored': local_ok, - }) + local_ok = local_store_result and local_store_result["success"] + results.append( + { + "trigger": str(trigger), + "memories_count": len(memories), + "old_lines": old_lines, + "new_lines": new_lines, + "global_collection": global_collection, + "global_total": global_total, + "local_stored": local_ok, + } + ) - logger.info(f"[rollover] Successfully rolled over {trigger}: {len(memories)} items, {old_lines} -> {new_lines} lines") + logger.info( + f"[rollover] Successfully rolled over {trigger}: {len(memories)} items, {old_lines} -> {new_lines} lines" + ) # Summary logging if success_count > 0: @@ -466,14 +478,13 @@ def execute_rollover() -> Dict[str, Any]: # Post-rollover processing chain # ========================================================================= if success_count > 0: - # Post-rollover: fire event for trigger system try: from aipass.trigger.apps.modules.core import Trigger - Trigger.fire('rollover_complete', - triggers_count=len(triggers), - success_count=success_count, - failed_count=len(failed)) + + Trigger.fire( + "rollover_complete", triggers_count=len(triggers), success_count=success_count, failed_count=len(failed) + ) logger.info("[rollover] Fired rollover_complete event") except Exception as e: logger.warning(f"[orchestrator] Failed to fire rollover_complete event: {e}") @@ -481,8 +492,9 @@ def execute_rollover() -> Dict[str, Any]: # Post-rollover: update central stats try: from aipass.memory.apps.handlers.central_writer import update_central + central_result = update_central() - if central_result and central_result.get('success'): + if central_result and central_result.get("success"): logger.info("[rollover] Central stats updated") else: logger.warning(f"[rollover] Central update returned: {central_result}") @@ -492,6 +504,7 @@ def execute_rollover() -> Dict[str, Any]: # Post-rollover: push dashboard try: from aipass.memory.apps.handlers.dashboard_push import push_memory_bank_dashboard + dash_ok = push_memory_bank_dashboard() if dash_ok: logger.info("[rollover] Dashboard pushed") @@ -503,25 +516,29 @@ def execute_rollover() -> Dict[str, Any]: # Post-rollover: process memory pool if files waiting try: from aipass.memory.apps.handlers.intake.pool_processor import process_memory_pool + pool_result = process_memory_pool() - if pool_result and pool_result.get('files_processed', 0) > 0: + if pool_result and pool_result.get("files_processed", 0) > 0: logger.info(f"[rollover] Memory pool: {pool_result['files_processed']} files processed") except Exception as e: logger.info(f"[rollover] Memory pool check: {e}") - json_handler.log_operation("rollover_execute", { - "triggers": len(triggers), - "success_count": success_count, - "failed_count": len(failed), - "success": success_count > 0 or len(triggers) == 0, - }) + json_handler.log_operation( + "rollover_execute", + { + "triggers": len(triggers), + "success_count": success_count, + "failed_count": len(failed), + "success": success_count > 0 or len(triggers) == 0, + }, + ) return { - 'success': success_count > 0 or len(triggers) == 0, - 'triggers_count': len(triggers), - 'success_count': success_count, - 'failed': failed, - 'results': results, + "success": success_count > 0 or len(triggers) == 0, + "triggers_count": len(triggers), + "success_count": success_count, + "failed": failed, + "results": results, } @@ -529,6 +546,7 @@ def execute_rollover() -> Dict[str, Any]: # LINE COUNT SYNC # ============================================================================= + def sync_line_counts() -> Dict[str, Any]: """ Update line count metadata for all branch memory files. @@ -541,7 +559,7 @@ def sync_line_counts() -> Dict[str, Any]: """ result = line_counter.update_all_memory_files() - if result['success']: + if result["success"]: logger.info(f"[rollover] Synced line counts: {result['updated']} updated, {result['failed']} failed") else: logger.error("[rollover] Failed to sync line counts") diff --git a/src/aipass/memory/apps/handlers/schema/normalize.py b/src/aipass/memory/apps/handlers/schema/normalize.py index 80b682dc..897c0d9d 100644 --- a/src/aipass/memory/apps/handlers/schema/normalize.py +++ b/src/aipass/memory/apps/handlers/schema/normalize.py @@ -53,95 +53,92 @@ def normalize_memory_file(file_path: Path, dry_run: bool = False) -> Dict[str, A Dict with success status and changes made """ if not file_path.exists(): - return {'success': False, 'error': f"File not found: {file_path}"} + return {"success": False, "error": f"File not found: {file_path}"} try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) except Exception as e: logger.warning(f"[normalize] Failed to read {file_path}: {e}") - return {'success': False, 'error': f"Failed to read: {e}"} + return {"success": False, "error": f"Failed to read: {e}"} changes = [] # Ensure document_metadata exists - if 'document_metadata' not in data: - data['document_metadata'] = {} + if "document_metadata" not in data: + data["document_metadata"] = {} changes.append("Created document_metadata") - metadata = data['document_metadata'] + metadata = data["document_metadata"] # 1. Move root 'limits' into document_metadata.limits - if 'limits' in data and 'limits' not in metadata: - metadata['limits'] = data.pop('limits') + if "limits" in data and "limits" not in metadata: + metadata["limits"] = data.pop("limits") changes.append("Moved root 'limits' into document_metadata") - elif 'limits' in data and 'limits' in metadata: + elif "limits" in data and "limits" in metadata: # Both exist - merge, preferring document_metadata values - root_limits = data.pop('limits') + root_limits = data.pop("limits") for key, val in root_limits.items(): - if key not in metadata['limits']: - metadata['limits'][key] = val + if key not in metadata["limits"]: + metadata["limits"][key] = val changes.append("Merged root 'limits' into document_metadata.limits") # 2. Remove root 'status' (redundant) - if 'status' in data: - root_status = data.pop('status') + if "status" in data: + root_status = data.pop("status") # If document_metadata.status doesn't have current_lines, copy it - if 'status' not in metadata: - metadata['status'] = {} - if 'current_lines' not in metadata['status'] and 'current_lines' in root_status: - metadata['status']['current_lines'] = root_status['current_lines'] + if "status" not in metadata: + metadata["status"] = {} + if "current_lines" not in metadata["status"] and "current_lines" in root_status: + metadata["status"]["current_lines"] = root_status["current_lines"] changes.append("Removed redundant root 'status'") # 3. Remove auto_compress_at from document_metadata.status (redundant with max_lines) - if 'status' in metadata and 'auto_compress_at' in metadata['status']: - del metadata['status']['auto_compress_at'] + if "status" in metadata and "auto_compress_at" in metadata["status"]: + del metadata["status"]["auto_compress_at"] changes.append("Removed redundant 'auto_compress_at'") # 4. Remove unused limits fields (max_word_count, max_token_count - no code uses these) # Preserve v2 fields: max_sessions, max_key_learnings, session_summary_max_chars, learning_value_max_chars, # max_observations, max_lines, note - if 'limits' in metadata: - for unused_field in ['max_word_count', 'max_token_count']: - if unused_field in metadata['limits']: - del metadata['limits'][unused_field] + if "limits" in metadata: + for unused_field in ["max_word_count", "max_token_count"]: + if unused_field in metadata["limits"]: + del metadata["limits"][unused_field] changes.append(f"Removed unused '{unused_field}'") # 4. Ensure status has required fields - if 'status' not in metadata: - metadata['status'] = {} + if "status" not in metadata: + metadata["status"] = {} - if 'current_lines' not in metadata['status']: + if "current_lines" not in metadata["status"]: # Count actual lines try: - with open(file_path, 'r', encoding='utf-8') as f: - metadata['status']['current_lines'] = len(f.readlines()) + with open(file_path, "r", encoding="utf-8") as f: + metadata["status"]["current_lines"] = len(f.readlines()) changes.append("Added current_lines count") except Exception as e: logger.warning(f"[normalize] Failed to count lines in {file_path}: {e}") - if 'last_health_check' not in metadata['status']: - metadata['status']['last_health_check'] = datetime.now().strftime("%Y-%m-%d") + if "last_health_check" not in metadata["status"]: + metadata["status"]["last_health_check"] = datetime.now().strftime("%Y-%m-%d") changes.append("Added last_health_check") # Write if changes made and not dry run if changes and not dry_run: try: - with open(file_path, 'w', encoding='utf-8') as f: + with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') + f.write("\n") except Exception as e: logger.error(f"[normalize] Failed to write {file_path}: {e}") - return {'success': False, 'error': f"Failed to write: {e}"} + return {"success": False, "error": f"Failed to write: {e}"} - json_handler.log_operation("normalize_memory_file", {"file": file_path.name, "changes": len(changes), "success": True}) + json_handler.log_operation( + "normalize_memory_file", {"file": file_path.name, "changes": len(changes), "success": True} + ) - return { - 'success': True, - 'file': str(file_path), - 'changes': changes, - 'dry_run': dry_run - } + return {"success": True, "file": str(file_path), "changes": changes, "dry_run": dry_run} def normalize_all_memory_files(dry_run: bool = False) -> Dict[str, Any]: @@ -158,48 +155,39 @@ def normalize_all_memory_files(dry_run: bool = False) -> Dict[str, Any]: registry_path = _find_repo_root() / "AIPASS_REGISTRY.json" if not registry_path.exists(): - return {'success': False, 'error': "AIPASS_REGISTRY.json not found"} + return {"success": False, "error": "AIPASS_REGISTRY.json not found"} try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) - branches = registry.get('branches', []) + branches = registry.get("branches", []) except Exception as e: logger.warning(f"[normalize] Failed to read registry: {e}") - return {'success': False, 'error': f"Failed to read registry: {e}"} + return {"success": False, "error": f"Failed to read registry: {e}"} - results = { - 'success': True, - 'files_checked': 0, - 'files_modified': 0, - 'dry_run': dry_run, - 'details': [] - } + results = {"success": True, "files_checked": 0, "files_modified": 0, "dry_run": dry_run, "details": []} for branch in branches: - branch_path = Path(branch.get('path', '')) - branch_name = branch.get('name', '').upper() + branch_path = Path(branch.get("path", "")) + branch_name = branch.get("name", "").upper() if not branch_path.exists(): continue # Check both file types - for memory_type in ['local', 'observations']: + for memory_type in ["local", "observations"]: file_name = f"{branch_name}.{memory_type}.json" file_path = branch_path / file_name if not file_path.exists(): continue - results['files_checked'] += 1 + results["files_checked"] += 1 result = normalize_memory_file(file_path, dry_run=dry_run) - if result['success'] and result.get('changes'): - results['files_modified'] += 1 - results['details'].append({ - 'file': file_name, - 'changes': result['changes'] - }) + if result["success"] and result.get("changes"): + results["files_modified"] += 1 + results["details"].append({"file": file_name, "changes": result["changes"]}) return results @@ -209,8 +197,8 @@ if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Normalize memory file schema") - parser.add_argument('--dry-run', action='store_true', help="Report changes without writing") - parser.add_argument('--file', type=str, help="Normalize single file") + parser.add_argument("--dry-run", action="store_true", help="Report changes without writing") + parser.add_argument("--file", type=str, help="Normalize single file") args = parser.parse_args() if args.file: @@ -220,9 +208,9 @@ if __name__ == "__main__": result = normalize_all_memory_files(dry_run=args.dry_run) print(f"Files checked: {result['files_checked']}") print(f"Files modified: {result['files_modified']}") - if result['details']: + if result["details"]: print("\nChanges:") - for detail in result['details']: + for detail in result["details"]: print(f" {detail['file']}:") - for change in detail['changes']: + for change in detail["changes"]: print(f" - {change}") diff --git a/src/aipass/memory/apps/handlers/search/query_executor.py b/src/aipass/memory/apps/handlers/search/query_executor.py index d23496a9..d220b508 100644 --- a/src/aipass/memory/apps/handlers/search/query_executor.py +++ b/src/aipass/memory/apps/handlers/search/query_executor.py @@ -58,6 +58,7 @@ MIN_SIMILARITY_THRESHOLD = 0.40 # 40% minimum relevance # SUBPROCESS EMBEDDING # ============================================================================= + def encode_query_subprocess(query: str) -> dict: """ Encode query text via subprocess using memory venv's sentence-transformers. @@ -68,7 +69,7 @@ def encode_query_subprocess(query: str) -> dict: Returns: Dict with success, embedding (list of floats), dimension """ - input_data = json.dumps({'texts': [query]}) + input_data = json.dumps({"texts": [query]}) try: result = subprocess.run( @@ -76,46 +77,43 @@ def encode_query_subprocess(query: str) -> dict: input=input_data, capture_output=True, text=True, - timeout=120 + timeout=120, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Embedding subprocess failed'} + return {"success": False, "error": result.stderr or "Embedding subprocess failed"} data = json.loads(result.stdout) - if not data.get('success'): + if not data.get("success"): return data - embeddings = data.get('embeddings', []) + embeddings = data.get("embeddings", []) if not embeddings: - return {'success': False, 'error': 'No embedding generated'} + return {"success": False, "error": "No embedding generated"} - return { - 'success': True, - 'embedding': embeddings[0], - 'dimension': data.get('dimension', 384) - } + return {"success": True, "embedding": embeddings[0], "dimension": data.get("dimension", 384)} except subprocess.TimeoutExpired: logger.warning("[query_executor] Embedding subprocess timed out") - return {'success': False, 'error': 'Embedding timed out'} + return {"success": False, "error": "Embedding timed out"} except json.JSONDecodeError as e: logger.warning(f"[query_executor] Invalid JSON from embedder: {e}") - return {'success': False, 'error': f'Invalid JSON from embedder: {e}'} + return {"success": False, "error": f"Invalid JSON from embedder: {e}"} except Exception as e: logger.error(f"[query_executor] Embedding subprocess failed: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # SUBPROCESS VECTOR SEARCH # ============================================================================= + def search_vectors_subprocess( query_embedding: list, branch: str | None = None, memory_type: str | None = None, n_results: int = 5, - db_path: str | Path | None = None + db_path: str | Path | None = None, ) -> dict: """ Search vectors via subprocess. @@ -131,12 +129,12 @@ def search_vectors_subprocess( Dict with success status and search results """ input_data = { - 'operation': 'search_vectors', - 'query_embedding': query_embedding, - 'branch': branch, - 'memory_type': memory_type, - 'n_results': n_results, - 'db_path': str(db_path) if db_path else None + "operation": "search_vectors", + "query_embedding": query_embedding, + "branch": branch, + "memory_type": memory_type, + "n_results": n_results, + "db_path": str(db_path) if db_path else None, } try: @@ -145,28 +143,29 @@ def search_vectors_subprocess( input=json.dumps(input_data), capture_output=True, text=True, - timeout=60 + timeout=60, ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Subprocess failed'} + return {"success": False, "error": result.stderr or "Subprocess failed"} return json.loads(result.stdout) except subprocess.TimeoutExpired: logger.warning("[query_executor] Search subprocess timed out") - return {'success': False, 'error': 'Search operation timed out'} + return {"success": False, "error": "Search operation timed out"} except json.JSONDecodeError as e: logger.warning(f"[query_executor] Invalid JSON from search subprocess: {e}") - return {'success': False, 'error': f'Invalid JSON response: {e}'} + return {"success": False, "error": f"Invalid JSON response: {e}"} except Exception as e: logger.error(f"[query_executor] Search subprocess failed: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================= # RESULT PROCESSING # ============================================================================= + def _calculate_similarity(distance: float) -> float: """ Calculate similarity from ChromaDB cosine distance. @@ -195,8 +194,8 @@ def _filter_results(results: list, n_results: int) -> list: """ filtered = [] for result in results[:n_results]: - document = result.get('document', '') - distance = result.get('distance', 0) + document = result.get("document", "") + distance = result.get("distance", 0) similarity = _calculate_similarity(distance) @@ -205,7 +204,7 @@ def _filter_results(results: list, n_results: int) -> list: if similarity < MIN_SIMILARITY_THRESHOLD: continue - result['similarity'] = similarity + result["similarity"] = similarity filtered.append(result) return filtered @@ -215,11 +214,9 @@ def _filter_results(results: list, n_results: int) -> list: # PUBLIC API # ============================================================================= + def execute_search( - query: str, - branch: str | None = None, - memory_type: str | None = None, - n_results: int = 5 + query: str, branch: str | None = None, memory_type: str | None = None, n_results: int = 5 ) -> Dict[str, Any]: """ Execute semantic search: encode query, search vectors, filter results. @@ -241,38 +238,35 @@ def execute_search( # Step 1: Encode query via subprocess embed_result = encode_query_subprocess(query) - if not embed_result['success']: - error_msg = embed_result.get('error', 'Unknown error') + if not embed_result["success"]: + error_msg = embed_result.get("error", "Unknown error") logger.error(f"[search] Failed to encode query: {error_msg}") return { - 'success': False, - 'error': f'Failed to encode query: {error_msg}', - 'query': query, + "success": False, + "error": f"Failed to encode query: {error_msg}", + "query": query, } - query_embedding = embed_result['embedding'] + query_embedding = embed_result["embedding"] logger.info(f"[search] Encoded query to {len(query_embedding)}-dim vector") # Step 2: Search via subprocess search_result = search_vectors_subprocess( - query_embedding=query_embedding, - branch=branch, - memory_type=memory_type, - n_results=n_results + query_embedding=query_embedding, branch=branch, memory_type=memory_type, n_results=n_results ) - if not search_result['success']: - error_msg = search_result.get('error', 'Unknown error') + if not search_result["success"]: + error_msg = search_result.get("error", "Unknown error") logger.error(f"[search] Search failed: {error_msg}") return { - 'success': False, - 'error': f'Search failed: {error_msg}', - 'query': query, + "success": False, + "error": f"Search failed: {error_msg}", + "query": query, } - raw_results = search_result.get('results', []) - collections_searched = search_result.get('collections_searched', 0) - total_results = search_result.get('total_results', 0) + raw_results = search_result.get("results", []) + collections_searched = search_result.get("collections_searched", 0) + total_results = search_result.get("total_results", 0) logger.info(f"[search] Found {total_results} results across {collections_searched} collections") @@ -281,14 +275,16 @@ def execute_search( logger.info(f"[search] Filtered to {len(filtered_results)} relevant results") - json_handler.log_operation("search_execute", {"query_len": len(query), "results": len(filtered_results), "success": True}) + json_handler.log_operation( + "search_execute", {"query_len": len(query), "results": len(filtered_results), "success": True} + ) return { - 'success': True, - 'query': query, - 'branch': branch, - 'memory_type': memory_type, - 'results': filtered_results, - 'collections_searched': collections_searched, - 'total_results': total_results, - 'filtered_count': len(filtered_results), + "success": True, + "query": query, + "branch": branch, + "memory_type": memory_type, + "results": filtered_results, + "collections_searched": collections_searched, + "total_results": total_results, + "filtered_count": len(filtered_results), } diff --git a/src/aipass/memory/apps/handlers/search/vector_search.py b/src/aipass/memory/apps/handlers/search/vector_search.py index df310eca..f68d304e 100644 --- a/src/aipass/memory/apps/handlers/search/vector_search.py +++ b/src/aipass/memory/apps/handlers/search/vector_search.py @@ -47,6 +47,7 @@ from aipass.memory.apps.handlers.storage.chroma import get_client # QUERY ENCODING SERVICE (Singleton) # ============================================================================= + class QueryEncoder: """ Query encoding service using same model as embedder.py @@ -55,7 +56,7 @@ class QueryEncoder: Uses all-MiniLM-L6-v2 model with same settings. """ - def __init__(self, model_name: str = 'all-MiniLM-L6-v2'): + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): """ Initialize query encoder @@ -83,11 +84,10 @@ class QueryEncoder: # GPU optimization if available self.use_gpu = torch.cuda.is_available() if self.use_gpu: - self.model = self.model.to('cuda') + self.model = self.model.to("cuda") self.dimension = 384 # all-MiniLM-L6-v2 output dimension - def encode(self, query: str) -> List[float]: """ Encode query text to embedding @@ -105,7 +105,7 @@ class QueryEncoder: query, convert_to_tensor=False, # Return numpy normalize_embeddings=True, # Critical for L2 distance - show_progress_bar=False + show_progress_bar=False, ) # Cleanup GPU memory if used @@ -135,6 +135,7 @@ def _get_encoder() -> QueryEncoder: # CHROMA SEARCH SERVICE # ============================================================================= + class SearchService: """ ChromaDB search service @@ -157,13 +158,12 @@ class SearchService: self.client = get_client(db_path) self.db_path = db_path - def query_collection( self, collection_name: str, query_embedding: List[float], n_results: int = 5, - where: Dict[str, Any] | None = None + where: Dict[str, Any] | None = None, ) -> Dict[str, Any]: """ Query a specific collection @@ -178,36 +178,24 @@ class SearchService: Dict with query results """ try: - collection = self.client.get_collection( - collection_name, - embedding_function=None - ) + collection = self.client.get_collection(collection_name, embedding_function=None) except Exception as e: logger.warning(f"[vector_search] Collection lookup failed for '{collection_name}': {e}") - return { - "collection": collection_name, - "exists": False, - "error": f"Collection not found: {e}" - } + return {"collection": collection_name, "exists": False, "error": f"Collection not found: {e}"} # Query collection - results = collection.query( - query_embeddings=[query_embedding], - n_results=n_results, - where=where - ) + results = collection.query(query_embeddings=[query_embedding], n_results=n_results, where=where) return { "collection": collection_name, "exists": True, - "ids": results['ids'][0] if results['ids'] else [], - "documents": results['documents'][0] if results['documents'] else [], - "metadatas": results['metadatas'][0] if results['metadatas'] else [], - "distances": results['distances'][0] if results['distances'] else [], - "count": len(results['ids'][0]) if results['ids'] else 0 + "ids": results["ids"][0] if results["ids"] else [], + "documents": results["documents"][0] if results["documents"] else [], + "metadatas": results["metadatas"][0] if results["metadatas"] else [], + "distances": results["distances"][0] if results["distances"] else [], + "count": len(results["ids"][0]) if results["ids"] else 0, } - def list_collections(self) -> List[str]: """ List all collections in database @@ -255,12 +243,13 @@ def _get_service(db_path: Path | None = None) -> SearchService: # PUBLIC API # ============================================================================= + def search_collection( query_embedding: List[float], collection_name: str, n_results: int = 5, where: Dict[str, Any] | None = None, - db_path: Path | None = None + db_path: Path | None = None, ) -> Dict[str, Any]: """ Query a ChromaDB collection with embedding @@ -297,40 +286,31 @@ def search_collection( db_path = Path(db_path) if not query_embedding: - return { - 'success': False, - 'error': 'No query embedding provided' - } + return {"success": False, "error": "No query embedding provided"} try: service = _get_service(db_path) result = service.query_collection( - collection_name=collection_name, - query_embedding=query_embedding, - n_results=n_results, - where=where + collection_name=collection_name, query_embedding=query_embedding, n_results=n_results, where=where ) # Check if collection exists - if not result.get('exists', False): + if not result.get("exists", False): return { - 'success': False, - 'error': result.get('error', 'Collection not found'), - 'collection': collection_name + "success": False, + "error": result.get("error", "Collection not found"), + "collection": collection_name, } - json_handler.log_operation("vector_search_collection", {"collection": collection_name, "count": result.get('count', 0), "success": True}) - return { - 'success': True, - **result - } + json_handler.log_operation( + "vector_search_collection", + {"collection": collection_name, "count": result.get("count", 0), "success": True}, + ) + return {"success": True, **result} except Exception as e: logger.error(f"[vector_search] Collection search failed for '{collection_name}': {e}") - return { - 'success': False, - 'error': f"Search failed: {e}" - } + return {"success": False, "error": f"Search failed: {e}"} def encode_query(query: str) -> Dict[str, Any]: @@ -353,28 +333,17 @@ def encode_query(query: str) -> Dict[str, Any]: dimension = result['dimension'] # 384 """ if not query or not query.strip(): - return { - 'success': False, - 'error': 'Empty query string' - } + return {"success": False, "error": "Empty query string"} try: encoder = _get_encoder() embedding = encoder.encode(query) - return { - 'success': True, - 'embedding': embedding, - 'dimension': len(embedding), - 'model': encoder.model_name - } + return {"success": True, "embedding": embedding, "dimension": len(embedding), "model": encoder.model_name} except Exception as e: logger.error(f"[vector_search] Query encoding failed: {e}") - return { - 'success': False, - 'error': f"Encoding failed: {e}" - } + return {"success": False, "error": f"Encoding failed: {e}"} def list_collections(db_path: Path | None = None) -> Dict[str, Any]: @@ -401,26 +370,15 @@ def list_collections(db_path: Path | None = None) -> Dict[str, Any]: service = _get_service(db_path) collections = service.list_collections() - return { - 'success': True, - 'collections': collections, - 'count': len(collections), - 'db_path': str(service.db_path) - } + return {"success": True, "collections": collections, "count": len(collections), "db_path": str(service.db_path)} except Exception as e: logger.error(f"[vector_search] Failed to list collections: {e}") - return { - 'success': False, - 'error': f"Failed to list collections: {e}" - } + return {"success": False, "error": f"Failed to list collections: {e}"} def search_all_collections( - query_embedding: List[float], - n_results: int = 5, - where: Dict[str, Any] | None = None, - db_path: Path | None = None + query_embedding: List[float], n_results: int = 5, where: Dict[str, Any] | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Search across all collections in database @@ -456,53 +414,37 @@ def search_all_collections( db_path = Path(db_path) if not query_embedding: - return { - 'success': False, - 'error': 'No query embedding provided' - } + return {"success": False, "error": "No query embedding provided"} try: service = _get_service(db_path) collections = service.list_collections() if not collections: - return { - 'success': True, - 'results': {}, - 'message': 'No collections found' - } + return {"success": True, "results": {}, "message": "No collections found"} # Query each collection results = {} for collection_name in collections: coll_result = service.query_collection( - collection_name=collection_name, - query_embedding=query_embedding, - n_results=n_results, - where=where + collection_name=collection_name, query_embedding=query_embedding, n_results=n_results, where=where ) - if coll_result.get('exists', False): + if coll_result.get("exists", False): results[collection_name] = { - 'documents': coll_result['documents'], - 'metadatas': coll_result['metadatas'], - 'distances': coll_result['distances'], - 'ids': coll_result['ids'], - 'count': coll_result['count'] + "documents": coll_result["documents"], + "metadatas": coll_result["metadatas"], + "distances": coll_result["distances"], + "ids": coll_result["ids"], + "count": coll_result["count"], } - total = sum(r['count'] for r in results.values()) - json_handler.log_operation("vector_search_all", {"collections": len(results), "total_results": total, "success": True}) - return { - 'success': True, - 'results': results, - 'collections_searched': len(results), - 'total_results': total - } + total = sum(r["count"] for r in results.values()) + json_handler.log_operation( + "vector_search_all", {"collections": len(results), "total_results": total, "success": True} + ) + return {"success": True, "results": results, "collections_searched": len(results), "total_results": total} except Exception as e: logger.error(f"[vector_search] Multi-collection search failed: {e}") - return { - 'success': False, - 'error': f"Multi-collection search failed: {e}" - } + return {"success": False, "error": f"Multi-collection search failed: {e}"} diff --git a/src/aipass/memory/apps/handlers/storage/chroma.py b/src/aipass/memory/apps/handlers/storage/chroma.py index fa87bb93..9a7fdb0f 100644 --- a/src/aipass/memory/apps/handlers/storage/chroma.py +++ b/src/aipass/memory/apps/handlers/storage/chroma.py @@ -43,6 +43,7 @@ _MEMORY_ROOT = Path(__file__).resolve().parents[3] # was an internal singleton wrapper _chroma_clients: Dict[str, Any] = {} + def get_client(db_path: Path): """ Get or create a ChromaDB PersistentClient for the given path. @@ -63,10 +64,7 @@ def get_client(db_path: Path): import chromadb except ImportError: logger.info("[chroma] chromadb not installed, vector storage unavailable") - raise ImportError( - "chromadb is required for vector storage. " - "Install with: pip install chromadb" - ) + raise ImportError("chromadb is required for vector storage. Install with: pip install chromadb") db_path.mkdir(parents=True, exist_ok=True) _chroma_clients[path_str] = chromadb.PersistentClient(path=str(db_path)) return _chroma_clients[path_str] @@ -76,6 +74,7 @@ def get_client(db_path: Path): # CHROMA SERVICE (Singleton) # ============================================================================= + class ChromaService: """ Chroma vector database service @@ -101,7 +100,6 @@ class ChromaService: self.client = get_client(db_path) self.db_path = db_path - def get_collection_name(self, branch: str, memory_type: str) -> str: """ Generate collection name using pattern: {branch}_{type} @@ -115,14 +113,8 @@ class ChromaService: """ return f"{branch.lower()}_{memory_type.lower()}" - def store_vectors( - self, - branch: str, - memory_type: str, - embeddings: List, - documents: List[str], - metadatas: List[Dict[str, Any]] + self, branch: str, memory_type: str, embeddings: List, documents: List[str], metadatas: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Store vectors in branch-specific collection @@ -143,7 +135,7 @@ class ChromaService: collection = self.client.get_or_create_collection( name=collection_name, metadata={"hnsw:space": "cosine", "branch": branch, "type": memory_type}, - embedding_function=None + embedding_function=None, ) # Get existing count for ID generation @@ -151,31 +143,17 @@ class ChromaService: # Generate unique IDs timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - ids = [ - f"{branch}_{memory_type}_{existing_count + i}_{timestamp}" - for i in range(len(embeddings)) - ] + ids = [f"{branch}_{memory_type}_{existing_count + i}_{timestamp}" for i in range(len(embeddings))] # Convert embeddings to list format (Chroma requirement) - embeddings_list = [emb.tolist() if hasattr(emb, 'tolist') else emb for emb in embeddings] + embeddings_list = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings] # Batch insert (optimal size: 100-150) - collection.add( - embeddings=embeddings_list, - documents=documents, - metadatas=metadatas, - ids=ids - ) + collection.add(embeddings=embeddings_list, documents=documents, metadatas=metadatas, ids=ids) new_count = collection.count() - return { - "collection": collection_name, - "count": len(embeddings), - "total_vectors": new_count, - "ids": ids - } - + return {"collection": collection_name, "count": len(embeddings), "total_vectors": new_count, "ids": ids} def get_collection_stats(self, branch: str, memory_type: str) -> Dict[str, Any]: """ @@ -191,25 +169,13 @@ class ChromaService: collection_name = self.get_collection_name(branch, memory_type) try: - collection = self.client.get_collection( - collection_name, - embedding_function=None - ) + collection = self.client.get_collection(collection_name, embedding_function=None) count = collection.count() - return { - "collection": collection_name, - "exists": True, - "vector_count": count - } + return {"collection": collection_name, "exists": True, "vector_count": count} except Exception as e: logger.warning(f"[chroma] Collection stats lookup failed for '{collection_name}': {e}") - return { - "collection": collection_name, - "exists": False, - "vector_count": 0 - } - + return {"collection": collection_name, "exists": False, "vector_count": 0} def list_all_collections(self) -> List[str]: """ @@ -262,13 +228,14 @@ def _get_service(db_path: Path | None = None) -> ChromaService: # PUBLIC API # ============================================================================= + def store_vectors( branch: str, memory_type: str, embeddings: List, documents: List[str], metadatas: List[Dict[str, Any]], - db_path: Path | None = None + db_path: Path | None = None, ) -> Dict[str, Any]: """ Store vectors in Chroma collection @@ -300,35 +267,28 @@ def store_vectors( db_path = Path(db_path) if not embeddings: - return { - 'success': True, - 'message': 'No vectors provided', - 'count': 0 - } + return {"success": True, "message": "No vectors provided", "count": 0} if len(embeddings) != len(documents) or len(embeddings) != len(metadatas): return { - 'success': False, - 'error': f"Length mismatch: {len(embeddings)} embeddings, " - f"{len(documents)} documents, {len(metadatas)} metadatas" + "success": False, + "error": f"Length mismatch: {len(embeddings)} embeddings, " + f"{len(documents)} documents, {len(metadatas)} metadatas", } try: service = _get_service(db_path) result = service.store_vectors(branch, memory_type, embeddings, documents, metadatas) - json_handler.log_operation("chroma_store_vectors", {"collection": result.get('collection'), "count": result.get('count', 0), "success": True}) - return { - 'success': True, - **result - } + json_handler.log_operation( + "chroma_store_vectors", + {"collection": result.get("collection"), "count": result.get("count", 0), "success": True}, + ) + return {"success": True, **result} except Exception as e: logger.error(f"[chroma] Vector storage failed: {e}") - return { - 'success': False, - 'error': f"Storage failed: {e}" - } + return {"success": False, "error": f"Storage failed: {e}"} def get_collection_stats(branch: str, memory_type: str) -> Dict[str, Any]: @@ -346,17 +306,11 @@ def get_collection_stats(branch: str, memory_type: str) -> Dict[str, Any]: service = _get_service() stats = service.get_collection_stats(branch, memory_type) - return { - 'success': True, - **stats - } + return {"success": True, **stats} except Exception as e: logger.error(f"[chroma] Failed to get collection stats: {e}") - return { - 'success': False, - 'error': f"Failed to get stats: {e}" - } + return {"success": False, "error": f"Failed to get stats: {e}"} def list_all_collections() -> Dict[str, Any]: @@ -370,18 +324,11 @@ def list_all_collections() -> Dict[str, Any]: service = _get_service() collections = service.list_all_collections() - return { - 'success': True, - 'collections': collections, - 'count': len(collections) - } + return {"success": True, "collections": collections, "count": len(collections)} except Exception as e: logger.error(f"[chroma] Failed to list collections: {e}") - return { - 'success': False, - 'error': f"Failed to list collections: {e}" - } + return {"success": False, "error": f"Failed to list collections: {e}"} def get_database_info() -> Dict[str, Any]: @@ -396,18 +343,15 @@ def get_database_info() -> Dict[str, Any]: collections = service.list_all_collections() return { - 'success': True, - 'db_path': str(service.db_path), - 'collections_count': len(collections), - 'collections': collections + "success": True, + "db_path": str(service.db_path), + "collections_count": len(collections), + "collections": collections, } except Exception as e: logger.error(f"[chroma] Failed to get database info: {e}") - return { - 'success': False, - 'error': f"Failed to get database info: {e}" - } + return {"success": False, "error": f"Failed to get database info: {e}"} def search_vectors( @@ -415,7 +359,7 @@ def search_vectors( branch: str | None = None, memory_type: str | None = None, n_results: int = 5, - db_path: Path | None = None + db_path: Path | None = None, ) -> Dict[str, Any]: """ Search for similar vectors in Chroma collections @@ -459,38 +403,30 @@ def search_vectors( collection_names = [c for c in collection_names if c.endswith(memory_type.lower())] if not collection_names: - return { - 'success': True, - 'results': [], - 'message': 'No matching collections found' - } + return {"success": True, "results": [], "message": "No matching collections found"} # Search each collection all_results = [] for collection_name in collection_names: try: - collection = service.client.get_collection( - collection_name, - embedding_function=None - ) + collection = service.client.get_collection(collection_name, embedding_function=None) # Query collection - results = collection.query( - query_embeddings=[query_embedding], - n_results=n_results - ) + results = collection.query(query_embeddings=[query_embedding], n_results=n_results) # Format results - if results['documents'] and results['documents'][0]: - for i, doc in enumerate(results['documents'][0]): - all_results.append({ - 'collection': collection_name, - 'document': doc, - 'metadata': results['metadatas'][0][i] if results['metadatas'] else {}, - 'distance': results['distances'][0][i] if results['distances'] else None, - 'id': results['ids'][0][i] if results['ids'] else None - }) + if results["documents"] and results["documents"][0]: + for i, doc in enumerate(results["documents"][0]): + all_results.append( + { + "collection": collection_name, + "document": doc, + "metadata": results["metadatas"][0][i] if results["metadatas"] else {}, + "distance": results["distances"][0][i] if results["distances"] else None, + "id": results["ids"][0][i] if results["ids"] else None, + } + ) except Exception as e: # Collection might not exist - skip it @@ -498,19 +434,19 @@ def search_vectors( continue # Sort by distance (lower is better) - all_results.sort(key=lambda x: x['distance'] if x['distance'] is not None else float('inf')) + all_results.sort(key=lambda x: x["distance"] if x["distance"] is not None else float("inf")) - json_handler.log_operation("chroma_search_vectors", {"collections": len(collection_names), "results": len(all_results), "success": True}) + json_handler.log_operation( + "chroma_search_vectors", + {"collections": len(collection_names), "results": len(all_results), "success": True}, + ) return { - 'success': True, - 'results': all_results[:n_results * len(collection_names)] if all_results else [], - 'collections_searched': len(collection_names), - 'total_results': len(all_results) + "success": True, + "results": all_results[: n_results * len(collection_names)] if all_results else [], + "collections_searched": len(collection_names), + "total_results": len(all_results), } except Exception as e: logger.error(f"[chroma] Vector search failed: {e}") - return { - 'success': False, - 'error': f"Search failed: {e}" - } + return {"success": False, "error": f"Search failed: {e}"} diff --git a/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py b/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py index abf6406f..9475c5c5 100755 --- a/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py +++ b/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py @@ -64,37 +64,26 @@ def _store_vectors(branch, memory_type, embeddings, documents, metadatas, db_pat collection = client.get_or_create_collection( name=collection_name, metadata={"hnsw:space": "cosine", "branch": branch, "type": memory_type}, - embedding_function=None + embedding_function=None, ) existing_count = collection.count() timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - ids = [ - f"{branch}_{memory_type}_{existing_count + i}_{timestamp}" - for i in range(len(embeddings)) - ] + ids = [f"{branch}_{memory_type}_{existing_count + i}_{timestamp}" for i in range(len(embeddings))] # Chroma expects lists, not numpy arrays - embeddings_list = [ - emb.tolist() if hasattr(emb, 'tolist') else emb - for emb in embeddings - ] + embeddings_list = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings] - collection.add( - embeddings=embeddings_list, - documents=documents, - metadatas=metadatas, - ids=ids - ) + collection.add(embeddings=embeddings_list, documents=documents, metadatas=metadatas, ids=ids) new_count = collection.count() return { - 'success': True, - 'collection': collection_name, - 'count': len(embeddings), - 'total_vectors': new_count, - 'ids': ids + "success": True, + "collection": collection_name, + "count": len(embeddings), + "total_vectors": new_count, + "ids": ids, } @@ -103,11 +92,7 @@ def _list_collections(db_path=None): client = _get_client(db_path) collections = client.list_collections() names = [col.name for col in collections] - return { - 'success': True, - 'collections': names, - 'count': len(names) - } + return {"success": True, "collections": names, "count": len(names)} def _check_plan(plan_label, db_path=None): @@ -128,30 +113,25 @@ def _check_plan(plan_label, db_path=None): except Exception as e: logger.warning(f"[chroma_subprocess] Collection '{collection_name}' not found during plan check: {e}") return { - 'success': True, - 'found': False, - 'count': 0, - 'source_files': [], - 'message': f'Collection {collection_name} does not exist' + "success": True, + "found": False, + "count": 0, + "source_files": [], + "message": f"Collection {collection_name} does not exist", } result = collection.get(include=["metadatas"]) - metadatas = result.get('metadatas', []) + metadatas = result.get("metadatas", []) matching_files = set() match_count = 0 for metadata in metadatas: - source_file = metadata.get('source_file', '') + source_file = metadata.get("source_file", "") if plan_label in source_file: match_count += 1 matching_files.add(source_file) - return { - 'success': True, - 'found': match_count > 0, - 'count': match_count, - 'source_files': sorted(matching_files) - } + return {"success": True, "found": match_count > 0, "count": match_count, "source_files": sorted(matching_files)} def _search_vectors(query_embedding, branch=None, memory_type=None, n_results=5, db_path=None): @@ -170,36 +150,35 @@ def _search_vectors(query_embedding, branch=None, memory_type=None, n_results=5, collection_names = [c for c in collection_names if c.endswith(memory_type.lower())] if not collection_names: - return {'success': True, 'results': [], 'message': 'No matching collections'} + return {"success": True, "results": [], "message": "No matching collections"} all_results = [] for cname in collection_names: try: collection = client.get_collection(cname, embedding_function=None) - results = collection.query( - query_embeddings=[query_embedding], - n_results=n_results - ) - if results['documents'] and results['documents'][0]: - for i, doc in enumerate(results['documents'][0]): - all_results.append({ - 'collection': cname, - 'document': doc, - 'metadata': results['metadatas'][0][i] if results['metadatas'] else {}, - 'distance': results['distances'][0][i] if results['distances'] else None, - 'id': results['ids'][0][i] if results['ids'] else None - }) + results = collection.query(query_embeddings=[query_embedding], n_results=n_results) + if results["documents"] and results["documents"][0]: + for i, doc in enumerate(results["documents"][0]): + all_results.append( + { + "collection": cname, + "document": doc, + "metadata": results["metadatas"][0][i] if results["metadatas"] else {}, + "distance": results["distances"][0][i] if results["distances"] else None, + "id": results["ids"][0][i] if results["ids"] else None, + } + ) except Exception as e: logger.warning(f"[chroma_subprocess] Skipping collection '{cname}' during search: {e}") continue - all_results.sort(key=lambda x: x['distance'] if x['distance'] is not None else float('inf')) + all_results.sort(key=lambda x: x["distance"] if x["distance"] is not None else float("inf")) return { - 'success': True, - 'results': all_results, - 'collections_searched': len(collection_names), - 'total_results': len(all_results) + "success": True, + "results": all_results, + "collections_searched": len(collection_names), + "total_results": len(all_results), } @@ -207,48 +186,44 @@ def _search_vectors(query_embedding, branch=None, memory_type=None, n_results=5, # MAIN # ============================================================================= + def main(): """Process ChromaDB operation from stdin JSON.""" try: input_data = json.load(sys.stdin) - operation = input_data.get('operation') + operation = input_data.get("operation") - if operation == 'store_vectors': + if operation == "store_vectors": result = _store_vectors( - branch=input_data.get('branch'), - memory_type=input_data.get('memory_type'), - embeddings=input_data.get('embeddings'), - documents=input_data.get('documents'), - metadatas=input_data.get('metadatas'), - db_path=input_data.get('db_path') + branch=input_data.get("branch"), + memory_type=input_data.get("memory_type"), + embeddings=input_data.get("embeddings"), + documents=input_data.get("documents"), + metadatas=input_data.get("metadatas"), + db_path=input_data.get("db_path"), ) - elif operation == 'list_collections': - result = _list_collections( - db_path=input_data.get('db_path') - ) - elif operation == 'search_vectors': + elif operation == "list_collections": + result = _list_collections(db_path=input_data.get("db_path")) + elif operation == "search_vectors": result = _search_vectors( - query_embedding=input_data.get('query_embedding'), - branch=input_data.get('branch'), - memory_type=input_data.get('memory_type'), - n_results=input_data.get('n_results', 5), - db_path=input_data.get('db_path') - ) - elif operation == 'check_plan': - result = _check_plan( - plan_label=input_data.get('plan_label'), - db_path=input_data.get('db_path') + query_embedding=input_data.get("query_embedding"), + branch=input_data.get("branch"), + memory_type=input_data.get("memory_type"), + n_results=input_data.get("n_results", 5), + db_path=input_data.get("db_path"), ) + elif operation == "check_plan": + result = _check_plan(plan_label=input_data.get("plan_label"), db_path=input_data.get("db_path")) else: - result = {'success': False, 'error': f'Unknown operation: {operation}'} + result = {"success": False, "error": f"Unknown operation: {operation}"} print(json.dumps(result)) except Exception as e: logger.error(f"[chroma_subprocess] Subprocess operation failed: {e}") - print(json.dumps({'success': False, 'error': str(e)})) + print(json.dumps({"success": False, "error": str(e)})) sys.exit(1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/aipass/memory/apps/handlers/symbolic/__init__.py b/src/aipass/memory/apps/handlers/symbolic/__init__.py index ec117905..a4b26e00 100644 --- a/src/aipass/memory/apps/handlers/symbolic/__init__.py +++ b/src/aipass/memory/apps/handlers/symbolic/__init__.py @@ -35,18 +35,18 @@ from .extractor import ( extract_key_learnings, extract_context_triggers, extract_symbolic_dimensions, - analyze_conversation + analyze_conversation, ) from .chroma_client import get_chroma_client __all__ = [ - 'extract_technical_flow', - 'extract_emotional_journey', - 'extract_collaboration_patterns', - 'extract_key_learnings', - 'extract_context_triggers', - 'extract_symbolic_dimensions', - 'analyze_conversation', - 'get_chroma_client' + "extract_technical_flow", + "extract_emotional_journey", + "extract_collaboration_patterns", + "extract_key_learnings", + "extract_context_triggers", + "extract_symbolic_dimensions", + "analyze_conversation", + "get_chroma_client", ] diff --git a/src/aipass/memory/apps/handlers/symbolic/chroma_client.py b/src/aipass/memory/apps/handlers/symbolic/chroma_client.py index b633f32c..f9958492 100644 --- a/src/aipass/memory/apps/handlers/symbolic/chroma_client.py +++ b/src/aipass/memory/apps/handlers/symbolic/chroma_client.py @@ -45,6 +45,7 @@ _clients: Dict[str, Any] = {} # CLIENT MANAGEMENT # ============================================================================= + def get_client(db_path: Path | str | None = None): """ Get or create a shared ChromaDB PersistentClient (singleton per path) @@ -86,10 +87,7 @@ def get_chroma_client(db_path: Path | str | None = None): def get_collection( - collection_name: str, - db_path: Path | None = None, - create: bool = True, - metadata: Dict[str, Any] | None = None + collection_name: str, db_path: Path | None = None, create: bool = True, metadata: Dict[str, Any] | None = None ): """ Get a collection from the shared ChromaDB client @@ -112,25 +110,16 @@ def get_collection( metadata = {"hnsw:space": "cosine"} collection = client.get_or_create_collection( - name=collection_name, - metadata=metadata, - embedding_function=None + name=collection_name, metadata=metadata, embedding_function=None ) else: - collection = client.get_collection( - collection_name, - embedding_function=None - ) + collection = client.get_collection(collection_name, embedding_function=None) - json_handler.log_operation("chroma_get_collection", {"collection": collection_name, "create": create, "success": True}) - return { - 'success': True, - 'collection': collection - } + json_handler.log_operation( + "chroma_get_collection", {"collection": collection_name, "create": create, "success": True} + ) + return {"success": True, "collection": collection} except Exception as e: logger.error(f"[chroma_client] Failed to get collection '{collection_name}': {e}") - return { - 'success': False, - 'error': str(e) - } + return {"success": False, "error": str(e)} diff --git a/src/aipass/memory/apps/handlers/symbolic/deduplicator.py b/src/aipass/memory/apps/handlers/symbolic/deduplicator.py index e9f268fd..a7c5dfb8 100644 --- a/src/aipass/memory/apps/handlers/symbolic/deduplicator.py +++ b/src/aipass/memory/apps/handlers/symbolic/deduplicator.py @@ -61,17 +61,15 @@ DEDUP_SYSTEM_PROMPT = ( "- Return ONLY the JSON object, no other text" ) -VALID_ACTIONS = {'ADD', 'UPDATE', 'DELETE', 'NOOP'} +VALID_ACTIONS = {"ADD", "UPDATE", "DELETE", "NOOP"} # ============================================================================= # DEDUPLICATION # ============================================================================= -def deduplicate_fragment( - new_fragment: Dict[str, Any], - existing_fragments: List[Dict[str, Any]] -) -> Dict[str, Any]: + +def deduplicate_fragment(new_fragment: Dict[str, Any], existing_fragments: List[Dict[str, Any]]) -> Dict[str, Any]: """ Compare a new LLM-extracted fragment against existing similar fragments and decide the AUDN action via LLM. @@ -86,20 +84,15 @@ def deduplicate_fragment( 'fragment' (updated or original), 'reason' (explanation) """ if not new_fragment: - return { - 'success': False, - 'action': 'NOOP', - 'fragment': new_fragment, - 'reason': 'No fragment provided' - } + return {"success": False, "action": "NOOP", "fragment": new_fragment, "reason": "No fragment provided"} # If no existing fragments to compare, always ADD if not existing_fragments: return { - 'success': True, - 'action': 'ADD', - 'fragment': new_fragment, - 'reason': 'No existing fragments to compare against' + "success": True, + "action": "ADD", + "fragment": new_fragment, + "reason": "No existing fragments to compare against", } # Build prompt and call LLM @@ -112,6 +105,7 @@ def deduplicate_fragment( # Load API key via api branch's key management try: from aipass.api.apps.handlers.auth.keys import get_api_key + api_key = get_api_key("openrouter") except ImportError as e: logger.warning(f"[deduplicator] api branch not available for key loading: {e}") @@ -119,18 +113,15 @@ def deduplicate_fragment( if not api_key: return { - 'success': True, - 'action': 'ADD', - 'fragment': new_fragment, - 'reason': 'No OpenRouter API key found (api branch unavailable or key missing), defaulting to ADD' + "success": True, + "action": "ADD", + "fragment": new_fragment, + "reason": "No OpenRouter API key found (api branch unavailable or key missing), defaulting to ADD", } - payload = json.dumps({ - "model": LLM_MODEL, - "messages": messages, - "temperature": 0.2, - "max_tokens": 500 - }).encode("utf-8") + payload = json.dumps({"model": LLM_MODEL, "messages": messages, "temperature": 0.2, "max_tokens": 500}).encode( + "utf-8" + ) req = urllib.request.Request( "https://openrouter.ai/api/v1/chat/completions", data=payload, @@ -138,8 +129,8 @@ def deduplicate_fragment( "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://aipass.dev", - "X-Title": "AIPass Memory" - } + "X-Title": "AIPass Memory", + }, ) try: with urllib.request.urlopen(req, timeout=30) as resp: @@ -148,85 +139,70 @@ def deduplicate_fragment( except (urllib.error.URLError, json.JSONDecodeError, KeyError, IndexError) as e: logger.warning(f"[deduplicator] LLM dedup request failed, defaulting to ADD: {e}") return { - 'success': True, - 'action': 'ADD', - 'fragment': new_fragment, - 'reason': 'LLM request failed, defaulting to ADD' + "success": True, + "action": "ADD", + "fragment": new_fragment, + "reason": "LLM request failed, defaulting to ADD", } if not content: return { - 'success': True, - 'action': 'ADD', - 'fragment': new_fragment, - 'reason': 'Empty LLM response, defaulting to ADD' + "success": True, + "action": "ADD", + "fragment": new_fragment, + "reason": "Empty LLM response, defaulting to ADD", } - data = {'content': content} + data = {"content": content} # Parse LLM decision - parsed = _parse_dedup_response(data['content']) + parsed = _parse_dedup_response(data["content"]) if parsed is None: return { - 'success': True, - 'action': 'ADD', - 'fragment': new_fragment, - 'reason': 'Failed to parse LLM dedup response, defaulting to ADD' + "success": True, + "action": "ADD", + "fragment": new_fragment, + "reason": "Failed to parse LLM dedup response, defaulting to ADD", } - action = parsed['action'] - reason = parsed.get('reason', 'No reason provided') + action = parsed["action"] + reason = parsed.get("reason", "No reason provided") - json_handler.log_operation("symbolic_dedup", {"action": action, "existing_count": len(existing_fragments), "success": True}) + json_handler.log_operation( + "symbolic_dedup", {"action": action, "existing_count": len(existing_fragments), "success": True} + ) # Apply action to fragment - if action == 'UPDATE': + if action == "UPDATE": # Merge content from LLM response into fragment updated_fragment = new_fragment.copy() - if parsed.get('merged_summary'): - updated_fragment['summary'] = parsed['merged_summary'] - if parsed.get('merged_insight'): - updated_fragment['insight'] = parsed['merged_insight'] + if parsed.get("merged_summary"): + updated_fragment["summary"] = parsed["merged_summary"] + if parsed.get("merged_insight"): + updated_fragment["insight"] = parsed["merged_insight"] + return {"success": True, "action": "UPDATE", "fragment": updated_fragment, "reason": reason} + + elif action == "DELETE": return { - 'success': True, - 'action': 'UPDATE', - 'fragment': updated_fragment, - 'reason': reason + "success": True, + "action": "DELETE", + "fragment": new_fragment, + "delete_id": parsed.get("delete_id", ""), + "reason": reason, } - elif action == 'DELETE': - return { - 'success': True, - 'action': 'DELETE', - 'fragment': new_fragment, - 'delete_id': parsed.get('delete_id', ''), - 'reason': reason - } - - elif action == 'NOOP': - return { - 'success': True, - 'action': 'NOOP', - 'fragment': new_fragment, - 'reason': reason - } + elif action == "NOOP": + return {"success": True, "action": "NOOP", "fragment": new_fragment, "reason": reason} else: # ADD - return { - 'success': True, - 'action': 'ADD', - 'fragment': new_fragment, - 'reason': reason - } + return {"success": True, "action": "ADD", "fragment": new_fragment, "reason": reason} # ============================================================================= # PROMPT BUILDING # ============================================================================= -def _build_dedup_prompt( - new_fragment: Dict[str, Any], - existing_fragments: List[Dict[str, Any]] -) -> List[Dict[str, str]]: + +def _build_dedup_prompt(new_fragment: Dict[str, Any], existing_fragments: List[Dict[str, Any]]) -> List[Dict[str, str]]: """ Build system/user messages for the deduplication LLM call. @@ -251,15 +227,15 @@ def _build_dedup_prompt( # Format existing fragments (limit to top 3) existing_parts = [] for i, frag in enumerate(existing_fragments[:3]): - frag_id = frag.get('id', f'unknown_{i}') - content = frag.get('content', '') - metadata = frag.get('metadata', {}) + frag_id = frag.get("id", f"unknown_{i}") + content = frag.get("content", "") + metadata = frag.get("metadata", {}) # Extract summary/insight from metadata if available (v2 fragments) - summary = metadata.get('summary', content[:200] if content else '') - insight = metadata.get('insight', '') - frag_type = metadata.get('type', '') - triggers = metadata.get('triggers', '') + summary = metadata.get("summary", content[:200] if content else "") + insight = metadata.get("insight", "") + frag_type = metadata.get("type", "") + triggers = metadata.get("triggers", "") existing_parts.append( f"EXISTING FRAGMENT {i + 1} (ID: {frag_id}):\n" @@ -280,16 +256,14 @@ def _build_dedup_prompt( f"DELETE an existing one, or NOOP (skip as duplicate)?" ) - return [ - {"role": "system", "content": DEDUP_SYSTEM_PROMPT}, - {"role": "user", "content": user_content} - ] + return [{"role": "system", "content": DEDUP_SYSTEM_PROMPT}, {"role": "user", "content": user_content}] # ============================================================================= # RESPONSE PARSING # ============================================================================= + def _parse_dedup_response(raw_text: str) -> Optional[Dict[str, Any]]: """ Parse the JSON response from the deduplication LLM call. @@ -317,7 +291,7 @@ def _parse_dedup_response(raw_text: str) -> Optional[Dict[str, Any]]: logger.warning(f"[deduplicator] Direct JSON parse failed, trying fallback: {e}") # Attempt 2: Strip markdown fences - match = re.search(r'```(?:json)?\s*\n?(.*?)\n?\s*```', text, re.DOTALL) + match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", text, re.DOTALL) if match: try: result = json.loads(match.group(1).strip()) @@ -349,9 +323,9 @@ def _validate_dedup_result(result: Dict[str, Any]) -> bool: Returns: True if action is valid """ - action = result.get('action', '').upper() + action = result.get("action", "").upper() if action not in VALID_ACTIONS: return False # Normalize action to uppercase - result['action'] = action + result["action"] = action return True diff --git a/src/aipass/memory/apps/handlers/symbolic/extractor.py b/src/aipass/memory/apps/handlers/symbolic/extractor.py index c26f14fa..6d3e6487 100644 --- a/src/aipass/memory/apps/handlers/symbolic/extractor.py +++ b/src/aipass/memory/apps/handlers/symbolic/extractor.py @@ -32,145 +32,161 @@ _MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # v1 REGEX EXTRACTION (fallback) # ============================================================================= + def extract_technical_flow(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """Detect problem/debug/breakthrough patterns via keyword matching.""" if not chat_history: - return {'success': True, 'patterns': ['no_conversation'], 'details': {}} + return {"success": True, "patterns": ["no_conversation"], "details": {}} patterns = [] indicators = { - 'problems': ['error', 'bug', 'issue', 'problem', 'broken', 'fail', 'wrong'], - 'debugging': ['debug', 'trace', 'check', 'test', 'try', 'attempt'], - 'solutions': ['fix', 'solve', 'work', 'success', 'breakthrough', 'got it'], - 'struggle': ['stuck', 'confused', 'difficult', 'hard', 'frustrating'], - 'learning': ['understand', 'learn', 'realize', 'discover', 'insight'] + "problems": ["error", "bug", "issue", "problem", "broken", "fail", "wrong"], + "debugging": ["debug", "trace", "check", "test", "try", "attempt"], + "solutions": ["fix", "solve", "work", "success", "breakthrough", "got it"], + "struggle": ["stuck", "confused", "difficult", "hard", "frustrating"], + "learning": ["understand", "learn", "realize", "discover", "insight"], } cat_counts = {cat: 0 for cat in indicators} for msg in chat_history: - content = (msg.get('content') or '').lower() - role = msg.get('role', '') + content = (msg.get("content") or "").lower() + role = msg.get("role", "") for cat, kws in indicators.items(): if any(kw in content for kw in kws): - patterns.append(f'{cat}_{role}') + patterns.append(f"{cat}_{role}") cat_counts[cat] += 1 - ps = ' '.join(patterns) - if 'problems' in ps and 'solutions' in ps: - flow = ['problem_struggle_breakthrough'] if 'struggle' in ps else ['problem_solution_flow'] - elif 'debugging' in ps: - flow = ['debugging_session'] - elif 'learning' in ps: - flow = ['learning_conversation'] + ps = " ".join(patterns) + if "problems" in ps and "solutions" in ps: + flow = ["problem_struggle_breakthrough"] if "struggle" in ps else ["problem_solution_flow"] + elif "debugging" in ps: + flow = ["debugging_session"] + elif "learning" in ps: + flow = ["learning_conversation"] else: - flow = ['general_technical'] - return {'success': True, 'patterns': flow, - 'details': {'category_counts': cat_counts, 'raw_patterns': patterns[:10]}} + flow = ["general_technical"] + return { + "success": True, + "patterns": flow, + "details": {"category_counts": cat_counts, "raw_patterns": patterns[:10]}, + } def extract_emotional_journey(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """Detect emotional arc via tone markers.""" if not chat_history: - return {'success': True, 'arc': ['neutral'], 'details': {}} + return {"success": True, "arc": ["neutral"], "details": {}} markers = { - 'frustration': ['frustrated', 'annoying', 'difficult', 'stuck', 'ugh', 'damn'], - 'excitement': ['cool', 'awesome', 'great', 'amazing', 'perfect', 'brilliant'], - 'confidence': ['sure', 'certain', 'definitely', 'absolutely', 'know'], - 'uncertainty': ['maybe', 'possibly', 'not sure', 'think', 'guess'], - 'breakthrough': ['got it', 'understand', 'works', 'success', 'finally'], - 'curiosity': ['wonder', 'curious', 'interesting', 'what if', 'how'] + "frustration": ["frustrated", "annoying", "difficult", "stuck", "ugh", "damn"], + "excitement": ["cool", "awesome", "great", "amazing", "perfect", "brilliant"], + "confidence": ["sure", "certain", "definitely", "absolutely", "know"], + "uncertainty": ["maybe", "possibly", "not sure", "think", "guess"], + "breakthrough": ["got it", "understand", "works", "success", "finally"], + "curiosity": ["wonder", "curious", "interesting", "what if", "how"], } timeline = [] for msg in chat_history: - content = (msg.get('content') or '').lower() - role = msg.get('role', '') + content = (msg.get("content") or "").lower() + role = msg.get("role", "") emos = [e for e, ms in markers.items() if any(m in content for m in ms)] if emos: timeline.append((role, emos)) if not timeline: - return {'success': True, 'arc': ['neutral_tone'], 'details': {'timeline': []}} + return {"success": True, "arc": ["neutral_tone"], "details": {"timeline": []}} all_emos = [e for _, es in timeline for e in es] - if 'frustration' in all_emos and 'breakthrough' in all_emos: - arc = ['frustration_to_breakthrough'] - elif 'curiosity' in all_emos and 'excitement' in all_emos: - arc = ['curiosity_to_excitement'] - elif 'uncertainty' in all_emos and 'confidence' in all_emos: - arc = ['uncertainty_to_confidence'] + if "frustration" in all_emos and "breakthrough" in all_emos: + arc = ["frustration_to_breakthrough"] + elif "curiosity" in all_emos and "excitement" in all_emos: + arc = ["curiosity_to_excitement"] + elif "uncertainty" in all_emos and "confidence" in all_emos: + arc = ["uncertainty_to_confidence"] else: arc = [e for e, _ in Counter(all_emos).most_common(2)] - return {'success': True, 'arc': arc, - 'details': {'timeline': timeline[:10], 'emotion_counts': dict(Counter(all_emos))}} + return { + "success": True, + "arc": arc, + "details": {"timeline": timeline[:10], "emotion_counts": dict(Counter(all_emos))}, + } def extract_collaboration_patterns(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """Identify interaction dynamics (user-directed, balanced, teaching, etc.).""" if not chat_history: - return {'success': True, 'patterns': ['no_interaction'], 'details': {}} - u_msgs = [m for m in chat_history if m.get('role') == 'user'] - a_msgs = [m for m in chat_history if m.get('role') == 'assistant'] + return {"success": True, "patterns": ["no_interaction"], "details": {}} + u_msgs = [m for m in chat_history if m.get("role") == "user"] + a_msgs = [m for m in chat_history if m.get("role") == "assistant"] if not u_msgs or not a_msgs: - return {'success': True, 'patterns': ['one_sided_conversation'], 'details': {}} + return {"success": True, "patterns": ["one_sided_conversation"], "details": {}} patterns = [] - avg_u = sum(len(m.get('content', '')) for m in u_msgs) / len(u_msgs) - avg_a = sum(len(m.get('content', '')) for m in a_msgs) / len(a_msgs) + avg_u = sum(len(m.get("content", "")) for m in u_msgs) / len(u_msgs) + avg_a = sum(len(m.get("content", "")) for m in a_msgs) / len(a_msgs) if avg_u > avg_a * 1.5: - patterns.append('user_directed') + patterns.append("user_directed") elif avg_a > avg_u * 1.5: - patterns.append('assistant_detailed') + patterns.append("assistant_detailed") else: - patterns.append('balanced_exchange') - u_qs = sum(1 for m in u_msgs if '?' in m.get('content', '')) + patterns.append("balanced_exchange") + u_qs = sum(1 for m in u_msgs if "?" in m.get("content", "")) if u_qs > len(u_msgs) * 0.6: - patterns.append('question_heavy') - uc = ' '.join(m.get('content', '').lower() for m in u_msgs) - ac = ' '.join(m.get('content', '').lower() for m in a_msgs) - if any(i in uc for i in ['try', "let's", 'what if', 'how about', 'consider']): - patterns.append('user_coaching') - if any(i in ac for i in ['explain', 'show', 'understand', 'learn', 'because']): - patterns.append('assistant_teaching') - if any(i in uc + ac for i in ["let's build", 'we can', 'together', 'collaborate']): - patterns.append('collaborative_building') - return {'success': True, 'patterns': patterns or ['standard_interaction'], - 'details': {'avg_user_length': int(avg_u), 'avg_assistant_length': int(avg_a), - 'user_questions': u_qs, 'total_user_messages': len(u_msgs)}} + patterns.append("question_heavy") + uc = " ".join(m.get("content", "").lower() for m in u_msgs) + ac = " ".join(m.get("content", "").lower() for m in a_msgs) + if any(i in uc for i in ["try", "let's", "what if", "how about", "consider"]): + patterns.append("user_coaching") + if any(i in ac for i in ["explain", "show", "understand", "learn", "because"]): + patterns.append("assistant_teaching") + if any(i in uc + ac for i in ["let's build", "we can", "together", "collaborate"]): + patterns.append("collaborative_building") + return { + "success": True, + "patterns": patterns or ["standard_interaction"], + "details": { + "avg_user_length": int(avg_u), + "avg_assistant_length": int(avg_a), + "user_questions": u_qs, + "total_user_messages": len(u_msgs), + }, + } def extract_key_learnings(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """Extract insight categories (discovery, problem_solving, etc.).""" if not chat_history: - return {'success': True, 'insights': ['no_insights'], 'details': {}} + return {"success": True, "insights": ["no_insights"], "details": {}} insights = [] - lp = {'discovery': ['discovered', 'found out', 'realized', 'learned'], - 'problem_solving': ['solution', 'approach', 'method', 'way to'], - 'understanding': ['understand', 'makes sense', 'clear', 'see'], - 'improvement': ['better', 'improve', 'optimize', 'enhance'], - 'mistakes': ['wrong', 'mistake', 'error', 'incorrect']} - ac = ' '.join((m.get('content') or '').lower() for m in chat_history) + lp = { + "discovery": ["discovered", "found out", "realized", "learned"], + "problem_solving": ["solution", "approach", "method", "way to"], + "understanding": ["understand", "makes sense", "clear", "see"], + "improvement": ["better", "improve", "optimize", "enhance"], + "mistakes": ["wrong", "mistake", "error", "incorrect"], + } + ac = " ".join((m.get("content") or "").lower() for m in chat_history) for cat, inds in lp.items(): if any(i in ac for i in inds): insights.append(cat) - if 'module' in ac and 'toggle' in ac: - insights.append('module_system_learning') - if 'memory' in ac and 'compression' in ac: - insights.append('memory_system_learning') - if 'debug' in ac and 'fix' in ac: - insights.append('debugging_skills') - return {'success': True, 'insights': insights or ['general_conversation'], - 'details': {'content_length': len(ac)}} + if "module" in ac and "toggle" in ac: + insights.append("module_system_learning") + if "memory" in ac and "compression" in ac: + insights.append("memory_system_learning") + if "debug" in ac and "fix" in ac: + insights.append("debugging_skills") + return {"success": True, "insights": insights or ["general_conversation"], "details": {"content_length": len(ac)}} def extract_context_triggers(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """Extract keyword triggers for future memory recall.""" if not chat_history: - return {'success': True, 'triggers': [], 'details': {}} - ac = ' '.join((m.get('content') or '') for m in chat_history).lower() - pat = (r'\b(?:module|system|debug|memory|compression|vector|symbolic|' - r'registry|toggle|profile|chat|context|api|token|embedding|storage|' - r'json|function|method|class|import|file|script|error|fix|solution|' - r'breakthrough|pattern|analysis|extraction|conversation|interaction|' - r'collaboration|learning|insight|discovery|handler|branch|rollover)\b') + return {"success": True, "triggers": [], "details": {}} + ac = " ".join((m.get("content") or "") for m in chat_history).lower() + pat = ( + r"\b(?:module|system|debug|memory|compression|vector|symbolic|" + r"registry|toggle|profile|chat|context|api|token|embedding|storage|" + r"json|function|method|class|import|file|script|error|fix|solution|" + r"breakthrough|pattern|analysis|extraction|conversation|interaction|" + r"collaboration|learning|insight|discovery|handler|branch|rollover)\b" + ) terms = re.findall(pat, ac) tc = Counter(terms) triggers = [t for t, c in tc.most_common(10) if c > 1] - return {'success': True, 'triggers': triggers, - 'details': {'term_counts': dict(tc.most_common(15))}} + return {"success": True, "triggers": triggers, "details": {"term_counts": dict(tc.most_common(15))}} def extract_symbolic_dimensions(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: @@ -181,38 +197,51 @@ def extract_symbolic_dimensions(chat_history: List[Dict[str, Any]]) -> Dict[str, learn = extract_key_learnings(chat_history) trig = extract_context_triggers(chat_history) return { - 'success': True, - 'dimensions': { - 'technical': tech.get('patterns', []), 'emotional': emo.get('arc', []), - 'collaboration': collab.get('patterns', []), - 'learnings': learn.get('insights', []), 'triggers': trig.get('triggers', [])}, - 'details': { - 'technical': tech.get('details', {}), 'emotional': emo.get('details', {}), - 'collaboration': collab.get('details', {}), - 'learnings': learn.get('details', {}), 'triggers': trig.get('details', {})}} + "success": True, + "dimensions": { + "technical": tech.get("patterns", []), + "emotional": emo.get("arc", []), + "collaboration": collab.get("patterns", []), + "learnings": learn.get("insights", []), + "triggers": trig.get("triggers", []), + }, + "details": { + "technical": tech.get("details", {}), + "emotional": emo.get("details", {}), + "collaboration": collab.get("details", {}), + "learnings": learn.get("details", {}), + "triggers": trig.get("details", {}), + }, + } def analyze_conversation(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """v1 entry point: symbolic dimensions + conversation metadata.""" if not chat_history: - return {'success': True, 'message_count': 0, 'dimensions': {}, 'metadata': {}} + return {"success": True, "message_count": 0, "dimensions": {}, "metadata": {}} dims = extract_symbolic_dimensions(chat_history) - total_chars = sum(len(m.get('content') or '') for m in chat_history) - total_words = sum(len((m.get('content') or '').split()) for m in chat_history) + total_chars = sum(len(m.get("content") or "") for m in chat_history) + total_words = sum(len((m.get("content") or "").split()) for m in chat_history) if total_words > 2000 and len(chat_history) > 20: - depth = 'deep_extended' + depth = "deep_extended" elif total_words > 1000 and len(chat_history) > 10: - depth = 'substantial' + depth = "substantial" elif total_words > 500: - depth = 'moderate' + depth = "moderate" else: - depth = 'light' + depth = "light" return { - 'success': True, 'message_count': len(chat_history), - 'dimensions': dims.get('dimensions', {}), - 'metadata': {'timestamp': datetime.now().isoformat(), 'total_chars': total_chars, - 'total_words': total_words, 'depth': depth}, - 'details': dims.get('details', {})} + "success": True, + "message_count": len(chat_history), + "dimensions": dims.get("dimensions", {}), + "metadata": { + "timestamp": datetime.now().isoformat(), + "total_chars": total_chars, + "total_words": total_words, + "depth": depth, + }, + "details": dims.get("details", {}), + } # ============================================================================= @@ -220,9 +249,9 @@ def analyze_conversation(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: # ============================================================================= LLM_MODEL = "meta-llama/llama-3.3-70b-instruct:free" -CHUNK_THRESHOLD = 25 # messages before chunking -CHUNK_SIZE = 20 # messages per chunk -CHUNK_OVERLAP = 5 # overlap between chunks +CHUNK_THRESHOLD = 25 # messages before chunking +CHUNK_SIZE = 20 # messages per chunk +CHUNK_OVERLAP = 5 # overlap between chunks EXTRACTION_SYSTEM_PROMPT = ( "You are a memory extraction system for an AI collaboration platform.\n" @@ -272,13 +301,13 @@ def _format_conversation_for_prompt(messages: List[Dict[str, Any]]) -> str: """Format conversation messages into readable text for the LLM prompt.""" lines = [] for msg in messages: - role = msg.get('role', 'unknown') - content = (msg.get('content') or '').strip() + role = msg.get("role", "unknown") + content = (msg.get("content") or "").strip() if content: if len(content) > 1500: content = content[:1500] + "... [truncated]" lines.append(f"{role}: {content}") - return '\n'.join(lines) + return "\n".join(lines) def _chunk_messages(messages: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: @@ -293,7 +322,7 @@ def _chunk_messages(messages: List[Dict[str, Any]]) -> List[List[Dict[str, Any]] next_start = start + CHUNK_SIZE - CHUNK_OVERLAP remaining = len(messages) - next_start if 0 < remaining < CHUNK_OVERLAP: - chunks[-1] = messages[start:len(messages)] + chunks[-1] = messages[start : len(messages)] break start = next_start return chunks @@ -312,7 +341,7 @@ def _parse_llm_json(raw_text: str) -> Optional[List[Dict[str, Any]]]: except (json.JSONDecodeError, ValueError) as e: logger.warning(f"[extractor] Direct JSON parse failed, trying fallback: {e}") # Attempt 2: Strip markdown fences - match = re.search(r'```(?:json)?\s*\n?(.*?)\n?\s*```', text, re.DOTALL) + match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", text, re.DOTALL) if match: try: result = json.loads(match.group(1).strip()) @@ -327,14 +356,14 @@ def _validate_fragment(fragment: Any) -> bool: """Check fragment dict has required fields with valid enum values.""" if not isinstance(fragment, dict): return False - required = {'summary', 'insight', 'type', 'triggers', 'emotional_tone'} + required = {"summary", "insight", "type", "triggers", "emotional_tone"} if not required.issubset(fragment.keys()): return False - if fragment.get('type') not in {'episodic', 'procedural', 'semantic', 'emotional'}: + if fragment.get("type") not in {"episodic", "procedural", "semantic", "emotional"}: return False - if fragment.get('emotional_tone') not in {'neutral', 'frustrated', 'excited', 'curious', 'confident'}: + if fragment.get("emotional_tone") not in {"neutral", "frustrated", "excited", "curious", "confident"}: return False - if not isinstance(fragment.get('triggers'), list): + if not isinstance(fragment.get("triggers"), list): return False return True @@ -344,7 +373,7 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: Extract memory fragments via OpenRouter LLM (Llama 3.3 70B). Chunks long conversations. Returns status dict with 'fragments' list. """ - empty = {'success': True, 'fragments': [], 'chunk_count': 0, 'error': None} + empty = {"success": True, "fragments": [], "chunk_count": 0, "error": None} if not chat_history: return empty @@ -355,14 +384,19 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: # Load API key via api branch's key management try: from aipass.api.apps.handlers.auth.keys import get_api_key + api_key = get_api_key("openrouter") except ImportError as e: logger.warning(f"[extractor] api branch not available for key loading: {e}") api_key = None if not api_key: - return {'success': False, 'fragments': [], 'chunk_count': 0, - 'error': "No OpenRouter API key found (api branch unavailable or key missing)"} + return { + "success": False, + "fragments": [], + "chunk_count": 0, + "error": "No OpenRouter API key found (api branch unavailable or key missing)", + } chunks = _chunk_messages(chat_history) all_fragments = [] @@ -372,16 +406,10 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: conv_text = _format_conversation_for_prompt(chunk) if not conv_text.strip(): continue - messages = [ - {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT}, - {"role": "user", "content": conv_text} - ] - payload = json.dumps({ - "model": LLM_MODEL, - "messages": messages, - "temperature": 0.3, - "max_tokens": 2000 - }).encode("utf-8") + messages = [{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT}, {"role": "user", "content": conv_text}] + payload = json.dumps({"model": LLM_MODEL, "messages": messages, "temperature": 0.3, "max_tokens": 2000}).encode( + "utf-8" + ) req = urllib.request.Request( "https://openrouter.ai/api/v1/chat/completions", data=payload, @@ -389,17 +417,17 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://aipass.dev", - "X-Title": "AIPass Memory" - } + "X-Title": "AIPass Memory", + }, ) try: with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read().decode("utf-8")) content = result.get("choices", [{}])[0].get("message", {}).get("content", "") except (urllib.error.URLError, json.JSONDecodeError, KeyError, IndexError) as e: - err_msg = f"Chunk {i+1}/{len(chunks)}: {type(e).__name__}: {e}" + err_msg = f"Chunk {i + 1}/{len(chunks)}: {type(e).__name__}: {e}" chunk_errors.append(err_msg) - logger.warning(f"[extractor] LLM extraction failed for chunk {i+1}/{len(chunks)}: {e}") + logger.warning(f"[extractor] LLM extraction failed for chunk {i + 1}/{len(chunks)}: {e}") continue if not content: continue @@ -409,18 +437,18 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: chunks_succeeded += 1 for frag in parsed: if _validate_fragment(frag): - frag.setdefault('technical_domain', '') + frag.setdefault("technical_domain", "") all_fragments.append(frag) all_failed = len(chunks) > 0 and chunks_succeeded == 0 and len(chunk_errors) > 0 return { - 'success': not all_failed, - 'fragments': all_fragments, - 'chunk_count': len(chunks), - 'chunks_succeeded': chunks_succeeded, - 'chunks_failed': len(chunk_errors), - 'error': '; '.join(chunk_errors) if all_failed else None, - 'chunk_errors': chunk_errors + "success": not all_failed, + "fragments": all_fragments, + "chunk_count": len(chunks), + "chunks_succeeded": chunks_succeeded, + "chunks_failed": len(chunk_errors), + "error": "; ".join(chunk_errors) if all_failed else None, + "chunk_errors": chunk_errors, } @@ -430,21 +458,25 @@ def analyze_conversation_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, An Returns status dict with 'fragments', 'metadata', 'message_count'. """ if not chat_history: - return {'success': True, 'fragments': [], 'metadata': {}, - 'message_count': 0, 'error': None} + return {"success": True, "fragments": [], "metadata": {}, "message_count": 0, "error": None} llm = extract_fragments_llm(chat_history) reg = analyze_conversation(chat_history) result = { - 'success': llm.get('success', False), - 'fragments': llm.get('fragments', []), - 'metadata': { - 'timestamp': reg.get('metadata', {}).get('timestamp', datetime.now().isoformat()), - 'total_chars': reg.get('metadata', {}).get('total_chars', 0), - 'total_words': reg.get('metadata', {}).get('total_words', 0), - 'depth': reg.get('metadata', {}).get('depth', 'unknown'), - 'dimensions': reg.get('dimensions', {}), - 'chunk_count': llm.get('chunk_count', 0)}, - 'message_count': reg.get('message_count', len(chat_history)), - 'error': llm.get('error')} - json_handler.log_operation("symbolic_extract", {"fragments": len(result['fragments']), "messages": result['message_count'], "success": True}) + "success": llm.get("success", False), + "fragments": llm.get("fragments", []), + "metadata": { + "timestamp": reg.get("metadata", {}).get("timestamp", datetime.now().isoformat()), + "total_chars": reg.get("metadata", {}).get("total_chars", 0), + "total_words": reg.get("metadata", {}).get("total_words", 0), + "depth": reg.get("metadata", {}).get("depth", "unknown"), + "dimensions": reg.get("dimensions", {}), + "chunk_count": llm.get("chunk_count", 0), + }, + "message_count": reg.get("message_count", len(chat_history)), + "error": llm.get("error"), + } + json_handler.log_operation( + "symbolic_extract", + {"fragments": len(result["fragments"]), "messages": result["message_count"], "success": True}, + ) return result diff --git a/src/aipass/memory/apps/handlers/symbolic/hook.py b/src/aipass/memory/apps/handlers/symbolic/hook.py index 9b7e31c9..3801b825 100644 --- a/src/aipass/memory/apps/handlers/symbolic/hook.py +++ b/src/aipass/memory/apps/handlers/symbolic/hook.py @@ -45,22 +45,18 @@ DEFAULT_CONFIG = { "threshold": 0.3, "max_fragments_per_session": 5, "min_messages_between": 10, - "cooldown_seconds": 300 + "cooldown_seconds": 300, } # Session state for tracking surfacing frequency -SESSION_STATE = { - "fragments_surfaced": 0, - "messages_since_last": 0, - "last_surface_time": 0, - "surfaced_ids": set() -} +SESSION_STATE = {"fragments_surfaced": 0, "messages_since_last": 0, "last_surface_time": 0, "surfaced_ids": set()} # ============================================================================= # CONFIGURATION # ============================================================================= + def load_config(config_path: Path | None = None) -> Dict[str, Any]: """ Load hook configuration from JSON file @@ -75,8 +71,8 @@ def load_config(config_path: Path | None = None) -> Dict[str, Any]: if path.exists(): result = memory_files.read_memory_file(path) - if result.get('success'): - config = result.get('data', {}) + if result.get("success"): + config = result.get("data", {}) # Merge with defaults for any missing keys return {**DEFAULT_CONFIG, **config} @@ -106,10 +102,8 @@ def save_config(config: Dict[str, Any], config_path: Path | None = None) -> Dict # CONTEXT EXTRACTION # ============================================================================= -def extract_conversation_context( - messages: List[Dict[str, Any]], - max_messages: int = 5 -) -> Dict[str, Any]: + +def extract_conversation_context(messages: List[Dict[str, Any]], max_messages: int = 5) -> Dict[str, Any]: """ Extract keywords, themes, and mood from recent conversation messages @@ -126,27 +120,21 @@ def extract_conversation_context( Dict with 'success', 'keywords', 'mood', 'themes' """ if not messages: - return { - 'success': True, - 'keywords': [], - 'mood': 'neutral', - 'themes': [], - 'message': 'No messages to analyze' - } + return {"success": True, "keywords": [], "mood": "neutral", "themes": [], "message": "No messages to analyze"} # Take only recent messages recent = messages[-max_messages:] if len(messages) > max_messages else messages # Combine all content - all_content = ' '.join((msg.get('content') or '') for msg in recent).lower() + all_content = " ".join((msg.get("content") or "") for msg in recent).lower() # Extract keywords (technical terms and significant words) keyword_pattern = ( - r'\b(?:error|bug|fix|debug|issue|problem|solution|work|stuck|' - r'module|system|memory|vector|storage|file|function|method|class|' - r'api|token|embedding|json|import|script|handler|branch|pattern|' - r'frustrated|excited|confused|understand|learn|discover|insight|' - r'help|create|build|implement|design|architecture)\b' + r"\b(?:error|bug|fix|debug|issue|problem|solution|work|stuck|" + r"module|system|memory|vector|storage|file|function|method|class|" + r"api|token|embedding|json|import|script|handler|branch|pattern|" + r"frustrated|excited|confused|understand|learn|discover|insight|" + r"help|create|build|implement|design|architecture)\b" ) keywords = list(set(re.findall(keyword_pattern, all_content))) @@ -158,12 +146,12 @@ def extract_conversation_context( themes = _extract_themes(all_content) return { - 'success': True, - 'keywords': keywords[:10], # Limit to top 10 - 'mood': mood, - 'themes': themes, - 'analyzed_messages': len(recent), - 'content_length': len(all_content) + "success": True, + "keywords": keywords[:10], # Limit to top 10 + "mood": mood, + "themes": themes, + "analyzed_messages": len(recent), + "content_length": len(all_content), } @@ -178,11 +166,11 @@ def _detect_mood(content: str) -> str: Detected mood string (frustrated, curious, excited, confused, focused, or neutral) """ mood_indicators = { - 'frustrated': ['frustrated', 'annoying', 'stuck', 'difficult', 'ugh', 'damn', 'hate'], - 'curious': ['wonder', 'curious', 'interesting', 'what if', 'how does', 'why'], - 'excited': ['cool', 'awesome', 'great', 'amazing', 'perfect', 'love', 'finally'], - 'confused': ['confused', 'unclear', 'dont understand', "don't understand", 'lost'], - 'focused': ['need to', 'want to', 'lets', "let's", 'should', 'must'] + "frustrated": ["frustrated", "annoying", "stuck", "difficult", "ugh", "damn", "hate"], + "curious": ["wonder", "curious", "interesting", "what if", "how does", "why"], + "excited": ["cool", "awesome", "great", "amazing", "perfect", "love", "finally"], + "confused": ["confused", "unclear", "dont understand", "don't understand", "lost"], + "focused": ["need to", "want to", "lets", "let's", "should", "must"], } mood_scores: Dict[str, int] = {} @@ -193,7 +181,7 @@ def _detect_mood(content: str) -> str: if mood_scores: return max(mood_scores, key=lambda k: mood_scores[k]) - return 'neutral' + return "neutral" def _extract_themes(content: str) -> List[str]: @@ -209,12 +197,12 @@ def _extract_themes(content: str) -> List[str]: themes = [] theme_patterns = { - 'debugging': ['debug', 'error', 'fix', 'trace', 'bug'], - 'building': ['create', 'build', 'implement', 'design', 'architecture'], - 'learning': ['learn', 'understand', 'discover', 'insight', 'realize'], - 'memory_systems': ['memory', 'storage', 'vector', 'embedding', 'chroma'], - 'coding': ['code', 'function', 'module', 'class', 'import'], - 'problem_solving': ['problem', 'solution', 'approach', 'method', 'way'] + "debugging": ["debug", "error", "fix", "trace", "bug"], + "building": ["create", "build", "implement", "design", "architecture"], + "learning": ["learn", "understand", "discover", "insight", "realize"], + "memory_systems": ["memory", "storage", "vector", "embedding", "chroma"], + "coding": ["code", "function", "module", "class", "import"], + "problem_solving": ["problem", "solution", "approach", "method", "way"], } for theme, indicators in theme_patterns.items(): @@ -228,11 +216,8 @@ def _extract_themes(content: str) -> List[str]: # FRAGMENT FINDING # ============================================================================= -def find_relevant_fragments( - context: Dict[str, Any], - n_results: int = 3, - db_path: Path | None = None -) -> Dict[str, Any]: + +def find_relevant_fragments(context: Dict[str, Any], n_results: int = 3, db_path: Path | None = None) -> Dict[str, Any]: """ Query fragments based on extracted conversation context @@ -246,50 +231,44 @@ def find_relevant_fragments( Returns: Dict with 'success', 'fragments' list with relevance scores """ - keywords = context.get('keywords', []) - mood = context.get('mood', 'neutral') - themes = context.get('themes', []) + keywords = context.get("keywords", []) + mood = context.get("mood", "neutral") + themes = context.get("themes", []) if not keywords and not themes: - return { - 'success': True, - 'fragments': [], - 'message': 'No context to search with' - } + return {"success": True, "fragments": [], "message": "No context to search with"} # Build search query from context search_terms = keywords[:5] + themes[:3] - if mood != 'neutral': + if mood != "neutral": search_terms.append(mood) - query = ' '.join(search_terms) + query = " ".join(search_terms) # Use retriever to find fragments result = retriever.retrieve_fragments( - query=query, - trigger_keywords=keywords[:5] if keywords else None, - n_results=n_results, - db_path=db_path + query=query, trigger_keywords=keywords[:5] if keywords else None, n_results=n_results, db_path=db_path ) - if not result.get('success'): + if not result.get("success"): return result # Filter results by minimum threshold config = load_config() - threshold = config.get('threshold', 0.3) + threshold = config.get("threshold", 0.3) fragments = [ - frag for frag in result.get('results', []) - if frag.get('relevance_score', frag.get('similarity', 0)) >= threshold + frag + for frag in result.get("results", []) + if frag.get("relevance_score", frag.get("similarity", 0)) >= threshold ] return { - 'success': True, - 'fragments': fragments, - 'query_used': query, - 'threshold_applied': threshold, - 'total_before_filter': len(result.get('results', [])) + "success": True, + "fragments": fragments, + "query_used": query, + "threshold_applied": threshold, + "total_before_filter": len(result.get("results", [])), } @@ -297,6 +276,7 @@ def find_relevant_fragments( # FRAGMENT FORMATTING # ============================================================================= + def format_fragment_recall(fragment: Dict[str, Any]) -> str: """ Format a fragment as natural recall text @@ -311,11 +291,11 @@ def format_fragment_recall(fragment: Dict[str, Any]) -> str: Returns: Formatted recall string """ - content = fragment.get('content', '') - metadata = fragment.get('metadata', {}) + content = fragment.get("content", "") + metadata = fragment.get("metadata", {}) # v2 schema: LLM-extracted fragments with summary/insight/type - if metadata.get('schema_version') == 'v2': + if metadata.get("schema_version") == "v2": return _format_v2_recall(content, metadata) # v1 schema: dimension-based fragments (original format) @@ -335,16 +315,16 @@ def _format_v2_recall(content: str, metadata: Dict[str, Any]) -> str: Returns: Formatted v2 recall string """ - summary = metadata.get('summary', content or 'a past experience') - insight = metadata.get('insight', '') - frag_type = metadata.get('type', '') + summary = metadata.get("summary", content or "a past experience") + insight = metadata.get("insight", "") + frag_type = metadata.get("type", "") # Type-based opening TYPE_PREFIXES = { - 'episodic': f"During a session, {summary}", - 'procedural': f"We learned how to: {summary}", - 'semantic': f"An important concept: {summary}", - 'emotional': f"A meaningful moment: {summary}", + "episodic": f"During a session, {summary}", + "procedural": f"We learned how to: {summary}", + "semantic": f"An important concept: {summary}", + "emotional": f"A meaningful moment: {summary}", } recall_text = TYPE_PREFIXES.get(frag_type, f"I remember: {summary}") @@ -354,8 +334,8 @@ def _format_v2_recall(content: str, metadata: Dict[str, Any]) -> str: recall_text = f"{recall_text}. The key insight: {insight}." else: # Ensure trailing period - if not recall_text.endswith('.'): - recall_text += '.' + if not recall_text.endswith("."): + recall_text += "." return recall_text @@ -373,39 +353,39 @@ def _format_v1_recall(content: str, metadata: Dict[str, Any]) -> str: Returns: Formatted v1 recall string """ - emotional = metadata.get('emotional_0', '') - technical = metadata.get('technical_0', '') - learnings = metadata.get('learnings_0', '') + emotional = metadata.get("emotional_0", "") + technical = metadata.get("technical_0", "") + learnings = metadata.get("learnings_0", "") # Build recall phrase recall_parts = [] # Opening - if emotional and 'frustration' in emotional: + if emotional and "frustration" in emotional: recall_parts.append("This reminds me of a conversation where we dealt with a similar frustration") - elif emotional and 'curiosity' in emotional: + elif emotional and "curiosity" in emotional: recall_parts.append("This brings back a curious exploration") - elif emotional and 'excitement' in emotional: + elif emotional and "excitement" in emotional: recall_parts.append("This reminds me of an exciting moment") else: recall_parts.append("This reminds me of a past conversation") # Context if technical: - technical_desc = technical.replace('_', ' ') + technical_desc = technical.replace("_", " ") recall_parts.append(f"involving {technical_desc}") # Pattern if emotional: - emotional_desc = emotional.replace('_', '-') - recall_parts.append(f"The pattern was \"{emotional_desc}\"") + emotional_desc = emotional.replace("_", "-") + recall_parts.append(f'The pattern was "{emotional_desc}"') # Insight if learnings: - learnings_desc = learnings.replace('_', ' ') + learnings_desc = learnings.replace("_", " ") recall_parts.append(f"and the key insight was about {learnings_desc}") - recall_text = '. '.join(recall_parts) + '.' + recall_text = ". ".join(recall_parts) + "." # Add compressed content if available if content and len(content) < 200: @@ -432,8 +412,8 @@ def format_multiple_recalls(fragments: List[Dict[str, Any]]) -> str: recalls = [] for frag in fragments: - metadata = frag.get('metadata', {}) - schema = metadata.get('schema_version', 'v1') + metadata = frag.get("metadata", {}) + schema = metadata.get("schema_version", "v1") recall = format_fragment_recall(frag) recalls.append(f"[{schema}] {recall}") @@ -444,9 +424,9 @@ def format_multiple_recalls(fragments: List[Dict[str, Any]]) -> str: # SURFACING CONTROL # ============================================================================= + def should_surface_fragment( - fragment: Dict[str, Any] | None = None, - config: Dict[str, Any] | None = None + fragment: Dict[str, Any] | None = None, config: Dict[str, Any] | None = None ) -> Tuple[bool, str]: """ Check if a fragment should be surfaced based on rules @@ -469,30 +449,30 @@ def should_surface_fragment( config = load_config() # Check if enabled - if not config.get('enabled', True): + if not config.get("enabled", True): return False, "Hook is disabled" # Check max fragments per session - max_frags = config.get('max_fragments_per_session', 5) - if SESSION_STATE['fragments_surfaced'] >= max_frags: + max_frags = config.get("max_fragments_per_session", 5) + if SESSION_STATE["fragments_surfaced"] >= max_frags: return False, f"Max fragments ({max_frags}) reached for session" # Check messages since last surface - min_messages = config.get('min_messages_between', 10) - if SESSION_STATE['messages_since_last'] < min_messages: + min_messages = config.get("min_messages_between", 10) + if SESSION_STATE["messages_since_last"] < min_messages: return False, f"Only {SESSION_STATE['messages_since_last']}/{min_messages} messages since last surface" # Check cooldown - cooldown = config.get('cooldown_seconds', 300) - elapsed = time.time() - SESSION_STATE['last_surface_time'] + cooldown = config.get("cooldown_seconds", 300) + elapsed = time.time() - SESSION_STATE["last_surface_time"] if elapsed < cooldown: remaining = int(cooldown - elapsed) return False, f"Cooldown active ({remaining}s remaining)" # Check if already surfaced if fragment: - frag_id = fragment.get('id') - if frag_id and frag_id in SESSION_STATE['surfaced_ids']: + frag_id = fragment.get("id") + if frag_id and frag_id in SESSION_STATE["surfaced_ids"]: return False, "Fragment already surfaced this session" return True, "Ready to surface" @@ -507,13 +487,13 @@ def record_surface(fragment: Dict[str, Any]) -> None: Args: fragment: The fragment that was surfaced """ - SESSION_STATE['fragments_surfaced'] += 1 - SESSION_STATE['messages_since_last'] = 0 - SESSION_STATE['last_surface_time'] = time.time() + SESSION_STATE["fragments_surfaced"] += 1 + SESSION_STATE["messages_since_last"] = 0 + SESSION_STATE["last_surface_time"] = time.time() - frag_id = fragment.get('id') + frag_id = fragment.get("id") if frag_id: - SESSION_STATE['surfaced_ids'].add(frag_id) + SESSION_STATE["surfaced_ids"].add(frag_id) def record_message() -> None: @@ -522,7 +502,7 @@ def record_message() -> None: Increments the messages_since_last counter. """ - SESSION_STATE['messages_since_last'] += 1 + SESSION_STATE["messages_since_last"] += 1 def reset_session() -> None: @@ -531,10 +511,10 @@ def reset_session() -> None: Clears all tracking counters and surfaced fragment IDs. """ - SESSION_STATE['fragments_surfaced'] = 0 - SESSION_STATE['messages_since_last'] = 0 - SESSION_STATE['last_surface_time'] = 0 - SESSION_STATE['surfaced_ids'] = set() + SESSION_STATE["fragments_surfaced"] = 0 + SESSION_STATE["messages_since_last"] = 0 + SESSION_STATE["last_surface_time"] = 0 + SESSION_STATE["surfaced_ids"] = set() def get_session_state() -> Dict[str, Any]: @@ -545,10 +525,10 @@ def get_session_state() -> Dict[str, Any]: Dict with session state values """ return { - "fragments_surfaced": SESSION_STATE['fragments_surfaced'], - "messages_since_last": SESSION_STATE['messages_since_last'], - "last_surface_time": SESSION_STATE['last_surface_time'], - "surfaced_count": len(SESSION_STATE['surfaced_ids']) + "fragments_surfaced": SESSION_STATE["fragments_surfaced"], + "messages_since_last": SESSION_STATE["messages_since_last"], + "last_surface_time": SESSION_STATE["last_surface_time"], + "surfaced_count": len(SESSION_STATE["surfaced_ids"]), } @@ -556,10 +536,9 @@ def get_session_state() -> Dict[str, Any]: # MAIN HOOK FUNCTION # ============================================================================= + def process_hook( - messages: List[Dict[str, Any]], - config: Dict[str, Any] | None = None, - db_path: Path | None = None + messages: List[Dict[str, Any]], config: Dict[str, Any] | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Main hook function - process messages and surface relevant fragments @@ -580,45 +559,27 @@ def process_hook( # Check if we should attempt to surface can_surface, reason = should_surface_fragment(config=config) if not can_surface: - return { - 'success': True, - 'surfaced': False, - 'reason': reason - } + return {"success": True, "surfaced": False, "reason": reason} # Extract context from recent messages context = extract_conversation_context(messages) - if not context.get('success'): - return { - 'success': False, - 'error': context.get('error', 'Context extraction failed') - } + if not context.get("success"): + return {"success": False, "error": context.get("error", "Context extraction failed")} # Find relevant fragments result = find_relevant_fragments(context, n_results=1, db_path=db_path) - if not result.get('success'): - return { - 'success': False, - 'error': result.get('error', 'Fragment retrieval failed') - } + if not result.get("success"): + return {"success": False, "error": result.get("error", "Fragment retrieval failed")} - fragments = result.get('fragments', []) + fragments = result.get("fragments", []) if not fragments: - return { - 'success': True, - 'surfaced': False, - 'reason': 'No relevant fragments found above threshold' - } + return {"success": True, "surfaced": False, "reason": "No relevant fragments found above threshold"} # Check the specific fragment fragment = fragments[0] can_surface, reason = should_surface_fragment(fragment=fragment, config=config) if not can_surface: - return { - 'success': True, - 'surfaced': False, - 'reason': reason - } + return {"success": True, "surfaced": False, "reason": reason} # Format the recall recall_text = format_fragment_recall(fragment) @@ -626,16 +587,16 @@ def process_hook( # Record the surface record_surface(fragment) - json_handler.log_operation("symbolic_hook", {"fragment_id": fragment.get('id'), "surfaced": True, "success": True}) + json_handler.log_operation("symbolic_hook", {"fragment_id": fragment.get("id"), "surfaced": True, "success": True}) return { - 'success': True, - 'surfaced': True, - 'recall': recall_text, - 'fragment_id': fragment.get('id'), - 'relevance_score': fragment.get('relevance_score', fragment.get('similarity', 0)), - 'context_used': { - 'keywords': context.get('keywords', []), - 'mood': context.get('mood'), - 'themes': context.get('themes', []) - } + "success": True, + "surfaced": True, + "recall": recall_text, + "fragment_id": fragment.get("id"), + "relevance_score": fragment.get("relevance_score", fragment.get("similarity", 0)), + "context_used": { + "keywords": context.get("keywords", []), + "mood": context.get("mood"), + "themes": context.get("themes", []), + }, } diff --git a/src/aipass/memory/apps/handlers/symbolic/retriever.py b/src/aipass/memory/apps/handlers/symbolic/retriever.py index c2a356c0..6b67acee 100644 --- a/src/aipass/memory/apps/handlers/symbolic/retriever.py +++ b/src/aipass/memory/apps/handlers/symbolic/retriever.py @@ -44,11 +44,8 @@ DEFAULT_N_RESULTS = 5 # VECTOR SIMILARITY SEARCH # ============================================================================= -def search_by_vector( - query: str, - n_results: int = DEFAULT_N_RESULTS, - db_path: Path | None = None -) -> Dict[str, Any]: + +def search_by_vector(query: str, n_results: int = DEFAULT_N_RESULTS, db_path: Path | None = None) -> Dict[str, Any]: """ Search fragments by vector similarity @@ -64,28 +61,19 @@ def search_by_vector( Dict with 'success', 'results' list containing fragments with scores """ if not query: - return { - 'success': False, - 'error': 'Query text required' - } + return {"success": False, "error": "Query text required"} # Encode query to embedding embed_result = embedder.encode_batch([query]) - if not embed_result.get('success'): - return { - 'success': False, - 'error': f"Embedding failed: {embed_result.get('error', 'Unknown error')}" - } + if not embed_result.get("success"): + return {"success": False, "error": f"Embedding failed: {embed_result.get('error', 'Unknown error')}"} - embeddings = embed_result.get('embeddings', []) + embeddings = embed_result.get("embeddings", []) if not embeddings: - return { - 'success': False, - 'error': 'No embedding generated' - } + return {"success": False, "error": "No embedding generated"} query_vec = embeddings[0] - if hasattr(query_vec, 'tolist'): + if hasattr(query_vec, "tolist"): query_vec = query_vec.tolist() try: @@ -93,50 +81,31 @@ def search_by_vector( # Get collection try: - collection = client.get_collection( - COLLECTION_NAME, - embedding_function=None - ) + collection = client.get_collection(COLLECTION_NAME, embedding_function=None) except Exception as e: logger.warning(f"[retriever] Collection '{COLLECTION_NAME}' not found for vector search: {e}") - return { - 'success': True, - 'results': [], - 'message': f'Collection {COLLECTION_NAME} not found' - } + return {"success": True, "results": [], "message": f"Collection {COLLECTION_NAME} not found"} # Query by vector similarity - results = collection.query( - query_embeddings=[query_vec], - n_results=n_results - ) + results = collection.query(query_embeddings=[query_vec], n_results=n_results) # Format results formatted = _format_query_results(results) - return { - 'success': True, - 'results': formatted, - 'total_results': len(formatted), - 'search_type': 'vector' - } + return {"success": True, "results": formatted, "total_results": len(formatted), "search_type": "vector"} except Exception as e: logger.error(f"[retriever] Vector search failed: {e}") - return { - 'success': False, - 'error': f"Vector search failed: {e}" - } + return {"success": False, "error": f"Vector search failed: {e}"} # ============================================================================= # DIMENSION FILTERING # ============================================================================= + def search_by_dimensions( - dimension_filters: Dict[str, str], - n_results: int = DEFAULT_N_RESULTS, - db_path: Path | None = None + dimension_filters: Dict[str, str], n_results: int = DEFAULT_N_RESULTS, db_path: Path | None = None ) -> Dict[str, Any]: """ Search fragments by symbolic dimension filters @@ -153,27 +122,17 @@ def search_by_dimensions( Dict with 'success', 'results' list of matching fragments """ if not dimension_filters: - return { - 'success': False, - 'error': 'Dimension filters required' - } + return {"success": False, "error": "Dimension filters required"} try: client = get_chroma_client(db_path) # Get collection try: - collection = client.get_collection( - COLLECTION_NAME, - embedding_function=None - ) + collection = client.get_collection(COLLECTION_NAME, embedding_function=None) except Exception as e: logger.warning(f"[retriever] Collection '{COLLECTION_NAME}' not found for dimension search: {e}") - return { - 'success': True, - 'results': [], - 'message': f'Collection {COLLECTION_NAME} not found' - } + return {"success": True, "results": [], "message": f"Collection {COLLECTION_NAME} not found"} # Build where clause for filtering # ChromaDB where clause: {"$and": [{"key": {"$eq": "value"}}, ...]} @@ -187,39 +146,31 @@ def search_by_dimensions( where_clause = {"$and": where_conditions} # Query with filter (no embedding, just metadata filter) - results = collection.get( - where=where_clause, - limit=n_results, - include=['documents', 'metadatas'] - ) + results = collection.get(where=where_clause, limit=n_results, include=["documents", "metadatas"]) # Format results (get() returns different structure than query()) formatted = _format_get_results(results) return { - 'success': True, - 'results': formatted, - 'total_results': len(formatted), - 'search_type': 'dimension_filter', - 'filters_applied': dimension_filters + "success": True, + "results": formatted, + "total_results": len(formatted), + "search_type": "dimension_filter", + "filters_applied": dimension_filters, } except Exception as e: logger.error(f"[retriever] Dimension search failed: {e}") - return { - 'success': False, - 'error': f"Dimension search failed: {e}" - } + return {"success": False, "error": f"Dimension search failed: {e}"} # ============================================================================= # TRIGGER KEYWORD SEARCH # ============================================================================= + def search_by_triggers( - keywords: List[str], - n_results: int = DEFAULT_N_RESULTS, - db_path: Path | None = None + keywords: List[str], n_results: int = DEFAULT_N_RESULTS, db_path: Path | None = None ) -> Dict[str, Any]: """ Search fragments by trigger keywords @@ -237,45 +188,33 @@ def search_by_triggers( Dict with 'success', 'results' list of matching fragments """ if not keywords: - return { - 'success': False, - 'error': 'Keywords required' - } + return {"success": False, "error": "Keywords required"} try: client = get_chroma_client(db_path) # Get collection try: - collection = client.get_collection( - COLLECTION_NAME, - embedding_function=None - ) + collection = client.get_collection(COLLECTION_NAME, embedding_function=None) except Exception as e: logger.warning(f"[retriever] Collection '{COLLECTION_NAME}' not found for trigger search: {e}") - return { - 'success': True, - 'results': [], - 'message': f'Collection {COLLECTION_NAME} not found' - } + return {"success": True, "results": [], "message": f"Collection {COLLECTION_NAME} not found"} # Get all fragments and filter in Python # ChromaDB metadata where doesn't support $contains for strings # Triggers are stored as comma-separated strings in metadata - all_results = collection.get( - include=['documents', 'metadatas'] - ) + all_results = collection.get(include=["documents", "metadatas"]) # Filter by trigger keywords matching_indices = [] keywords_lower = [k.lower() for k in keywords] - if all_results.get('metadatas'): - for i, meta in enumerate(all_results['metadatas']): - triggers_str = meta.get('triggers', '') + if all_results.get("metadatas"): + for i, meta in enumerate(all_results["metadatas"]): + triggers_str = meta.get("triggers", "") if triggers_str: # Triggers are comma-separated - fragment_triggers = [t.strip().lower() for t in triggers_str.split(',')] + fragment_triggers = [t.strip().lower() for t in triggers_str.split(",")] # Check if any keyword matches any trigger for kw in keywords_lower: if any(kw in trigger for trigger in fragment_triggers): @@ -284,46 +223,48 @@ def search_by_triggers( # Build filtered results filtered_results = { - 'ids': [all_results['ids'][i] for i in matching_indices] if all_results.get('ids') else [], - 'documents': [all_results['documents'][i] for i in matching_indices] if all_results.get('documents') else [], - 'metadatas': [all_results['metadatas'][i] for i in matching_indices] if all_results.get('metadatas') else [] + "ids": [all_results["ids"][i] for i in matching_indices] if all_results.get("ids") else [], + "documents": [all_results["documents"][i] for i in matching_indices] + if all_results.get("documents") + else [], + "metadatas": [all_results["metadatas"][i] for i in matching_indices] + if all_results.get("metadatas") + else [], } # Limit results - if len(filtered_results['ids']) > n_results: - filtered_results['ids'] = filtered_results['ids'][:n_results] - filtered_results['documents'] = filtered_results['documents'][:n_results] - filtered_results['metadatas'] = filtered_results['metadatas'][:n_results] + if len(filtered_results["ids"]) > n_results: + filtered_results["ids"] = filtered_results["ids"][:n_results] + filtered_results["documents"] = filtered_results["documents"][:n_results] + filtered_results["metadatas"] = filtered_results["metadatas"][:n_results] # Format results formatted = _format_get_results(filtered_results) return { - 'success': True, - 'results': formatted, - 'total_results': len(formatted), - 'search_type': 'trigger_keywords', - 'keywords_searched': keywords + "success": True, + "results": formatted, + "total_results": len(formatted), + "search_type": "trigger_keywords", + "keywords_searched": keywords, } except Exception as e: logger.error(f"[retriever] Trigger search failed: {e}") - return { - 'success': False, - 'error': f"Trigger search failed: {e}" - } + return {"success": False, "error": f"Trigger search failed: {e}"} # ============================================================================= # COMBINED RETRIEVAL # ============================================================================= + def retrieve_fragments( query: str | None = None, dimension_filters: Dict[str, str] | None = None, trigger_keywords: List[str] | None = None, n_results: int = DEFAULT_N_RESULTS, - db_path: Path | None = None + db_path: Path | None = None, ) -> Dict[str, Any]: """ Main retrieval function combining all search methods @@ -349,8 +290,8 @@ def retrieve_fragments( """ if not query and not dimension_filters and not trigger_keywords: return { - 'success': False, - 'error': 'At least one search method required (query, dimension_filters, or trigger_keywords)' + "success": False, + "error": "At least one search method required (query, dimension_filters, or trigger_keywords)", } all_results = [] @@ -359,36 +300,36 @@ def retrieve_fragments( # 1. Vector similarity search (if query provided) if query: vector_result = search_by_vector(query, n_results=n_results * 2, db_path=db_path) - if vector_result.get('success') and vector_result.get('results'): - search_methods_used.append('vector') - for result in vector_result['results']: - result['_source'] = 'vector' + if vector_result.get("success") and vector_result.get("results"): + search_methods_used.append("vector") + for result in vector_result["results"]: + result["_source"] = "vector" all_results.append(result) # 2. Dimension filter search (if filters provided) if dimension_filters: dim_result = search_by_dimensions(dimension_filters, n_results=n_results * 2, db_path=db_path) - if dim_result.get('success') and dim_result.get('results'): - search_methods_used.append('dimension') - for result in dim_result['results']: - result['_source'] = 'dimension' + if dim_result.get("success") and dim_result.get("results"): + search_methods_used.append("dimension") + for result in dim_result["results"]: + result["_source"] = "dimension" all_results.append(result) # 3. Trigger keyword search (if keywords provided) if trigger_keywords: trigger_result = search_by_triggers(trigger_keywords, n_results=n_results * 2, db_path=db_path) - if trigger_result.get('success') and trigger_result.get('results'): - search_methods_used.append('trigger') - for result in trigger_result['results']: - result['_source'] = 'trigger' + if trigger_result.get("success") and trigger_result.get("results"): + search_methods_used.append("trigger") + for result in trigger_result["results"]: + result["_source"] = "trigger" all_results.append(result) if not all_results: return { - 'success': True, - 'results': [], - 'message': 'No matching fragments found', - 'search_methods': search_methods_used + "success": True, + "results": [], + "message": "No matching fragments found", + "search_methods": search_methods_used, } # Merge and deduplicate results @@ -400,13 +341,15 @@ def retrieve_fragments( # Return top n_results final_results = ranked[:n_results] - json_handler.log_operation("symbolic_retrieve", {"results": len(final_results), "methods": search_methods_used, "success": True}) + json_handler.log_operation( + "symbolic_retrieve", {"results": len(final_results), "methods": search_methods_used, "success": True} + ) return { - 'success': True, - 'results': final_results, - 'total_results': len(final_results), - 'search_methods': search_methods_used, - 'total_before_merge': len(all_results) + "success": True, + "results": final_results, + "total_results": len(final_results), + "search_methods": search_methods_used, + "total_before_merge": len(all_results), } @@ -414,6 +357,7 @@ def retrieve_fragments( # HELPER FUNCTIONS # ============================================================================= + def _format_query_results(results: Dict) -> List[Dict[str, Any]]: """ Format ChromaDB query() results into standard fragment format @@ -423,25 +367,25 @@ def _format_query_results(results: Dict) -> List[Dict[str, Any]]: """ formatted = [] - if not results.get('documents') or not results['documents'][0]: + if not results.get("documents") or not results["documents"][0]: return formatted - for i, doc in enumerate(results['documents'][0]): + for i, doc in enumerate(results["documents"][0]): frag = { - 'id': results['ids'][0][i] if results.get('ids') else None, - 'content': doc, - 'metadata': results['metadatas'][0][i] if results.get('metadatas') else {}, - 'distance': results['distances'][0][i] if results.get('distances') else None + "id": results["ids"][0][i] if results.get("ids") else None, + "content": doc, + "metadata": results["metadatas"][0][i] if results.get("metadatas") else {}, + "distance": results["distances"][0][i] if results.get("distances") else None, } # Calculate similarity score (cosine distance: 0=identical, 2=opposite) - if frag['distance'] is not None: - frag['similarity'] = max(0, 1 - frag['distance']) + if frag["distance"] is not None: + frag["similarity"] = max(0, 1 - frag["distance"]) else: - frag['similarity'] = 0 + frag["similarity"] = 0 # Assign relevance tier - frag['relevance_tier'] = _compute_relevance_tier(frag['similarity']) + frag["relevance_tier"] = _compute_relevance_tier(frag["similarity"]) formatted.append(frag) @@ -457,18 +401,18 @@ def _format_get_results(results: Dict) -> List[Dict[str, Any]]: """ formatted = [] - if not results.get('documents'): + if not results.get("documents"): return formatted - for i, doc in enumerate(results['documents']): + for i, doc in enumerate(results["documents"]): similarity = 0.5 # Default score for filter-only results frag = { - 'id': results['ids'][i] if results.get('ids') else None, - 'content': doc, - 'metadata': results['metadatas'][i] if results.get('metadatas') else {}, - 'distance': None, - 'similarity': similarity, - 'relevance_tier': _compute_relevance_tier(similarity) + "id": results["ids"][i] if results.get("ids") else None, + "content": doc, + "metadata": results["metadatas"][i] if results.get("metadatas") else {}, + "distance": None, + "similarity": similarity, + "relevance_tier": _compute_relevance_tier(similarity), } formatted.append(frag) @@ -492,12 +436,12 @@ def _compute_relevance_tier(similarity: float) -> str: Relevance tier string """ if similarity >= 0.65: - return 'strong' + return "strong" if similarity >= 0.45: - return 'moderate' + return "moderate" if similarity >= 0.30: - return 'serendipity' - return 'weak' + return "serendipity" + return "weak" def _merge_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -509,21 +453,21 @@ def _merge_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: merged = {} for result in results: - frag_id = result.get('id') + frag_id = result.get("id") if not frag_id: continue if frag_id not in merged: merged[frag_id] = result.copy() - merged[frag_id]['_sources'] = [result.get('_source', 'unknown')] + merged[frag_id]["_sources"] = [result.get("_source", "unknown")] else: # Fragment found by multiple methods - combine scores existing = merged[frag_id] - existing['_sources'].append(result.get('_source', 'unknown')) + existing["_sources"].append(result.get("_source", "unknown")) # Take best similarity if both have scores - if result.get('similarity', 0) > existing.get('similarity', 0): - existing['similarity'] = result['similarity'] + if result.get("similarity", 0) > existing.get("similarity", 0): + existing["similarity"] = result["similarity"] return list(merged.values()) @@ -537,19 +481,19 @@ def _rank_results(results: List[Dict[str, Any]], _methods_used: List[str]) -> Li - Bonus: +0.1 for each additional method that found the fragment """ for result in results: - sources = result.get('_sources', []) - base_score = result.get('similarity', 0) + sources = result.get("_sources", []) + base_score = result.get("similarity", 0) # Bonus for being found by multiple methods multi_method_bonus = (len(sources) - 1) * 0.1 - result['relevance_score'] = min(1.0, base_score + multi_method_bonus) + result["relevance_score"] = min(1.0, base_score + multi_method_bonus) # Clean up internal fields - if '_source' in result: - del result['_source'] + if "_source" in result: + del result["_source"] # Sort by relevance score descending - results.sort(key=lambda x: x.get('relevance_score', 0), reverse=True) + results.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) return results diff --git a/src/aipass/memory/apps/handlers/symbolic/storage.py b/src/aipass/memory/apps/handlers/symbolic/storage.py index 95c4b22e..e1b1d7aa 100644 --- a/src/aipass/memory/apps/handlers/symbolic/storage.py +++ b/src/aipass/memory/apps/handlers/symbolic/storage.py @@ -46,10 +46,9 @@ COLLECTION_NAME = "symbolic_fragments" # FRAGMENT CREATION # ============================================================================= + def create_fragment( - analysis: Dict[str, Any], - content: str | None = None, - source_branch: str | None = None + analysis: Dict[str, Any], content: str | None = None, source_branch: str | None = None ) -> Dict[str, Any]: """ Create a fragment from conversation analysis @@ -66,13 +65,10 @@ def create_fragment( Dict with 'success', 'fragment' containing storable fragment """ if not analysis: - return { - 'success': False, - 'error': 'No analysis provided' - } + return {"success": False, "error": "No analysis provided"} - dimensions = analysis.get('dimensions', {}) - metadata = analysis.get('metadata', {}) + dimensions = analysis.get("dimensions", {}) + metadata = analysis.get("metadata", {}) # Generate unique ID with UUID suffix for uniqueness timestamp = datetime.now() @@ -86,30 +82,27 @@ def create_fragment( # Build fragment structure frag_data = { - 'id': frag_id, - 'content': content, - 'dimensions': { - 'technical': dimensions.get('technical', []), - 'emotional': dimensions.get('emotional', []), - 'collaboration': dimensions.get('collaboration', []), - 'learnings': dimensions.get('learnings', []), - 'triggers': dimensions.get('triggers', []) + "id": frag_id, + "content": content, + "dimensions": { + "technical": dimensions.get("technical", []), + "emotional": dimensions.get("emotional", []), + "collaboration": dimensions.get("collaboration", []), + "learnings": dimensions.get("learnings", []), + "triggers": dimensions.get("triggers", []), + }, + "metadata": { + "timestamp": metadata.get("timestamp", timestamp.isoformat()), + "message_count": analysis.get("message_count", 0), + "depth": metadata.get("depth", "unknown"), + "total_words": metadata.get("total_words", 0), }, - 'metadata': { - 'timestamp': metadata.get('timestamp', timestamp.isoformat()), - 'message_count': analysis.get('message_count', 0), - 'depth': metadata.get('depth', 'unknown'), - 'total_words': metadata.get('total_words', 0) - } } if source_branch: - frag_data['metadata']['source_branch'] = source_branch + frag_data["metadata"]["source_branch"] = source_branch - return { - 'success': True, - 'fragment': frag_data - } + return {"success": True, "fragment": frag_data} def _generate_essence(dimensions: Dict[str, Any], metadata: Dict[str, Any]) -> str: @@ -129,33 +122,33 @@ def _generate_essence(dimensions: Dict[str, Any], metadata: Dict[str, Any]) -> s parts = [] # Technical flow - technical = dimensions.get('technical', []) - if technical and 'no_conversation' not in technical: + technical = dimensions.get("technical", []) + if technical and "no_conversation" not in technical: parts.append(f"Technical: {', '.join(technical)}") # Emotional arc - emotional = dimensions.get('emotional', []) - if emotional and 'neutral' not in emotional: + emotional = dimensions.get("emotional", []) + if emotional and "neutral" not in emotional: parts.append(f"Emotional: {', '.join(emotional)}") # Collaboration patterns - collaboration = dimensions.get('collaboration', []) - if collaboration and 'no_interaction' not in collaboration: + collaboration = dimensions.get("collaboration", []) + if collaboration and "no_interaction" not in collaboration: parts.append(f"Collaboration: {', '.join(collaboration)}") # Key learnings - learnings = dimensions.get('learnings', []) - if learnings and 'no_insights' not in learnings: + learnings = dimensions.get("learnings", []) + if learnings and "no_insights" not in learnings: parts.append(f"Learnings: {', '.join(learnings)}") # Context triggers - triggers = dimensions.get('triggers', []) + triggers = dimensions.get("triggers", []) if triggers: parts.append(f"Context: {', '.join(triggers[:5])}") # Add depth indicator - depth = metadata.get('depth', 'unknown') - if depth != 'unknown': + depth = metadata.get("depth", "unknown") + if depth != "unknown": parts.append(f"Depth: {depth}") if not parts: @@ -168,6 +161,7 @@ def _generate_essence(dimensions: Dict[str, Any], metadata: Dict[str, Any]) -> s # METADATA FLATTENING # ============================================================================= + def flatten_dimensions(fragment: Dict[str, Any]) -> Dict[str, Any]: """ Flatten fragment dimensions for ChromaDB metadata storage @@ -182,49 +176,41 @@ def flatten_dimensions(fragment: Dict[str, Any]) -> Dict[str, Any]: Dict with 'success', 'metadata' containing flattened metadata """ if not fragment: - return { - 'success': False, - 'error': 'No fragment provided' - } + return {"success": False, "error": "No fragment provided"} - dimensions = fragment.get('dimensions', {}) - metadata = fragment.get('metadata', {}) + dimensions = fragment.get("dimensions", {}) + metadata = fragment.get("metadata", {}) flat = {} # Flatten each dimension list to indexed keys for dim_name, dim_values in dimensions.items(): - if dim_name == 'triggers': + if dim_name == "triggers": # Store triggers as comma-separated string for contains search - flat['triggers'] = ','.join(dim_values) if dim_values else '' + flat["triggers"] = ",".join(dim_values) if dim_values else "" else: # Store other dimensions as indexed keys for i, value in enumerate(dim_values[:5]): # Limit to 5 per dimension - flat[f'{dim_name}_{i}'] = value + flat[f"{dim_name}_{i}"] = value # Add metadata fields - flat['timestamp'] = metadata.get('timestamp', '') - flat['message_count'] = metadata.get('message_count', 0) - flat['depth'] = metadata.get('depth', 'unknown') - flat['total_words'] = metadata.get('total_words', 0) + flat["timestamp"] = metadata.get("timestamp", "") + flat["message_count"] = metadata.get("message_count", 0) + flat["depth"] = metadata.get("depth", "unknown") + flat["total_words"] = metadata.get("total_words", 0) - if 'source_branch' in metadata: - flat['source_branch'] = metadata['source_branch'] + if "source_branch" in metadata: + flat["source_branch"] = metadata["source_branch"] - return { - 'success': True, - 'metadata': flat - } + return {"success": True, "metadata": flat} # ============================================================================= # STORAGE FUNCTIONS # ============================================================================= -def store_fragment( - fragment: Dict[str, Any], - db_path: Path | None = None -) -> Dict[str, Any]: + +def store_fragment(fragment: Dict[str, Any], db_path: Path | None = None) -> Dict[str, Any]: """ Store a single fragment in ChromaDB @@ -239,87 +225,57 @@ def store_fragment( Dict with 'success', storage details """ if not fragment: - return { - 'success': False, - 'error': 'No fragment provided' - } + return {"success": False, "error": "No fragment provided"} # Import shared client from aipass.memory.apps.handlers.symbolic.chroma_client import get_chroma_client - frag_content = fragment.get('content', '') - frag_id = fragment.get('id', '') + frag_content = fragment.get("content", "") + frag_id = fragment.get("id", "") if not frag_content or not frag_id: - return { - 'success': False, - 'error': 'Fragment missing content or id' - } + return {"success": False, "error": "Fragment missing content or id"} # Generate embedding embed_result = embedder.encode_batch([frag_content]) - if not embed_result.get('success'): - return { - 'success': False, - 'error': f"Embedding failed: {embed_result.get('error', 'Unknown error')}" - } + if not embed_result.get("success"): + return {"success": False, "error": f"Embedding failed: {embed_result.get('error', 'Unknown error')}"} - embed_vectors = embed_result.get('embeddings', []) + embed_vectors = embed_result.get("embeddings", []) if not embed_vectors: - return { - 'success': False, - 'error': 'No embedding generated' - } + return {"success": False, "error": "No embedding generated"} embed_vec = embed_vectors[0] - if hasattr(embed_vec, 'tolist'): + if hasattr(embed_vec, "tolist"): embed_vec = embed_vec.tolist() # Flatten dimensions for metadata flat_result = flatten_dimensions(fragment) - if not flat_result.get('success'): + if not flat_result.get("success"): return flat_result - flat_meta = flat_result['metadata'] + flat_meta = flat_result["metadata"] try: client = get_chroma_client(db_path) collection = client.get_or_create_collection( - name=COLLECTION_NAME, - metadata={"hnsw:space": "cosine"}, - embedding_function=None + name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}, embedding_function=None ) # Upsert fragment (idempotent - safe for re-runs) - collection.upsert( - ids=[frag_id], - embeddings=[embed_vec], - documents=[frag_content], - metadatas=[flat_meta] - ) + collection.upsert(ids=[frag_id], embeddings=[embed_vec], documents=[frag_content], metadatas=[flat_meta]) total = collection.count() json_handler.log_operation("symbolic_store_fragment", {"fragment_id": frag_id, "total": total, "success": True}) - return { - 'success': True, - 'fragment_id': frag_id, - 'collection': COLLECTION_NAME, - 'total_fragments': total - } + return {"success": True, "fragment_id": frag_id, "collection": COLLECTION_NAME, "total_fragments": total} except Exception as e: logger.error(f"[storage] Fragment storage failed: {e}") - return { - 'success': False, - 'error': f"Storage failed: {e}" - } + return {"success": False, "error": f"Storage failed: {e}"} -def store_fragments_batch( - fragments: List[Dict[str, Any]], - db_path: Path | None = None -) -> Dict[str, Any]: +def store_fragments_batch(fragments: List[Dict[str, Any]], db_path: Path | None = None) -> Dict[str, Any]: """ Store multiple fragments in ChromaDB in batch @@ -334,43 +290,30 @@ def store_fragments_batch( Dict with 'success', batch storage details """ if not fragments: - return { - 'success': True, - 'message': 'No fragments to store', - 'stored': 0 - } + return {"success": True, "message": "No fragments to store", "stored": 0} # Extract content for batch embedding content_list = [] valid_frags = [] for frag in fragments: - frag_content = frag.get('content', '') - frag_id = frag.get('id', '') + frag_content = frag.get("content", "") + frag_id = frag.get("id", "") if frag_content and frag_id: content_list.append(frag_content) valid_frags.append(frag) if not valid_frags: - return { - 'success': False, - 'error': 'No valid fragments to store' - } + return {"success": False, "error": "No valid fragments to store"} # Batch embedding embed_result = embedder.encode_batch(content_list) - if not embed_result.get('success'): - return { - 'success': False, - 'error': f"Batch embedding failed: {embed_result.get('error', 'Unknown error')}" - } + if not embed_result.get("success"): + return {"success": False, "error": f"Batch embedding failed: {embed_result.get('error', 'Unknown error')}"} - embed_vectors = embed_result.get('embeddings', []) + embed_vectors = embed_result.get("embeddings", []) if len(embed_vectors) != len(valid_frags): - return { - 'success': False, - 'error': 'Embedding count mismatch' - } + return {"success": False, "error": "Embedding count mismatch"} # Prepare batch data batch_ids = [] @@ -380,17 +323,17 @@ def store_fragments_batch( for i, frag in enumerate(valid_frags): vec = embed_vectors[i] - if hasattr(vec, 'tolist'): + if hasattr(vec, "tolist"): vec = vec.tolist() flat_result = flatten_dimensions(frag) - if not flat_result.get('success'): + if not flat_result.get("success"): continue - batch_ids.append(frag['id']) + batch_ids.append(frag["id"]) batch_embeddings.append(vec) - batch_documents.append(frag['content']) - batch_metadatas.append(flat_result['metadata']) + batch_documents.append(frag["content"]) + batch_metadatas.append(flat_result["metadata"]) # Import shared client from aipass.memory.apps.handlers.symbolic.chroma_client import get_chroma_client @@ -399,44 +342,30 @@ def store_fragments_batch( client = get_chroma_client(db_path) collection = client.get_or_create_collection( - name=COLLECTION_NAME, - metadata={"hnsw:space": "cosine"}, - embedding_function=None + name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}, embedding_function=None ) # Batch upsert (idempotent - safe for re-runs) collection.upsert( - ids=batch_ids, - embeddings=batch_embeddings, - documents=batch_documents, - metadatas=batch_metadatas + ids=batch_ids, embeddings=batch_embeddings, documents=batch_documents, metadatas=batch_metadatas ) total = collection.count() json_handler.log_operation("symbolic_store_batch", {"stored": len(batch_ids), "total": total, "success": True}) - return { - 'success': True, - 'stored': len(batch_ids), - 'collection': COLLECTION_NAME, - 'total_fragments': total - } + return {"success": True, "stored": len(batch_ids), "collection": COLLECTION_NAME, "total_fragments": total} except Exception as e: logger.error(f"[storage] Batch fragment storage failed: {e}") - return { - 'success': False, - 'error': f"Batch storage failed: {e}" - } + return {"success": False, "error": f"Batch storage failed: {e}"} # ============================================================================= # v2 LLM FRAGMENT STORAGE # ============================================================================= + def store_llm_fragment( - fragment: Dict[str, Any], - source_branch: str | None = None, - db_path: Path | None = None + fragment: Dict[str, Any], source_branch: str | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Store a single LLM-extracted fragment in ChromaDB @@ -455,19 +384,13 @@ def store_llm_fragment( Dict with 'success', 'fragment_id', 'collection', 'total_fragments' """ if not fragment: - return { - 'success': False, - 'error': 'No fragment provided' - } + return {"success": False, "error": "No fragment provided"} - summary = fragment.get('summary', '') - insight = fragment.get('insight', '') + summary = fragment.get("summary", "") + insight = fragment.get("insight", "") if not summary: - return { - 'success': False, - 'error': 'Fragment missing summary' - } + return {"success": False, "error": "Fragment missing summary"} # Import shared client from aipass.memory.apps.handlers.symbolic.chroma_client import get_chroma_client @@ -482,78 +405,57 @@ def store_llm_fragment( doc_text = f"{summary}. {insight}" if insight else summary # Build flat metadata for ChromaDB - triggers_list = fragment.get('triggers', []) + triggers_list = fragment.get("triggers", []) flat_meta: Dict[str, Any] = { - 'summary': summary, - 'insight': insight, - 'type': fragment.get('type', ''), - 'emotional_tone': fragment.get('emotional_tone', ''), - 'technical_domain': fragment.get('technical_domain', ''), - 'triggers': ','.join(triggers_list) if triggers_list else '', - 'timestamp': timestamp.isoformat(), - 'schema_version': 'v2' + "summary": summary, + "insight": insight, + "type": fragment.get("type", ""), + "emotional_tone": fragment.get("emotional_tone", ""), + "technical_domain": fragment.get("technical_domain", ""), + "triggers": ",".join(triggers_list) if triggers_list else "", + "timestamp": timestamp.isoformat(), + "schema_version": "v2", } if source_branch: - flat_meta['source_branch'] = source_branch + flat_meta["source_branch"] = source_branch # Generate embedding embed_result = embedder.encode_batch([doc_text]) - if not embed_result.get('success'): - return { - 'success': False, - 'error': f"Embedding failed: {embed_result.get('error', 'Unknown error')}" - } + if not embed_result.get("success"): + return {"success": False, "error": f"Embedding failed: {embed_result.get('error', 'Unknown error')}"} - embed_vectors = embed_result.get('embeddings', []) + embed_vectors = embed_result.get("embeddings", []) if not embed_vectors: - return { - 'success': False, - 'error': 'No embedding generated' - } + return {"success": False, "error": "No embedding generated"} embed_vec = embed_vectors[0] - if hasattr(embed_vec, 'tolist'): + if hasattr(embed_vec, "tolist"): embed_vec = embed_vec.tolist() try: client = get_chroma_client(db_path) collection = client.get_or_create_collection( - name=COLLECTION_NAME, - metadata={"hnsw:space": "cosine"}, - embedding_function=None + name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}, embedding_function=None ) # Upsert fragment (idempotent - safe for re-runs) - collection.upsert( - ids=[frag_id], - embeddings=[embed_vec], - documents=[doc_text], - metadatas=[flat_meta] - ) + collection.upsert(ids=[frag_id], embeddings=[embed_vec], documents=[doc_text], metadatas=[flat_meta]) total = collection.count() - json_handler.log_operation("symbolic_store_llm_fragment", {"fragment_id": frag_id, "total": total, "success": True}) - return { - 'success': True, - 'fragment_id': frag_id, - 'collection': COLLECTION_NAME, - 'total_fragments': total - } + json_handler.log_operation( + "symbolic_store_llm_fragment", {"fragment_id": frag_id, "total": total, "success": True} + ) + return {"success": True, "fragment_id": frag_id, "collection": COLLECTION_NAME, "total_fragments": total} except Exception as e: logger.error(f"[storage] LLM fragment storage failed: {e}") - return { - 'success': False, - 'error': f"LLM fragment storage failed: {e}" - } + return {"success": False, "error": f"LLM fragment storage failed: {e}"} def store_llm_fragments_batch( - fragments: List[Dict[str, Any]], - source_branch: str | None = None, - db_path: Path | None = None + fragments: List[Dict[str, Any]], source_branch: str | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Store multiple LLM-extracted fragments in ChromaDB in batch @@ -570,11 +472,7 @@ def store_llm_fragments_batch( Dict with 'success', 'stored' count, 'collection', 'total_fragments' """ if not fragments: - return { - 'success': True, - 'message': 'No fragments to store', - 'stored': 0 - } + return {"success": True, "message": "No fragments to store", "stored": 0} # Build document texts and metadata for valid fragments timestamp = datetime.now() @@ -583,11 +481,11 @@ def store_llm_fragments_batch( frag_metas = [] for frag in fragments: - summary = frag.get('summary', '') + summary = frag.get("summary", "") if not summary: continue - insight = frag.get('insight', '') + insight = frag.get("insight", "") doc_text = f"{summary}. {insight}" if insight else summary # Generate unique ID (offset microseconds to avoid collisions) @@ -595,50 +493,41 @@ def store_llm_fragments_batch( unique_suffix = uuid.uuid4().hex[:8] frag_id = f"frag_{ts_str}_{unique_suffix}" - triggers_list = frag.get('triggers', []) + triggers_list = frag.get("triggers", []) flat_meta: Dict[str, Any] = { - 'summary': summary, - 'insight': insight, - 'type': frag.get('type', ''), - 'emotional_tone': frag.get('emotional_tone', ''), - 'technical_domain': frag.get('technical_domain', ''), - 'triggers': ','.join(triggers_list) if triggers_list else '', - 'timestamp': timestamp.isoformat(), - 'schema_version': 'v2' + "summary": summary, + "insight": insight, + "type": frag.get("type", ""), + "emotional_tone": frag.get("emotional_tone", ""), + "technical_domain": frag.get("technical_domain", ""), + "triggers": ",".join(triggers_list) if triggers_list else "", + "timestamp": timestamp.isoformat(), + "schema_version": "v2", } if source_branch: - flat_meta['source_branch'] = source_branch + flat_meta["source_branch"] = source_branch doc_texts.append(doc_text) frag_ids.append(frag_id) frag_metas.append(flat_meta) if not doc_texts: - return { - 'success': False, - 'error': 'No valid LLM fragments to store' - } + return {"success": False, "error": "No valid LLM fragments to store"} # Batch embedding embed_result = embedder.encode_batch(doc_texts) - if not embed_result.get('success'): - return { - 'success': False, - 'error': f"Batch embedding failed: {embed_result.get('error', 'Unknown error')}" - } + if not embed_result.get("success"): + return {"success": False, "error": f"Batch embedding failed: {embed_result.get('error', 'Unknown error')}"} - embed_vectors = embed_result.get('embeddings', []) + embed_vectors = embed_result.get("embeddings", []) if len(embed_vectors) != len(doc_texts): - return { - 'success': False, - 'error': 'Embedding count mismatch' - } + return {"success": False, "error": "Embedding count mismatch"} # Convert embeddings to lists batch_embeddings = [] for vec in embed_vectors: - if hasattr(vec, 'tolist'): + if hasattr(vec, "tolist"): vec = vec.tolist() batch_embeddings.append(vec) @@ -649,40 +538,24 @@ def store_llm_fragments_batch( client = get_chroma_client(db_path) collection = client.get_or_create_collection( - name=COLLECTION_NAME, - metadata={"hnsw:space": "cosine"}, - embedding_function=None + name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}, embedding_function=None ) # Batch upsert (idempotent - safe for re-runs) - collection.upsert( - ids=frag_ids, - embeddings=batch_embeddings, - documents=doc_texts, - metadatas=frag_metas - ) + collection.upsert(ids=frag_ids, embeddings=batch_embeddings, documents=doc_texts, metadatas=frag_metas) total = collection.count() - json_handler.log_operation("symbolic_store_llm_batch", {"stored": len(frag_ids), "total": total, "success": True}) - return { - 'success': True, - 'stored': len(frag_ids), - 'collection': COLLECTION_NAME, - 'total_fragments': total - } + json_handler.log_operation( + "symbolic_store_llm_batch", {"stored": len(frag_ids), "total": total, "success": True} + ) + return {"success": True, "stored": len(frag_ids), "collection": COLLECTION_NAME, "total_fragments": total} except Exception as e: logger.error(f"[storage] Batch LLM fragment storage failed: {e}") - return { - 'success': False, - 'error': f"Batch LLM fragment storage failed: {e}" - } + return {"success": False, "error": f"Batch LLM fragment storage failed: {e}"} -def delete_fragment( - fragment_id: str, - db_path: Path | None = None -) -> Dict[str, Any]: +def delete_fragment(fragment_id: str, db_path: Path | None = None) -> Dict[str, Any]: """ Delete a fragment from ChromaDB by ID. @@ -701,24 +574,17 @@ def delete_fragment( try: client = get_chroma_client(db_path) collection = client.get_or_create_collection( - name=COLLECTION_NAME, - metadata={"hnsw:space": "cosine"}, - embedding_function=None + name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}, embedding_function=None ) collection.delete(ids=[fragment_id]) total = collection.count() - json_handler.log_operation("symbolic_delete_fragment", {"deleted_id": fragment_id, "total": total, "success": True}) - return { - 'success': True, - 'deleted_id': fragment_id, - 'total_fragments': total - } + json_handler.log_operation( + "symbolic_delete_fragment", {"deleted_id": fragment_id, "total": total, "success": True} + ) + return {"success": True, "deleted_id": fragment_id, "total_fragments": total} except Exception as e: logger.error(f"[storage] Fragment deletion failed: {e}") - return { - 'success': False, - 'error': f"Fragment deletion failed: {e}" - } + return {"success": False, "error": f"Fragment deletion failed: {e}"} diff --git a/src/aipass/memory/apps/handlers/templates/differ.py b/src/aipass/memory/apps/handlers/templates/differ.py index 8652e659..747816b3 100644 --- a/src/aipass/memory/apps/handlers/templates/differ.py +++ b/src/aipass/memory/apps/handlers/templates/differ.py @@ -34,7 +34,9 @@ from aipass.memory.apps.handlers.json import json_handler # PATH SETUP # ============================================================================= -MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/templates/differ.py -> apps -> handlers -> templates -> memory/ +MEMORY_ROOT = ( + Path(__file__).resolve().parent.parent.parent.parent +) # handlers/templates/differ.py -> apps -> handlers -> templates -> memory/ # ============================================================================= # CONSTANTS @@ -60,9 +62,11 @@ OBS_STRUCTURAL = ["document_metadata", "guidelines", "notes"] # PLACEHOLDER REPLACEMENT # ============================================================================= + def _replace_placeholders(template: dict, branch_name: str) -> dict: """Replace {{BRANCHNAME}} and {{DATE}} in template values.""" from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") result = copy.deepcopy(template) @@ -84,11 +88,8 @@ def _replace_placeholders(template: dict, branch_name: str) -> dict: # DIFF LOGIC # ============================================================================= -def _diff_structural_section( - current: dict, - template: dict, - path_prefix: str -) -> Dict[str, List[str]]: + +def _diff_structural_section(current: dict, template: dict, path_prefix: str) -> Dict[str, List[str]]: """ Compare a structural section between current file and template. @@ -118,13 +119,18 @@ def _diff_structural_section( diffs["modifications"].extend(sub_diffs["modifications"]) elif current[key] != template[key]: # Skip dynamic values that are expected to differ - if key in ("current_lines", "health", "last_health_check", - "current_key_learnings", "current_recently_completed", - "created", "last_updated", "managed_by"): + if key in ( + "current_lines", + "health", + "last_health_check", + "current_key_learnings", + "current_recently_completed", + "created", + "last_updated", + "managed_by", + ): continue - diffs["modifications"].append( - f"{full_path}: {_truncate(current[key])} -> {_truncate(template[key])}" - ) + diffs["modifications"].append(f"{full_path}: {_truncate(current[key])} -> {_truncate(template[key])}") return diffs @@ -185,6 +191,7 @@ def _truncate(val: Any, max_len: int = 60) -> str: # PUBLIC API # ============================================================================= + def diff_template_vs_branch(branch_path: str | Path) -> dict: """ Compare template structure against a specific branch's memory files. @@ -205,13 +212,7 @@ def diff_template_vs_branch(branch_path: str | Path) -> dict: branch_path = Path(branch_path) branch_name = branch_path.name.upper() - result = { - "branch": branch_name, - "path": str(branch_path), - "local": [], - "observations": [], - "errors": [] - } + result = {"branch": branch_name, "path": str(branch_path), "local": [], "observations": [], "errors": []} # Load templates templates = {} @@ -220,7 +221,7 @@ def diff_template_vs_branch(branch_path: str | Path) -> dict: result["errors"].append(f"Template not found: {path}") return result try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: templates[name] = json.load(f) except (json.JSONDecodeError, IOError) as e: logger.warning(f"[differ] Failed to load template {path.name}: {e}") @@ -239,7 +240,7 @@ def diff_template_vs_branch(branch_path: str | Path) -> dict: continue try: - with open(f, 'r', encoding='utf-8') as fh: + with open(f, "r", encoding="utf-8") as fh: current = json.load(fh) except (json.JSONDecodeError, IOError) as e: logger.warning(f"[differ] Failed to read {f.name}: {e}") @@ -278,7 +279,7 @@ def diff_template_vs_branch(branch_path: str | Path) -> dict: continue try: - with open(f, 'r', encoding='utf-8') as fh: + with open(f, "r", encoding="utf-8") as fh: current = json.load(fh) except (json.JSONDecodeError, IOError) as e: logger.warning(f"[differ] Failed to read {f.name}: {e}") @@ -301,7 +302,15 @@ def diff_template_vs_branch(branch_path: str | Path) -> dict: if file_diff["additions"] or file_diff["removals"] or file_diff["modifications"]: result["observations"].append(file_diff) - json_handler.log_operation("template_diff", {"branch": branch_name, "local_diffs": len(result["local"]), "obs_diffs": len(result["observations"]), "success": True}) + json_handler.log_operation( + "template_diff", + { + "branch": branch_name, + "local_diffs": len(result["local"]), + "obs_diffs": len(result["observations"]), + "success": True, + }, + ) return result @@ -309,7 +318,7 @@ def diff_template_vs_branch(branch_path: str | Path) -> dict: # CLI INTERFACE # ============================================================================= -if __name__ == '__main__': +if __name__ == "__main__": import sys as _sys args = _sys.argv[1:] diff --git a/src/aipass/memory/apps/handlers/templates/pusher.py b/src/aipass/memory/apps/handlers/templates/pusher.py index 845507b1..0904b45b 100644 --- a/src/aipass/memory/apps/handlers/templates/pusher.py +++ b/src/aipass/memory/apps/handlers/templates/pusher.py @@ -33,16 +33,15 @@ from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler # Handler imports (same-branch allowed per handler boundaries) -from aipass.memory.apps.handlers.json.memory_files import ( - read_memory_file_data, - write_memory_file_simple -) +from aipass.memory.apps.handlers.json.memory_files import read_memory_file_data, write_memory_file_simple # ============================================================================= # PATH SETUP # ============================================================================= -MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/templates/pusher.py -> apps -> handlers -> templates -> memory/ +MEMORY_ROOT = ( + Path(__file__).resolve().parent.parent.parent.parent +) # handlers/templates/pusher.py -> apps -> handlers -> templates -> memory/ def _find_repo_root() -> Path: @@ -78,6 +77,7 @@ DEPRECATED_GUIDELINES_KEYS = ["emoji_usage", "high_value_patterns", "low_value_p # PLACEHOLDER REPLACEMENT # ============================================================================= + def _replace_placeholders(template: dict, branch_name: str) -> dict: """Replace {{BRANCHNAME}} and {{DATE}} in template values. Returns new dict.""" today = datetime.now().strftime("%Y-%m-%d") @@ -100,6 +100,7 @@ def _replace_placeholders(template: dict, branch_name: str) -> dict: # DEPRECATED SECTION REMOVAL # ============================================================================= + def _remove_deprecated(data: dict, file_type: str) -> List[str]: """Remove deprecated sections from data (in-place). Returns list of changes.""" changes = [] @@ -142,6 +143,7 @@ def _remove_deprecated(data: dict, file_type: str) -> List[str]: # SHARED METADATA MERGE # ============================================================================= + def _merge_metadata(curr_meta: dict, tmpl_meta: dict) -> List[str]: """ Merge template metadata into current metadata (in-place). @@ -195,6 +197,7 @@ def _merge_metadata(curr_meta: dict, tmpl_meta: dict) -> List[str]: # STRUCTURAL MERGE: LOCAL FILES # ============================================================================= + def _apply_template_to_local(current: dict, template: dict, branch_name: str) -> Tuple[dict, List[str]]: """Apply LOCAL template structural updates. Returns (updated_dict, list_of_changes).""" changes = [] @@ -239,6 +242,7 @@ def _apply_template_to_local(current: dict, template: dict, branch_name: str) -> # STRUCTURAL MERGE: OBSERVATIONS FILES # ============================================================================= + def _apply_template_to_observations(current: dict, template: dict, branch_name: str) -> Tuple[dict, List[str]]: """Apply OBSERVATIONS template structural updates. Returns (updated_dict, list_of_changes).""" changes = [] @@ -276,21 +280,22 @@ def _apply_template_to_observations(current: dict, template: dict, branch_name: # BRANCH DISCOVERY # ============================================================================= + def _load_registry() -> Optional[List[Dict[str, Any]]]: """Load AIPASS_REGISTRY.json and return list of active branches.""" if not REGISTRY_PATH.exists(): return None try: - with open(REGISTRY_PATH, 'r', encoding='utf-8') as f: + with open(REGISTRY_PATH, "r", encoding="utf-8") as f: registry = json.load(f) branches = registry.get("branches", []) # Resolve relative paths against repo root for branch in branches: - raw_path = branch.get('path', '') + raw_path = branch.get("path", "") resolved = Path(raw_path) if not resolved.is_absolute(): resolved = _REPO_ROOT / raw_path - branch['path'] = str(resolved) + branch["path"] = str(resolved) return [b for b in branches if b.get("status") == "active"] except (json.JSONDecodeError, KeyError) as e: logger.warning(f"[pusher] Failed to load registry {REGISTRY_PATH}: {e}") @@ -318,6 +323,7 @@ def _find_memory_files(branch_path: Path) -> Dict[str, List[Path]]: # TEMPLATE LOADING # ============================================================================= + def _load_templates() -> Optional[Dict[str, dict]]: """Load living templates from memory templates directory.""" templates = {} @@ -325,7 +331,7 @@ def _load_templates() -> Optional[Dict[str, dict]]: if not path.exists(): return None try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: templates[name] = json.load(f) except (json.JSONDecodeError, IOError) as e: logger.warning(f"[pusher] Failed to load template {path}: {e}") @@ -337,6 +343,7 @@ def _load_templates() -> Optional[Dict[str, dict]]: # MAIN PUSH FUNCTION # ============================================================================= + def push_templates(dry_run: bool = False) -> dict: """ Push living templates to all registered branches. @@ -360,7 +367,7 @@ def push_templates(dry_run: bool = False) -> dict: "files_modified": 0, "changes": [], "errors": [], - "branches_list": [] + "branches_list": [], } templates = _load_templates() @@ -422,7 +429,15 @@ def push_templates(dry_run: bool = False) -> dict: if not _update_version_file(result["branches_list"]): result["errors"].append("Failed to update template version file") - json_handler.log_operation("template_push", {"branches": result["branches_updated"], "files": result["files_modified"], "dry_run": dry_run, "success": True}) + json_handler.log_operation( + "template_push", + { + "branches": result["branches_updated"], + "files": result["files_modified"], + "dry_run": dry_run, + "success": True, + }, + ) return result @@ -430,18 +445,19 @@ def push_templates(dry_run: bool = False) -> dict: # VERSION TRACKING # ============================================================================= + def _update_version_file(branches_pushed: List[str]) -> bool: """Update .template_version.json with push record.""" try: version_data = {} if VERSION_FILE_PATH.exists(): - with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f: + with open(VERSION_FILE_PATH, "r", encoding="utf-8") as f: version_data = json.load(f) version_data["last_push"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") version_data["last_push_branches"] = branches_pushed - with open(VERSION_FILE_PATH, 'w', encoding='utf-8') as f: + with open(VERSION_FILE_PATH, "w", encoding="utf-8") as f: json.dump(version_data, f, indent=2, ensure_ascii=False) - f.write('\n') + f.write("\n") return True except Exception as e: logger.warning(f"[pusher] Failed to update version file: {e}") @@ -457,11 +473,11 @@ def get_template_status() -> dict: "observations_template_exists": OBS_TEMPLATE_PATH.exists(), "version": None, "last_push": None, - "last_push_branches": [] + "last_push_branches": [], } if VERSION_FILE_PATH.exists(): try: - with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f: + with open(VERSION_FILE_PATH, "r", encoding="utf-8") as f: data = json.load(f) status["version"] = data.get("version") status["last_push"] = data.get("last_push") @@ -476,8 +492,9 @@ def get_template_status() -> dict: # CLI INTERFACE # ============================================================================= -if __name__ == '__main__': +if __name__ == "__main__": import sys as _sys + _out = _sys.stdout.write args = _sys.argv[1:] @@ -524,8 +541,8 @@ if __name__ == '__main__': _out(f"Last push: {tmpl_status.get('last_push', 'never')}\n") pushed = tmpl_status.get("last_push_branches", []) if pushed: - preview = ', '.join(pushed[:5]) - suffix = '...' if len(pushed) > 5 else '' + preview = ", ".join(pushed[:5]) + suffix = "..." if len(pushed) > 5 else "" _out(f"Branches pushed: {len(pushed)} ({preview}{suffix})\n") _out("\n") _sys.exit(0) diff --git a/src/aipass/memory/apps/handlers/templates/spawn_pusher.py b/src/aipass/memory/apps/handlers/templates/spawn_pusher.py index 18d250d7..87d23ce6 100644 --- a/src/aipass/memory/apps/handlers/templates/spawn_pusher.py +++ b/src/aipass/memory/apps/handlers/templates/spawn_pusher.py @@ -62,6 +62,7 @@ def _find_spawn_templates_dir() -> Path: # TEMPLATE SET DISCOVERY # ============================================================================= + def _discover_template_sets(spawn_templates_dir: Path) -> List[Dict[str, Any]]: """ Auto-discover spawn template sets that contain a .trinity/ directory. @@ -84,10 +85,12 @@ def _discover_template_sets(spawn_templates_dir: Path) -> List[Dict[str, Any]]: continue trinity_dir = child / ".trinity" if trinity_dir.is_dir(): - template_sets.append({ - "name": child.name, - "trinity_path": trinity_dir, - }) + template_sets.append( + { + "name": child.name, + "trinity_path": trinity_dir, + } + ) return template_sets @@ -96,6 +99,7 @@ def _discover_template_sets(spawn_templates_dir: Path) -> List[Dict[str, Any]]: # JSON COMPARISON # ============================================================================= + def _read_json(path: Path) -> dict | None: """Read and parse a JSON file. Returns None on any error.""" try: @@ -129,6 +133,7 @@ def _json_equal(a: dict | None, b: dict | None) -> bool: # MAIN PUSH FUNCTION # ============================================================================= + def push_to_spawn_templates(dry_run: bool = False) -> dict: """ Push memory's canonical templates to spawn's template directories. @@ -208,17 +213,17 @@ def push_to_spawn_templates(dry_run: bool = False) -> dict: action = "updated" if spawn_file.exists() else "created" set_changed = True result["files_modified"] += 1 - result["changes"].append({ - "template_set": ts_name, - "file": filename, - "action": action, - }) + result["changes"].append( + { + "template_set": ts_name, + "file": filename, + "action": action, + } + ) if not dry_run: if not _write_json(spawn_file, canonical): - result["errors"].append( - f"{ts_name}: Failed to write {filename}" - ) + result["errors"].append(f"{ts_name}: Failed to write {filename}") if set_changed: result["template_sets_updated"] += 1 @@ -232,7 +237,15 @@ def push_to_spawn_templates(dry_run: bool = False) -> dict: f"{result['files_modified']} files modified" ) - json_handler.log_operation("spawn_template_push", {"sets_updated": result["template_sets_updated"], "files": result["files_modified"], "dry_run": dry_run, "success": True}) + json_handler.log_operation( + "spawn_template_push", + { + "sets_updated": result["template_sets_updated"], + "files": result["files_modified"], + "dry_run": dry_run, + "success": True, + }, + ) return result diff --git a/src/aipass/memory/apps/handlers/tracking/line_counter.py b/src/aipass/memory/apps/handlers/tracking/line_counter.py index f359aaa5..1ae44339 100644 --- a/src/aipass/memory/apps/handlers/tracking/line_counter.py +++ b/src/aipass/memory/apps/handlers/tracking/line_counter.py @@ -36,6 +36,7 @@ logger = get_system_logger() # LINE COUNTING # ============================================================================= + def _count_physical_lines(file_path: Path) -> int: """ Count physical lines in file @@ -47,7 +48,7 @@ def _count_physical_lines(file_path: Path) -> int: Number of lines """ try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: return len(f.readlines()) except Exception as e: logger.warning(f"[line_counter] Failed to count lines in {file_path}: {e}") @@ -58,6 +59,7 @@ def _count_physical_lines(file_path: Path) -> int: # METADATA UPDATE # ============================================================================= + def update_line_count(file_path: Path) -> Dict[str, Any]: """ Update current_lines in document_metadata.status @@ -71,34 +73,20 @@ def update_line_count(file_path: Path) -> Dict[str, Any]: Dict with success status and updated line count """ if not file_path.exists(): - return { - 'success': False, - 'error': f"File not found: {file_path}" - } + return {"success": False, "error": f"File not found: {file_path}"} # Count lines line_count = _count_physical_lines(file_path) # Update metadata using safe handler (atomic write) - result = update_metadata( - file_path, - current_lines=line_count, - last_health_check=datetime.now().strftime("%Y-%m-%d") - ) + result = update_metadata(file_path, current_lines=line_count, last_health_check=datetime.now().strftime("%Y-%m-%d")) - if not result['success']: - return { - 'success': False, - 'error': f"Failed to update metadata: {result['error']}" - } + if not result["success"]: + return {"success": False, "error": f"Failed to update metadata: {result['error']}"} json_handler.log_operation("update_line_count", {"file": file_path.name, "lines": line_count, "success": True}) - return { - 'success': True, - 'file': str(file_path), - 'lines': line_count - } + return {"success": True, "file": str(file_path), "lines": line_count} def update_all_memory_files() -> Dict[str, Any]: @@ -112,20 +100,15 @@ def update_all_memory_files() -> Dict[str, Any]: branches = _read_registry() if not branches: - return { - 'success': True, - 'updated': 0, - 'failed': 0, - 'message': 'No branches in registry' - } + return {"success": True, "updated": 0, "failed": 0, "message": "No branches in registry"} updated = 0 failed = [] for branch in branches: - branch_name = branch.get('name', 'UNKNOWN') + branch_name = branch.get("name", "UNKNOWN") - for memory_type in ['observations', 'local']: + for memory_type in ["observations", "local"]: file_path = _get_memory_file_path(branch, memory_type) if file_path is None: @@ -133,14 +116,9 @@ def update_all_memory_files() -> Dict[str, Any]: result = update_line_count(file_path) - if result['success']: + if result["success"]: updated += 1 else: - failed.append((branch_name, memory_type, result.get('error'))) + failed.append((branch_name, memory_type, result.get("error"))) - return { - 'success': True, - 'updated': updated, - 'failed': len(failed), - 'failures': failed - } + return {"success": True, "updated": updated, "failed": len(failed), "failures": failed} diff --git a/src/aipass/memory/apps/handlers/vector/embed_subprocess.py b/src/aipass/memory/apps/handlers/vector/embed_subprocess.py index eb376225..cac58ebd 100644 --- a/src/aipass/memory/apps/handlers/vector/embed_subprocess.py +++ b/src/aipass/memory/apps/handlers/vector/embed_subprocess.py @@ -24,21 +24,21 @@ def main(): """Process embedding request from stdin JSON""" try: input_data = json.load(sys.stdin) - texts = input_data.get('texts', []) + texts = input_data.get("texts", []) if not texts: - print(json.dumps({'success': True, 'embeddings': [], 'count': 0, 'dimension': 384})) + print(json.dumps({"success": True, "embeddings": [], "count": 0, "dimension": 384})) return # Import here — runs in memory venv where these are installed from sentence_transformers import SentenceTransformer import torch - model = SentenceTransformer('all-MiniLM-L6-v2') + model = SentenceTransformer("all-MiniLM-L6-v2") use_gpu = torch.cuda.is_available() if use_gpu: - model = model.to('cuda') + model = model.to("cuda") batch_size = 64 else: batch_size = 16 @@ -53,7 +53,7 @@ def main(): batch_size=batch_size, convert_to_tensor=False, normalize_embeddings=True, - show_progress_bar=False + show_progress_bar=False, ) # Restore original order @@ -64,17 +64,12 @@ def main(): if use_gpu: torch.cuda.empty_cache() - print(json.dumps({ - 'success': True, - 'embeddings': ordered, - 'count': len(ordered), - 'dimension': 384 - })) + print(json.dumps({"success": True, "embeddings": ordered, "count": len(ordered), "dimension": 384})) except Exception as e: - print(json.dumps({'success': False, 'error': str(e)})) + print(json.dumps({"success": False, "error": str(e)})) sys.exit(1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/aipass/memory/apps/handlers/vector/embedder.py b/src/aipass/memory/apps/handlers/vector/embedder.py index 77d83f62..6c2e8e58 100644 --- a/src/aipass/memory/apps/handlers/vector/embedder.py +++ b/src/aipass/memory/apps/handlers/vector/embedder.py @@ -43,6 +43,7 @@ logger = get_system_logger() # EMBEDDING SERVICE (Singleton) # ============================================================================= + class EmbeddingService: """ Production-ready embedding service @@ -54,7 +55,7 @@ class EmbeddingService: - GPU memory cleanup (prevents VRAM leaks) """ - def __init__(self, model_name: str = 'all-MiniLM-L6-v2'): + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): """ Initialize embedding service @@ -82,14 +83,13 @@ class EmbeddingService: # GPU optimization if available self.use_gpu = torch.cuda.is_available() if self.use_gpu: - self.model = self.model.to('cuda') + self.model = self.model.to("cuda") self.batch_size = 64 else: self.batch_size = 16 self.dimension = 384 # all-MiniLM-L6-v2 output dimension - def encode_batch(self, texts: List[str]) -> Dict[str, Any]: """ Encode batch of texts with all optimizations @@ -109,11 +109,7 @@ class EmbeddingService: import torch if not texts: - return { - "embeddings": [], - "count": 0, - "dimension": self.dimension - } + return {"embeddings": [], "count": 0, "dimension": self.dimension} # Pre-sort by length (reduces padding waste by 30%) sorted_pairs = sorted(enumerate(texts), key=lambda x: len(x[1])) @@ -126,7 +122,7 @@ class EmbeddingService: batch_size=self.batch_size, convert_to_tensor=False, # Return numpy for Chroma normalize_embeddings=True, # Critical for L2 distance - show_progress_bar=False + show_progress_bar=False, ) # Restore original order @@ -138,11 +134,7 @@ class EmbeddingService: if self.use_gpu: torch.cuda.empty_cache() - return { - "embeddings": ordered_embeddings, - "count": len(ordered_embeddings), - "dimension": self.dimension - } + return {"embeddings": ordered_embeddings, "count": len(ordered_embeddings), "dimension": self.dimension} # Global service instance (singleton pattern) @@ -165,6 +157,7 @@ def _get_service() -> EmbeddingService: # PUBLIC API # ============================================================================= + def encode_batch(texts: List[str]) -> Dict[str, Any]: """ Encode batch of texts to embeddings @@ -185,29 +178,21 @@ def encode_batch(texts: List[str]) -> Dict[str, Any]: # Each embedding is 384-dim numpy array """ if not texts: - return { - 'success': True, - 'embeddings': [], - 'count': 0, - 'message': 'No texts provided' - } + return {"success": True, "embeddings": [], "count": 0, "message": "No texts provided"} try: service = _get_service() result = service.encode_batch(texts) - json_handler.log_operation("vector_encode_batch", {"count": result.get('count', 0), "dimension": result.get('dimension', 0), "success": True}) - return { - 'success': True, - **result - } + json_handler.log_operation( + "vector_encode_batch", + {"count": result.get("count", 0), "dimension": result.get("dimension", 0), "success": True}, + ) + return {"success": True, **result} except Exception as e: logger.error(f"[embedder] Batch encoding failed: {e}") - return { - 'success': False, - 'error': f"Encoding failed: {e}" - } + return {"success": False, "error": f"Encoding failed: {e}"} def encode_memories(memories: List[Dict[str, Any]]) -> Dict[str, Any]: @@ -230,39 +215,33 @@ def encode_memories(memories: List[Dict[str, Any]]) -> Dict[str, Any]: original = result['memories'] """ if not memories: - return { - 'success': True, - 'embeddings': [], - 'memories': [], - 'count': 0, - 'message': 'No memories provided' - } + return {"success": True, "embeddings": [], "memories": [], "count": 0, "message": "No memories provided"} # Extract text content from memories texts = [] for memory in memories: # Try common fields for text content text = ( - memory.get('content') or - memory.get('text') or - memory.get('message') or - str(memory) # Fallback to string representation + memory.get("content") + or memory.get("text") + or memory.get("message") + or str(memory) # Fallback to string representation ) texts.append(text) # Encode texts encode_result = encode_batch(texts) - if not encode_result['success']: + if not encode_result["success"]: return encode_result # Combine embeddings with original memories return { - 'success': True, - 'embeddings': encode_result['embeddings'], - 'memories': memories, - 'count': len(memories), - 'dimension': encode_result['dimension'] + "success": True, + "embeddings": encode_result["embeddings"], + "memories": memories, + "count": len(memories), + "dimension": encode_result["dimension"], } @@ -277,15 +256,12 @@ def get_model_info() -> Dict[str, Any]: service = _get_service() return { - 'success': True, - 'model_name': service.model_name, - 'dimension': service.dimension, - 'batch_size': service.batch_size, - 'gpu_enabled': service.use_gpu + "success": True, + "model_name": service.model_name, + "dimension": service.dimension, + "batch_size": service.batch_size, + "gpu_enabled": service.use_gpu, } except Exception as e: logger.warning(f"[embedder] Failed to get model info: {e}") - return { - 'success': False, - 'error': f"Failed to get model info: {e}" - } + return {"success": False, "error": f"Failed to get model info: {e}"} diff --git a/src/aipass/memory/apps/memory.py b/src/aipass/memory/apps/memory.py index 888784fb..b2e39a10 100755 --- a/src/aipass/memory/apps/memory.py +++ b/src/aipass/memory/apps/memory.py @@ -44,6 +44,7 @@ _PACKAGE_BASE = "aipass.memory.apps.modules" # INTROSPECTION DISPLAY # ============================================================================= + def print_introspection(): """Display discovered modules and status""" console.print() @@ -59,7 +60,7 @@ def print_introspection(): console.print() for module in modules: - module_name = module.__name__.split('.')[-1] + module_name = module.__name__.split(".")[-1] console.print(f" [cyan]*[/cyan] {module_name}") if not modules: @@ -74,15 +75,18 @@ def print_introspection(): # HELP SYSTEM # ============================================================================= + def print_help(): """Display Rich-formatted help""" console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Central Memory Archive System[/bold cyan]\n[dim]Vector search, memory rollover, and fragmented memory for AIPass[/dim]", - border_style="cyan", - box=box.ROUNDED - )) + console.print( + Panel.fit( + "[bold cyan]Memory - Central Memory Archive System[/bold cyan]\n[dim]Vector search, memory rollover, and fragmented memory for AIPass[/dim]", + border_style="cyan", + box=box.ROUNDED, + ) + ) console.print() # What is Memory section @@ -91,12 +95,9 @@ def print_help(): " [green]>[/green] Provides semantic search across all branch memories\n" " [green]>[/green] Archives memories when branches hit rollover limits" ) - console.print(Panel( - what_content, - title="[bold cyan]What is Memory?[/bold cyan]", - border_style="dim", - box=box.ROUNDED - )) + console.print( + Panel(what_content, title="[bold cyan]What is Memory?[/bold cyan]", border_style="dim", box=box.ROUNDED) + ) console.print() console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]") @@ -128,12 +129,12 @@ def print_help(): console.print("[bold cyan]USAGE:[/bold cyan]") console.print() console.print(" [bold]Via Drone (recommended):[/bold]") - console.print(" [dim]drone @memory search \"performance patterns\"[/dim]") + console.print(' [dim]drone @memory search "performance patterns"[/dim]') console.print(" [dim]drone @memory rollover status[/dim]") console.print(" [dim]drone @memory rollover run[/dim]") console.print() console.print(" [bold]Direct execution:[/bold]") - console.print(" [dim]drone @memory search \"query\"[/dim]") + console.print(' [dim]drone @memory search "query"[/dim]') console.print(" [dim]drone @memory rollover run[/dim]") console.print() console.print("-" * 70) @@ -146,7 +147,7 @@ def print_help(): console.print(" [cyan]--n N[/cyan] Number of results (default: 5)") console.print() console.print(" [bold]Example:[/bold]") - console.print(" [dim]drone @memory search \"registry bugs\" --branch SEEDGO --n 10[/dim]") + console.print(' [dim]drone @memory search "registry bugs" --branch SEEDGO --n 10[/dim]') console.print() console.print("-" * 70) console.print() @@ -172,6 +173,7 @@ def print_help(): MODULES_DIR = Path(__file__).parent / "modules" + def discover_modules() -> List[Any]: """ Auto-discover modules in modules/ directory @@ -197,7 +199,7 @@ def discover_modules() -> List[Any]: module = importlib.import_module(module_name) # Duck typing: If it has handle_command(), it's a module - if hasattr(module, 'handle_command'): + if hasattr(module, "handle_command"): modules.append(module) logger.info(f"[memory] Discovered module: {module_name}") except Exception as e: @@ -221,7 +223,7 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: True if command was handled, False otherwise """ # Built-in commands handled by entry point - if command == 'watch': + if command == "watch": start_watch() return True @@ -239,6 +241,7 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: # WATCH MODE # ============================================================================= + def start_watch() -> None: """ Start memory watcher - monitors branch memory files for auto-rollover @@ -250,7 +253,7 @@ def start_watch() -> None: """ from ..handlers.monitor.memory_watcher import ( # type: ignore[import-not-found] start_memory_watcher, - stop_memory_watcher + stop_memory_watcher, ) from ..handlers.monitor.detector import get_rollover_stats # type: ignore[import-not-found] @@ -266,17 +269,13 @@ def start_watch() -> None: signal.signal(signal.SIGINT, signal_handler) console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Watch Mode[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Watch Mode[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() # Start the watcher result = start_memory_watcher() - if not result.get('success'): + if not result.get("success"): error(f"Failed to start watcher: {result.get('error')}") return @@ -287,9 +286,9 @@ def start_watch() -> None: # Show initial status stats = get_rollover_stats() - if stats.get('success'): - ready = stats.get('files_ready', 0) - total = stats.get('files_checked', 0) + if stats.get("success"): + ready = stats.get("files_ready", 0) + total = stats.get("files_checked", 0) status_marker = "[red]![/red]" if ready > 0 else "[green]OK[/green]" console.print(f"{status_marker} Current: {total} files monitored, {ready} ready for rollover") console.print() @@ -304,6 +303,7 @@ def start_watch() -> None: # MAIN ENTRY POINT # ============================================================================= + def main(): """Main entry point - routes commands or shows help""" @@ -316,12 +316,12 @@ def main(): return # Version flag - if args[0] in ['--version', '-V']: + if args[0] in ["--version", "-V"]: console.print("memory v1.0.0") return # Show help only for explicit help flags - if args[0] in ['--help', '-h', 'help']: + if args[0] in ["--help", "-h", "help"]: print_help() return diff --git a/src/aipass/memory/apps/modules/rollover.py b/src/aipass/memory/apps/modules/rollover.py index c1e2b6b9..de032b0f 100755 --- a/src/aipass/memory/apps/modules/rollover.py +++ b/src/aipass/memory/apps/modules/rollover.py @@ -78,37 +78,37 @@ def handle_command(command: str, args: List[str]) -> bool: True if command handled, False otherwise """ # Top-level help (backward compat — entry point may send these) - if command in ('--help', '-h', 'help'): + if command in ("--help", "-h", "help"): print_help() return True - if command == 'rollover': + if command == "rollover": # No args → introspection (seedgo standard) if not args: print_introspection() return True # --help / -h / help → full help - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True # Subcommand routing sub = args[0] - if sub == 'run': + if sub == "run": run_rollover() return True - if sub == 'status': + if sub == "status": show_status() return True - if sub == 'check': + if sub == "check": check_triggers() return True - if sub == 'sync-lines': + if sub == "sync-lines": sync_line_counts() return True @@ -120,19 +120,19 @@ def handle_command(command: str, args: List[str]) -> bool: return True # Backward-compatible top-level commands (entry point still routes these) - if command == 'status': + if command == "status": show_status() return True - elif command == 'check': + elif command == "check": check_triggers() return True - elif command == 'sync-lines': + elif command == "sync-lines": sync_line_counts() return True - elif command == 'process-plans': + elif command == "process-plans": process_plans_command() return True @@ -142,11 +142,13 @@ def handle_command(command: str, args: List[str]) -> bool: def print_help() -> None: """Display rollover module help""" console.print() - console.print(Panel.fit( - "[bold cyan]Rollover Module - Memory Rollover Orchestration[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print( + Panel.fit( + "[bold cyan]Rollover Module - Memory Rollover Orchestration[/bold cyan]", + border_style="cyan", + box=box.ROUNDED, + ) + ) console.print() console.print("[bold]USAGE:[/bold]") console.print(" drone @memory rollover ") @@ -174,6 +176,7 @@ def print_help() -> None: # ROLLOVER ORCHESTRATION # ============================================================================= + def run_rollover() -> bool: """ Execute rollover workflow for all triggered branches. @@ -181,11 +184,7 @@ def run_rollover() -> bool: Delegates to handler and renders results with Rich. """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Rollover Execution[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Rollover Execution[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() console.print("[cyan]Checking for rollover triggers... (first run may take 30s for model loading)[/cyan]") @@ -197,11 +196,11 @@ def run_rollover() -> bool: error(f"Rollover failed: {e}") return False - if not result.get('success') and result.get('error'): - error(result['error']) + if not result.get("success") and result.get("error"): + error(result["error"]) return False - triggers_count = result.get('triggers_count', 0) + triggers_count = result.get("triggers_count", 0) if triggers_count == 0: console.print("[green]>[/green] No files need rollover") return True @@ -210,16 +209,16 @@ def run_rollover() -> bool: console.print() # Display individual results - for item in result.get('results', []): - local_status = "> local" if item.get('local_stored') else "x local" + for item in result.get("results", []): + local_status = "> local" if item.get("local_stored") else "x local" console.print( f" [green]>[/green] Rolled over {item['memories_count']} items -> {item['global_collection']} " f"({item['old_lines']} -> {item['new_lines']} lines, global: {item['global_total']} vectors, {local_status})" ) # Report results - success_count = result.get('success_count', 0) - failed = result.get('failed', []) + success_count = result.get("success_count", 0) + failed = result.get("failed", []) console.print() if success_count > 0: @@ -238,6 +237,7 @@ def run_rollover() -> bool: # PLAN VECTORIZATION # ============================================================================= + def process_plans_command() -> None: """ Process pending plan files into vector storage. @@ -245,11 +245,7 @@ def process_plans_command() -> None: Batches all chunks from all files into a single embed + store call. """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Process Plans[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Process Plans[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() console.print("[cyan]Processing plan files into vector storage...[/cyan]") @@ -257,22 +253,23 @@ def process_plans_command() -> None: try: from ..handlers.intake.plans_processor import process_plans + result = process_plans() except Exception as e: logger.error(f"[rollover] Plan processing failed: {e}") error(f"Plan processing failed: {e}") return - if not result.get('success'): - error(result.get('error', 'Unknown error')) - if result.get('errors'): - for err in result['errors']: + if not result.get("success"): + error(result.get("error", "Unknown error")) + if result.get("errors"): + for err in result["errors"]: error(err) return - files_processed = result.get('files_processed', 0) - total_chunks = result.get('total_chunks', 0) - reason = result.get('reason', '') + files_processed = result.get("files_processed", 0) + total_chunks = result.get("total_chunks", 0) + reason = result.get("reason", "") if files_processed == 0 and reason: console.print(f"[green]>[/green] {reason}") @@ -281,19 +278,22 @@ def process_plans_command() -> None: else: console.print(f"[green]>[/green] Processed {files_processed} files ({total_chunks} chunks vectorized)") - if result.get('errors'): + if result.get("errors"): console.print() - for err in result['errors']: + for err in result["errors"]: error(err) console.print() - json_handler.log_operation("process_plans_command", {"files_processed": files_processed, "total_chunks": total_chunks}) + json_handler.log_operation( + "process_plans_command", {"files_processed": files_processed, "total_chunks": total_chunks} + ) # ============================================================================= # LINE COUNT SYNC # ============================================================================= + def sync_line_counts() -> None: """ Update line count metadata for all branch memory files. @@ -301,11 +301,7 @@ def sync_line_counts() -> None: Delegates to handler and renders results with Rich. """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Sync Line Counts[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Sync Line Counts[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() console.print("[cyan]Updating line counts for all memory files...[/cyan]") @@ -313,13 +309,13 @@ def sync_line_counts() -> None: result = _handler_sync_line_counts() - if result['success']: + if result["success"]: console.print(f"[green]>[/green] Updated {result['updated']} files") - if result['failed'] > 0: + if result["failed"] > 0: warning(f"{result['failed']} files failed") - for branch, mem_type, err_msg in result.get('failures', []): + for branch, mem_type, err_msg in result.get("failures", []): error(f"{branch}.{mem_type}: {err_msg}") - json_handler.log_operation("rollover_sync_lines", {"updated": result['updated'], "failed": result['failed']}) + json_handler.log_operation("rollover_sync_lines", {"updated": result["updated"], "failed": result["failed"]}) else: error("Failed to sync line counts") @@ -330,6 +326,7 @@ def sync_line_counts() -> None: # STATUS & CHECKING # ============================================================================= + def show_status() -> None: """ Show rollover statistics for all branches @@ -340,17 +337,13 @@ def show_status() -> None: - Per-branch status (current/max lines) """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Rollover Status[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Rollover Status[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() # Get stats from detector stats_result = detector.get_rollover_stats() - if not stats_result['success']: + if not stats_result["success"]: error(f"Failed to get status: {stats_result.get('error', 'Unknown error')}") logger.error(f"[rollover] Failed to get status: {stats_result.get('error')}") return @@ -364,35 +357,35 @@ def show_status() -> None: console.print() # Per-branch details - if stats['branches']: + if stats["branches"]: console.print("[bold cyan]Branch Details:[/bold cyan]") console.print() - for branch_name, branch_stats in stats['branches'].items(): + for branch_name, branch_stats in stats["branches"].items(): console.print(f" [bold]{branch_name}[/bold]") for memory_type, file_stats in branch_stats.items(): - current = file_stats['current'] - max_val = file_stats['max'] - ready = file_stats['ready'] - remaining = file_stats['remaining'] - schema_ver = file_stats.get('schema_version', '1.0.0') - v2_reason = file_stats.get('v2_reason', '') + current = file_stats["current"] + max_val = file_stats["max"] + ready = file_stats["ready"] + remaining = file_stats["remaining"] + schema_ver = file_stats.get("schema_version", "1.0.0") + v2_reason = file_stats.get("v2_reason", "") status_marker = "[red]![/red]" if ready else "[green]OK[/green]" - if schema_ver.startswith('2'): + if schema_ver.startswith("2"): status_text = f"READY ({v2_reason})" if ready else "OK (v2)" console.print(f" {status_marker} {memory_type}: {status_text}") else: status_text = "READY" if ready else f"{remaining} remaining" - console.print( - f" {status_marker} {memory_type}: {current}/{max_val} lines ({status_text})" - ) + console.print(f" {status_marker} {memory_type}: {current}/{max_val} lines ({status_text})") console.print() - json_handler.log_operation("rollover_status", {"branches_checked": stats['total_branches'], "files_ready": stats['files_ready']}) + json_handler.log_operation( + "rollover_status", {"branches_checked": stats["total_branches"], "files_ready": stats["files_ready"]} + ) def check_triggers() -> None: @@ -402,21 +395,17 @@ def check_triggers() -> None: Displays list of files that hit rollover threshold """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Rollover Check[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Rollover Check[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() triggers_result = detector.check_all_branches() - if not triggers_result['success']: + if not triggers_result["success"]: error(f"Failed to check triggers: {triggers_result.get('error', 'Unknown error')}") logger.error(f"[rollover] Failed to check triggers: {triggers_result.get('error')}") return - triggers = triggers_result.get('triggers', []) + triggers = triggers_result.get("triggers", []) if not triggers: console.print("[green]>[/green] No files need rollover") @@ -439,6 +428,7 @@ def check_triggers() -> None: # INTROSPECTION # ============================================================================= + def _discover_handlers() -> dict[str, list[str]]: """Auto-discover handler directories and their Python files. @@ -455,10 +445,7 @@ def _discover_handlers() -> dict[str, list[str]]: for d in sorted(handlers_dir.iterdir()): if not d.is_dir() or d.name.startswith("__"): continue - py_files = sorted( - f.name for f in d.iterdir() - if f.is_file() and f.suffix == ".py" and f.name != "__init__.py" - ) + py_files = sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix == ".py" and f.name != "__init__.py") if py_files: result[d.name] = py_files return result @@ -511,12 +498,12 @@ if __name__ == "__main__": # No args → introspection (seedgo standard) if len(sys.argv) < 2: - handle_command('rollover', []) + handle_command("rollover", []) sys.exit(0) # --help → full help - if sys.argv[1] in ('--help', '-h', 'help'): - handle_command('rollover', ['--help']) + if sys.argv[1] in ("--help", "-h", "help"): + handle_command("rollover", ["--help"]) sys.exit(0) # Execute command via handle_command diff --git a/src/aipass/memory/apps/modules/search.py b/src/aipass/memory/apps/modules/search.py index 846cbbfd..cfa18d6a 100755 --- a/src/aipass/memory/apps/modules/search.py +++ b/src/aipass/memory/apps/modules/search.py @@ -44,6 +44,7 @@ from aipass.memory.apps.handlers.search.query_executor import ( # COMMAND HANDLERS # ============================================================================= + def handle_command(command: str, args: List[str]) -> bool: """ Handle search commands with seedgo-compliant introspection. @@ -64,18 +65,18 @@ def handle_command(command: str, args: List[str]) -> bool: True if command handled, False otherwise """ # Top-level help (backward compat — entry point may send these) - if command in ('--help', '-h', 'help'): + if command in ("--help", "-h", "help"): print_help() return True - if command == 'search': + if command == "search": # No args → introspection (seedgo standard) if not args: print_introspection() return True # --help / -h / help → full help - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True @@ -87,13 +88,13 @@ def handle_command(command: str, args: List[str]) -> bool: i = 0 while i < len(args): - if args[i] == '--branch' and i + 1 < len(args): + if args[i] == "--branch" and i + 1 < len(args): branch = args[i + 1] i += 2 - elif args[i] == '--type' and i + 1 < len(args): + elif args[i] == "--type" and i + 1 < len(args): memory_type = args[i + 1] i += 2 - elif args[i] == '--n' and i + 1 < len(args): + elif args[i] == "--n" and i + 1 < len(args): try: n_results = int(args[i + 1]) except ValueError: @@ -105,7 +106,7 @@ def handle_command(command: str, args: List[str]) -> bool: query_parts.append(args[i]) i += 1 - query = ' '.join(query_parts) + query = " ".join(query_parts) if not query: error("Search query required") return True @@ -119,11 +120,9 @@ def handle_command(command: str, args: List[str]) -> bool: def print_help() -> None: """Display search module help""" console.print() - console.print(Panel.fit( - "[bold cyan]Search Module - Semantic Memory Search[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print( + Panel.fit("[bold cyan]Search Module - Semantic Memory Search[/bold cyan]", border_style="cyan", box=box.ROUNDED) + ) console.print() console.print("[bold]USAGE:[/bold]") console.print(" drone @memory search [options]") @@ -139,13 +138,13 @@ def print_help() -> None: console.print() console.print("[bold]EXAMPLES:[/bold]") console.print(" # Search all branches") - console.print(" [dim]drone @memory search \"performance patterns\"[/dim]") + console.print(' [dim]drone @memory search "performance patterns"[/dim]') console.print() console.print(" # Search specific branch") - console.print(" [dim]drone @memory search \"registry bugs\" --branch SEEDGO[/dim]") + console.print(' [dim]drone @memory search "registry bugs" --branch SEEDGO[/dim]') console.print() console.print(" # Search specific memory type") - console.print(" [dim]drone @memory search \"collaboration\" --type observations --n 10[/dim]") + console.print(' [dim]drone @memory search "collaboration" --type observations --n 10[/dim]') console.print() console.print("[bold]HOW IT WORKS:[/bold]") console.print(" 1. Convert query to 384-dim embedding (all-MiniLM-L6-v2)") @@ -158,11 +157,9 @@ def print_help() -> None: # SEARCH RESULTS DISPLAY # ============================================================================= + def show_search_results( - query: str, - branch: str | None = None, - memory_type: str | None = None, - n_results: int = 5 + query: str, branch: str | None = None, memory_type: str | None = None, n_results: int = 5 ) -> bool: """ Execute semantic search via handler and display results with Rich. @@ -177,11 +174,7 @@ def show_search_results( True if search successful, False otherwise """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Semantic Search[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Semantic Search[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() # Display query info @@ -196,53 +189,54 @@ def show_search_results( # Delegate to handler try: - result = _handler_execute_search( - query=query, - branch=branch, - memory_type=memory_type, - n_results=n_results - ) + result = _handler_execute_search(query=query, branch=branch, memory_type=memory_type, n_results=n_results) except Exception as exc: logger.error(f"[search] Handler raised exception: {exc}") error(f"Search failed: {exc}") return False - if not result['success']: - error(result.get('error', 'Unknown error')) + if not result["success"]: + error(result.get("error", "Unknown error")) return False - collections_searched = result.get('collections_searched', 0) - total_results = result.get('total_results', 0) - filtered_results = result.get('results', []) + collections_searched = result.get("collections_searched", 0) + total_results = result.get("total_results", 0) + filtered_results = result.get("results", []) # Display summary console.print(f"[green]>[/green] Found {total_results} results in {collections_searched} collections") console.print() if not filtered_results and total_results == 0: - warning("No matching memories found", details="Try different search terms, broader query without filters, or check if memories have been rolled over (drone @memory status)") + warning( + "No matching memories found", + details="Try different search terms, broader query without filters, or check if memories have been rolled over (drone @memory status)", + ) return True if not filtered_results: - warning("No relevant memories found", details="Results found but none relevant enough (>40% similarity). Try more specific search terms.") + warning( + "No relevant memories found", + details="Results found but none relevant enough (>40% similarity). Try more specific search terms.", + ) return True for i, item in enumerate(filtered_results, 1): - collection = item.get('collection', 'unknown') - document = item.get('document', '') - metadata = item.get('metadata', {}) - similarity = item.get('similarity', 0) + collection = item.get("collection", "unknown") + document = item.get("document", "") + metadata = item.get("metadata", {}) + similarity = item.get("similarity", 0) # Parse collection name - parts = collection.split('_') - branch_name = parts[0].upper() if parts else 'UNKNOWN' - mem_type = parts[1] if len(parts) > 1 else 'unknown' + parts = collection.split("_") + branch_name = parts[0].upper() if parts else "UNKNOWN" + mem_type = parts[1] if len(parts) > 1 else "unknown" # Build metadata display meta_lines = [] - if 'timestamp' in metadata: + if "timestamp" in metadata: meta_lines.append(f"[dim]Time:[/dim] {metadata['timestamp']}") - if 'source' in metadata: + if "source" in metadata: meta_lines.append(f"[dim]Source:[/dim] {metadata['source']}") meta_text = " | ".join(meta_lines) if meta_lines else "" @@ -254,12 +248,14 @@ def show_search_results( if meta_text: panel_content += f"\n\n{meta_text}" - console.print(Panel( - panel_content, - title=panel_title, - title_align="left", - border_style="cyan" if similarity > 0.7 else "blue" if similarity > 0.5 else "dim" - )) + console.print( + Panel( + panel_content, + title=panel_title, + title_align="left", + border_style="cyan" if similarity > 0.7 else "blue" if similarity > 0.5 else "dim", + ) + ) console.print() json_handler.log_operation("search_query", {"query": query, "results": len(filtered_results)}) @@ -270,6 +266,7 @@ def show_search_results( # INTROSPECTION # ============================================================================= + def _discover_handlers() -> dict[str, list[str]]: """Auto-discover handler directories and their Python files. @@ -286,10 +283,7 @@ def _discover_handlers() -> dict[str, list[str]]: for d in sorted(handlers_dir.iterdir()): if not d.is_dir() or d.name.startswith("__"): continue - py_files = sorted( - f.name for f in d.iterdir() - if f.is_file() and f.suffix == ".py" and f.name != "__init__.py" - ) + py_files = sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix == ".py" and f.name != "__init__.py") if py_files: result[d.name] = py_files return result @@ -332,12 +326,12 @@ def print_introspection() -> None: if __name__ == "__main__": # No args → introspection (seedgo standard) if len(sys.argv) < 2: - handle_command('search', []) + handle_command("search", []) sys.exit(0) # --help → full help - if sys.argv[1] in ('--help', '-h', 'help'): - handle_command('search', ['--help']) + if sys.argv[1] in ("--help", "-h", "help"): + handle_command("search", ["--help"]) sys.exit(0) # Execute command via handle_command diff --git a/src/aipass/memory/apps/modules/symbolic.py b/src/aipass/memory/apps/modules/symbolic.py index 24652276..0c9db726 100644 --- a/src/aipass/memory/apps/modules/symbolic.py +++ b/src/aipass/memory/apps/modules/symbolic.py @@ -53,6 +53,7 @@ _SUBCOMMANDS = { # PUBLIC API - Delegated to handlers # ============================================================================= + def extract_technical_flow(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: """ Analyze technical patterns from conversation @@ -156,43 +157,38 @@ def analyze_conversation(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]: # STORAGE API - Delegated to handlers # ============================================================================= + def create_fragment( - analysis: Dict[str, Any], - content: str | None = None, - source_branch: str | None = None + analysis: Dict[str, Any], content: str | None = None, source_branch: str | None = None ) -> Dict[str, Any]: """Create fragment from analysis, fire trigger on success""" result = storage.create_fragment(analysis, content, source_branch) - if result.get('success'): + if result.get("success"): try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('fragment_created', - fragment_id=result['fragment'].get('id'), - source_branch=source_branch or 'unknown') + + trigger.fire( + "fragment_created", fragment_id=result["fragment"].get("id"), source_branch=source_branch or "unknown" + ) except Exception as e: logger.warning(f"[symbolic] Trigger fire for fragment_created failed: {e}") return result -def store_fragment( - fragment: Dict[str, Any], - db_path: Path | None = None -) -> Dict[str, Any]: +def store_fragment(fragment: Dict[str, Any], db_path: Path | None = None) -> Dict[str, Any]: """Store fragment in ChromaDB, fire trigger on success""" result = storage.store_fragment(fragment, db_path) - if result.get('success'): + if result.get("success"): try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('fragment_stored', fragment_id=result.get('fragment_id')) + + trigger.fire("fragment_stored", fragment_id=result.get("fragment_id")) except Exception as e: logger.warning(f"[symbolic] Trigger fire for fragment_stored failed: {e}") return result -def store_fragments_batch( - fragments: List[Dict[str, Any]], - db_path: Path | None = None -) -> Dict[str, Any]: +def store_fragments_batch(fragments: List[Dict[str, Any]], db_path: Path | None = None) -> Dict[str, Any]: """ Store multiple fragments in ChromaDB in batch @@ -229,10 +225,9 @@ def flatten_dimensions(fragment: Dict[str, Any]) -> Dict[str, Any]: # v2 LLM PIPELINE - Extract, Deduplicate, Store # ============================================================================= + def store_llm_fragment( - fragment: Dict[str, Any], - source_branch: str | None = None, - db_path: Path | None = None + fragment: Dict[str, Any], source_branch: str | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Store a single LLM-extracted fragment in ChromaDB @@ -249,9 +244,7 @@ def store_llm_fragment( def store_llm_fragments_batch( - fragments: List[Dict[str, Any]], - source_branch: str | None = None, - db_path: Path | None = None + fragments: List[Dict[str, Any]], source_branch: str | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Store multiple LLM-extracted fragments in ChromaDB in batch @@ -267,10 +260,7 @@ def store_llm_fragments_batch( return storage.store_llm_fragments_batch(fragments, source_branch, db_path) -def deduplicate_fragment( - new_fragment: Dict[str, Any], - existing_fragments: List[Dict[str, Any]] -) -> Dict[str, Any]: +def deduplicate_fragment(new_fragment: Dict[str, Any], existing_fragments: List[Dict[str, Any]]) -> Dict[str, Any]: """ Compare a new LLM-extracted fragment against existing ones via AUDN pattern @@ -286,9 +276,7 @@ def deduplicate_fragment( def extract_and_store_llm( - chat_history: List[Dict[str, Any]], - source_branch: str | None = None, - db_path: Path | None = None + chat_history: List[Dict[str, Any]], source_branch: str | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ End-to-end pipeline: extract LLM fragments, deduplicate, and store @@ -312,44 +300,26 @@ def extract_and_store_llm( extract_result = extractor.extract_fragments_llm(chat_history) # Log any per-chunk errors even on partial success - chunk_errors = extract_result.get('chunk_errors', []) + chunk_errors = extract_result.get("chunk_errors", []) if chunk_errors: for ce in chunk_errors: logger.warning(f"[symbolic] Chunk extraction error: {ce}") - if not extract_result.get('success'): - error_msg = extract_result.get('error', 'Unknown extraction error') + if not extract_result.get("success"): + error_msg = extract_result.get("error", "Unknown extraction error") logger.error(f"[symbolic] LLM extraction failed: {error_msg}") try: from aipass.trigger.apps.modules.errors import report_error - report_error( - error_type="ExtractionError", - message=error_msg, - component="memory", - severity="high" - ) + + report_error(error_type="ExtractionError", message=error_msg, component="memory", severity="high") except Exception as e: logger.warning(f"[symbolic] Error report trigger unavailable: {e}") - return { - 'success': False, - 'processed': 0, - 'added': 0, - 'updated': 0, - 'skipped': 0, - 'errors': [error_msg] - } + return {"success": False, "processed": 0, "added": 0, "updated": 0, "skipped": 0, "errors": [error_msg]} - fragments = extract_result.get('fragments', []) + fragments = extract_result.get("fragments", []) if not fragments: logger.info("[symbolic] No fragments extracted from conversation") - return { - 'success': True, - 'processed': 0, - 'added': 0, - 'updated': 0, - 'skipped': 0, - 'errors': [] - } + return {"success": True, "processed": 0, "added": 0, "updated": 0, "skipped": 0, "errors": []} logger.info(f"[symbolic] Extracted {len(fragments)} fragments, starting dedup") @@ -362,55 +332,41 @@ def extract_and_store_llm( for frag in fragments: try: # Find similar existing fragments via vector search - search_query = frag.get('summary', '') - similar_result = retriever.search_by_vector( - search_query, n_results=5, db_path=db_path - ) + search_query = frag.get("summary", "") + similar_result = retriever.search_by_vector(search_query, n_results=5, db_path=db_path) existing = [] - if similar_result.get('success'): - existing = similar_result.get('results', []) + if similar_result.get("success"): + existing = similar_result.get("results", []) # Deduplicate dedup_result = deduplicator.deduplicate_fragment(frag, existing) - action = dedup_result.get('action', 'ADD') - deduped_frag = dedup_result.get('fragment', frag) + action = dedup_result.get("action", "ADD") + deduped_frag = dedup_result.get("fragment", frag) - if action == 'ADD': - store_result = storage.store_llm_fragment( - deduped_frag, source_branch, db_path - ) - if store_result.get('success'): + if action == "ADD": + store_result = storage.store_llm_fragment(deduped_frag, source_branch, db_path) + if store_result.get("success"): added += 1 else: - errors.append( - f"ADD store failed: {store_result.get('error', 'Unknown')}" - ) + errors.append(f"ADD store failed: {store_result.get('error', 'Unknown')}") - elif action == 'UPDATE': - store_result = storage.store_llm_fragment( - deduped_frag, source_branch, db_path - ) - if store_result.get('success'): + elif action == "UPDATE": + store_result = storage.store_llm_fragment(deduped_frag, source_branch, db_path) + if store_result.get("success"): updated += 1 else: - errors.append( - f"UPDATE store failed: {store_result.get('error', 'Unknown')}" - ) + errors.append(f"UPDATE store failed: {store_result.get('error', 'Unknown')}") - elif action == 'DELETE': - delete_id = dedup_result.get('delete_id', '') + elif action == "DELETE": + delete_id = dedup_result.get("delete_id", "") if delete_id: del_result = storage.delete_fragment(delete_id, db_path) - if del_result.get('success'): - logger.info( - f"[symbolic] DELETED {delete_id}: " - f"{dedup_result.get('reason', 'no reason')}" - ) + if del_result.get("success"): + logger.info(f"[symbolic] DELETED {delete_id}: {dedup_result.get('reason', 'no reason')}") else: logger.warning( - f"[symbolic] DELETE failed for {delete_id}: " - f"{del_result.get('error', 'unknown')}" + f"[symbolic] DELETE failed for {delete_id}: {del_result.get('error', 'unknown')}" ) skipped += 1 @@ -429,12 +385,12 @@ def extract_and_store_llm( ) return { - 'success': True, - 'processed': total_processed, - 'added': added, - 'updated': updated, - 'skipped': skipped, - 'errors': errors + "success": True, + "processed": total_processed, + "added": added, + "updated": updated, + "skipped": skipped, + "errors": errors, } @@ -442,12 +398,13 @@ def extract_and_store_llm( # RETRIEVAL API - Delegated to handlers # ============================================================================= + def retrieve_fragments( query: str | None = None, dimension_filters: Dict[str, str] | None = None, trigger_keywords: List[str] | None = None, n_results: int = 5, - db_path: Path | None = None + db_path: Path | None = None, ) -> Dict[str, Any]: """ Retrieve fragments using combined search methods @@ -467,11 +424,7 @@ def retrieve_fragments( return retriever.retrieve_fragments(query, dimension_filters, trigger_keywords, n_results, db_path) -def search_fragments_by_vector( - query: str, - n_results: int = 5, - db_path: Path | None = None -) -> Dict[str, Any]: +def search_fragments_by_vector(query: str, n_results: int = 5, db_path: Path | None = None) -> Dict[str, Any]: """ Search fragments by vector similarity only @@ -487,9 +440,7 @@ def search_fragments_by_vector( def search_fragments_by_dimensions( - dimension_filters: Dict[str, str], - n_results: int = 5, - db_path: Path | None = None + dimension_filters: Dict[str, str], n_results: int = 5, db_path: Path | None = None ) -> Dict[str, Any]: """ Search fragments by dimension filters only @@ -506,9 +457,7 @@ def search_fragments_by_dimensions( def search_fragments_by_triggers( - keywords: List[str], - n_results: int = 5, - db_path: Path | None = None + keywords: List[str], n_results: int = 5, db_path: Path | None = None ) -> Dict[str, Any]: """ Search fragments by trigger keywords only @@ -528,10 +477,8 @@ def search_fragments_by_triggers( # HOOK API - Delegated to handlers # ============================================================================= -def extract_conversation_context( - messages: List[Dict[str, Any]], - max_messages: int = 5 -) -> Dict[str, Any]: + +def extract_conversation_context(messages: List[Dict[str, Any]], max_messages: int = 5) -> Dict[str, Any]: """ Extract keywords, themes, and mood from recent conversation messages @@ -545,11 +492,7 @@ def extract_conversation_context( return hook.extract_conversation_context(messages, max_messages) -def find_relevant_fragments( - context: Dict[str, Any], - n_results: int = 3, - db_path: Path | None = None -) -> Dict[str, Any]: +def find_relevant_fragments(context: Dict[str, Any], n_results: int = 3, db_path: Path | None = None) -> Dict[str, Any]: """ Query fragments based on extracted conversation context @@ -579,10 +522,7 @@ def format_fragment_recall(fragment: Dict[str, Any]) -> str: return hook.format_fragment_recall(fragment) -def should_surface_fragment( - fragment: Dict[str, Any] | None = None, - config: Dict[str, Any] | None = None -) -> tuple: +def should_surface_fragment(fragment: Dict[str, Any] | None = None, config: Dict[str, Any] | None = None) -> tuple: """ Check if a fragment should be surfaced based on rules @@ -597,9 +537,7 @@ def should_surface_fragment( def process_hook( - messages: List[Dict[str, Any]], - config: Dict[str, Any] | None = None, - db_path: Path | None = None + messages: List[Dict[str, Any]], config: Dict[str, Any] | None = None, db_path: Path | None = None ) -> Dict[str, Any]: """ Main hook function - process messages and surface relevant fragments @@ -647,6 +585,7 @@ def get_hook_session_state() -> Dict[str, Any]: # INTROSPECTION (seedgo standard) # ============================================================================= + def _discover_handlers() -> dict[str, list[str]]: """Auto-discover handler directories and their Python files.""" handlers_dir = Path(__file__).resolve().parent.parent / "handlers" @@ -656,10 +595,7 @@ def _discover_handlers() -> dict[str, list[str]]: for d in sorted(handlers_dir.iterdir()): if not d.is_dir() or d.name.startswith("__"): continue - py_files = sorted( - f.name for f in d.iterdir() - if f.is_file() and f.suffix == ".py" and f.name != "__init__.py" - ) + py_files = sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix == ".py" and f.name != "__init__.py") if py_files: result[d.name] = py_files return result @@ -669,7 +605,9 @@ def print_introspection() -> None: """Display module introspection (seedgo standard: no args = structure/discovery).""" console.print() console.print("[bold cyan]symbolic[/bold cyan] — Fragmented Memory Extraction") - console.print("[dim]Extracts symbolic dimensions from conversations and stores as searchable vector fragments[/dim]") + console.print( + "[dim]Extracts symbolic dimensions from conversations and stores as searchable vector fragments[/dim]" + ) console.print() handlers = _discover_handlers() @@ -695,6 +633,7 @@ def print_introspection() -> None: # COMMAND HANDLERS # ============================================================================= + def handle_command(command: str, args: List[str]) -> bool: """ Handle symbolic memory commands @@ -715,18 +654,18 @@ def handle_command(command: str, args: List[str]) -> bool: True if command handled, False otherwise """ # Top-level help (backward compat -- entry point may send these) - if command in ('--help', '-h', 'help'): + if command in ("--help", "-h", "help"): print_help() return True - if command == 'symbolic': + if command == "symbolic": # No args -> introspection (seedgo standard) if not args: print_introspection() return True # --help / -h / help -> full help - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True @@ -734,11 +673,11 @@ def handle_command(command: str, args: List[str]) -> bool: sub = args[0] remaining = args[1:] - if sub == 'demo': + if sub == "demo": run_demo() return True - if sub == 'analyze': + if sub == "analyze": if not remaining: console.print("[red]Error:[/red] File path required") console.print("Usage: symbolic analyze ") @@ -746,7 +685,7 @@ def handle_command(command: str, args: List[str]) -> bool: analyze_file(remaining[0]) return True - if sub == 'extract': + if sub == "extract": if not remaining: console.print("[red]Error:[/red] File path required") console.print("Usage: symbolic extract ") @@ -754,30 +693,30 @@ def handle_command(command: str, args: List[str]) -> bool: extract_file(remaining[0], source_branch=remaining[1] if len(remaining) > 1 else None) return True - if sub == 'bootstrap': + if sub == "bootstrap": max_sessions = 8 for arg in remaining: - if arg.startswith('--max='): - max_sessions = int(arg.split('=')[1]) + if arg.startswith("--max="): + max_sessions = int(arg.split("=")[1]) bootstrap_from_jsonl(max_sessions=max_sessions) return True - if sub == 'fragments': + if sub == "fragments": search_fragments_cli(remaining) return True - if sub == 'hook-test': + if sub == "hook-test": run_hook_test(remaining) return True return False # Backward-compat: direct command routing (entry point may send these) - if command == 'demo': + if command == "demo": run_demo() return True - if command == 'analyze': + if command == "analyze": if not args: console.print("[red]Error:[/red] File path required") console.print("Usage: symbolic analyze ") @@ -785,7 +724,7 @@ def handle_command(command: str, args: List[str]) -> bool: analyze_file(args[0]) return True - if command == 'extract': + if command == "extract": if not args: console.print("[red]Error:[/red] File path required") console.print("Usage: symbolic extract ") @@ -793,19 +732,19 @@ def handle_command(command: str, args: List[str]) -> bool: extract_file(args[0], source_branch=args[1] if len(args) > 1 else None) return True - if command == 'bootstrap': + if command == "bootstrap": max_sessions = 8 for arg in args: - if arg.startswith('--max='): - max_sessions = int(arg.split('=')[1]) + if arg.startswith("--max="): + max_sessions = int(arg.split("=")[1]) bootstrap_from_jsonl(max_sessions=max_sessions) return True - if command == 'fragments': + if command == "fragments": search_fragments_cli(args) return True - if command == 'hook-test': + if command == "hook-test": run_hook_test(args) return True @@ -858,19 +797,21 @@ def print_help() -> None: console.print(" [dim]drone @memory symbolic extract chat_history.json memory[/dim]") console.print() console.print(" # Search fragments by query") - console.print(" [dim]drone @memory symbolic fragments \"debugging frustration\"[/dim]") + console.print(' [dim]drone @memory symbolic fragments "debugging frustration"[/dim]') console.print() console.print(" # Search with dimension filter") - console.print(" [dim]drone @memory symbolic fragments \"debug\" --dimension emotional_0=frustration_to_breakthrough[/dim]") + console.print( + ' [dim]drone @memory symbolic fragments "debug" --dimension emotional_0=frustration_to_breakthrough[/dim]' + ) console.print() console.print(" # Search with trigger keywords") - console.print(" [dim]drone @memory symbolic fragments \"error\" --trigger error --trigger debug[/dim]") + console.print(' [dim]drone @memory symbolic fragments "error" --trigger error --trigger debug[/dim]') console.print() console.print(" # Test hook with sample text") - console.print(" [dim]drone @memory symbolic hook-test \"I'm stuck on this error\"[/dim]") + console.print(' [dim]drone @memory symbolic hook-test "I\'m stuck on this error"[/dim]') console.print() console.print(" # Test hook bypassing cooldown") - console.print(" [dim]drone @memory symbolic hook-test \"debugging frustration\" --bypass[/dim]") + console.print(' [dim]drone @memory symbolic hook-test "debugging frustration" --bypass[/dim]') console.print() @@ -886,21 +827,21 @@ def run_demo() -> None: {"role": "assistant", "content": "Let me help debug this issue. Can you trace where it's failing?"}, {"role": "user", "content": "I tried checking the logs but I'm confused about what's wrong"}, {"role": "assistant", "content": "Let's try a different approach. I'll explain the fix step by step."}, - {"role": "user", "content": "Got it! That works! Finally a breakthrough! This is awesome!"} + {"role": "user", "content": "Got it! That works! Finally a breakthrough! This is awesome!"}, ] console.print("[cyan]Sample conversation:[/cyan]") for msg in demo_chat: - role = msg['role'].capitalize() + role = msg["role"].capitalize() console.print(f" [{role}]: {msg['content'][:60]}...") console.print() # Analyze result = analyze_conversation(demo_chat) - if result['success']: - dims = result['dimensions'] - meta = result['metadata'] + if result["success"]: + dims = result["dimensions"] + meta = result["metadata"] console.print("[green]✓[/green] Analysis complete") console.print() @@ -933,7 +874,7 @@ def run_demo() -> None: "type": "episodic", "triggers": ["error", "debug", "stuck", "breakthrough"], "emotional_tone": "excited", - "technical_domain": "debugging" + "technical_domain": "debugging", }, { "summary": "Collaborative pattern where assistant explains reasoning before giving solutions leads to better understanding", @@ -941,8 +882,8 @@ def run_demo() -> None: "type": "procedural", "triggers": ["explain", "step by step", "understanding"], "emotional_tone": "focused", - "technical_domain": "collaboration" - } + "technical_domain": "collaboration", + }, ] for i, frag in enumerate(mock_fragments, 1): @@ -956,15 +897,15 @@ def run_demo() -> None: # Show how format_fragment_recall would render this mock_stored = { - 'content': frag['summary'], - 'metadata': { - 'schema_version': 'v2', - 'summary': frag['summary'], - 'insight': frag['insight'], - 'type': frag['type'], - 'emotional_tone': frag['emotional_tone'], - 'technical_domain': frag['technical_domain'], - } + "content": frag["summary"], + "metadata": { + "schema_version": "v2", + "summary": frag["summary"], + "insight": frag["insight"], + "type": frag["type"], + "emotional_tone": frag["emotional_tone"], + "technical_domain": frag["technical_domain"], + }, } recall = format_fragment_recall(mock_stored) console.print(f" [green]Recall:[/green] {recall}") @@ -987,21 +928,21 @@ def search_fragments_cli(args: List[str]) -> None: i = 0 while i < len(args): - if args[i] == '--dimension' and i + 1 < len(args): + if args[i] == "--dimension" and i + 1 < len(args): # Parse KEY=VALUE dim_arg = args[i + 1] - if '=' in dim_arg: - key, value = dim_arg.split('=', 1) + if "=" in dim_arg: + key, value = dim_arg.split("=", 1) dimension_filters[key] = value else: console.print(f"[red]Error:[/red] Invalid dimension format: {dim_arg}") console.print("Expected: --dimension KEY=VALUE") return i += 2 - elif args[i] == '--trigger' and i + 1 < len(args): + elif args[i] == "--trigger" and i + 1 < len(args): trigger_keywords.append(args[i + 1]) i += 2 - elif args[i] == '--n' and i + 1 < len(args): + elif args[i] == "--n" and i + 1 < len(args): try: n_results = int(args[i + 1]) except ValueError: @@ -1013,7 +954,7 @@ def search_fragments_cli(args: List[str]) -> None: query_parts.append(args[i]) i += 1 - query = ' '.join(query_parts) if query_parts else None + query = " ".join(query_parts) if query_parts else None if not query and not dimension_filters and not trigger_keywords: console.print("[red]Error:[/red] Search query, dimension filter, or trigger required") @@ -1039,17 +980,17 @@ def search_fragments_cli(args: List[str]) -> None: query=query, dimension_filters=dimension_filters if dimension_filters else None, trigger_keywords=trigger_keywords if trigger_keywords else None, - n_results=n_results + n_results=n_results, ) - if not result.get('success'): - error_msg = result.get('error', 'Unknown error') + if not result.get("success"): + error_msg = result.get("error", "Unknown error") logger.error(f"[symbolic] Fragment search failed: {error_msg}") console.print(f"[red]Error:[/red] {error_msg}") return - results = result.get('results', []) - methods = result.get('search_methods', []) + results = result.get("results", []) + methods = result.get("search_methods", []) console.print(f"[green]Found {len(results)} fragments[/green] (methods: {', '.join(methods)})") console.print() @@ -1065,48 +1006,48 @@ def search_fragments_cli(args: List[str]) -> None: # Display results for i, frag in enumerate(results, 1): - content = frag.get('content', '') - metadata = frag.get('metadata', {}) - relevance = frag.get('relevance_score', frag.get('similarity', 0)) - sources = frag.get('_sources', ['unknown']) - tier = frag.get('relevance_tier', '') + content = frag.get("content", "") + metadata = frag.get("metadata", {}) + relevance = frag.get("relevance_score", frag.get("similarity", 0)) + sources = frag.get("_sources", ["unknown"]) + tier = frag.get("relevance_tier", "") # Build metadata display meta_lines = [] - if metadata.get('timestamp'): + if metadata.get("timestamp"): meta_lines.append(f"Time: {metadata['timestamp']}") - if metadata.get('source_branch'): + if metadata.get("source_branch"): meta_lines.append(f"Branch: {metadata['source_branch']}") # Schema-aware metadata display - if metadata.get('schema_version') == 'v2': + if metadata.get("schema_version") == "v2": # v2 fragment: show summary, insight, type, tone, domain - if metadata.get('type'): + if metadata.get("type"): meta_lines.append(f"Type: {metadata['type']}") - if metadata.get('emotional_tone'): + if metadata.get("emotional_tone"): meta_lines.append(f"Tone: {metadata['emotional_tone']}") - if metadata.get('technical_domain'): + if metadata.get("technical_domain"): meta_lines.append(f"Domain: {metadata['technical_domain']}") meta_text = " | ".join(meta_lines) if meta_lines else "" # Build rich content for v2 panel_content = "" - if metadata.get('summary'): + if metadata.get("summary"): panel_content += f"[bold]Summary:[/bold] {metadata['summary']}\n" - if metadata.get('insight'): + if metadata.get("insight"): panel_content += f"[bold]Insight:[/bold] {metadata['insight']}\n" - if content and content != metadata.get('summary', ''): + if content and content != metadata.get("summary", ""): panel_content += f"\n> {content}\n" if meta_text: panel_content += f"\n[dim]{meta_text}[/dim]" else: # v1 fragment: show dimensions - if metadata.get('depth'): + if metadata.get("depth"): meta_lines.append(f"Depth: {metadata['depth']}") dim_parts = [] - for key in ['technical_0', 'emotional_0', 'collaboration_0', 'learnings_0']: + for key in ["technical_0", "emotional_0", "collaboration_0", "learnings_0"]: if key in metadata: dim_parts.append(f"{key.replace('_0', '')}: {metadata[key]}") if dim_parts: @@ -1118,16 +1059,18 @@ def search_fragments_cli(args: List[str]) -> None: if meta_text: panel_content += f"\n\n[dim]{meta_text}[/dim]" - schema_tag = "v2" if metadata.get('schema_version') == 'v2' else "v1" + schema_tag = "v2" if metadata.get("schema_version") == "v2" else "v1" tier_tag = f" [{tier}]" if tier else "" panel_title = f"Result {i} ({schema_tag}) - Relevance: {relevance:.2%}{tier_tag} (via {', '.join(sources)})" - console.print(Panel( - panel_content, - title=panel_title, - title_align="left", - border_style="cyan" if relevance > 0.7 else "blue" if relevance > 0.5 else "dim" - )) + console.print( + Panel( + panel_content, + title=panel_title, + title_align="left", + border_style="cyan" if relevance > 0.7 else "blue" if relevance > 0.5 else "dim", + ) + ) console.print() logger.info(f"[symbolic] Displayed {len(results)} fragment results") @@ -1143,21 +1086,19 @@ def run_hook_test(args: List[str]) -> None: bypass_checks = False for arg in args: - if arg == '--bypass': + if arg == "--bypass": bypass_checks = True else: text_parts.append(arg) - text = ' '.join(text_parts) if text_parts else "I'm stuck on this error and need help debugging" + text = " ".join(text_parts) if text_parts else "I'm stuck on this error and need help debugging" console.print() header("Fragmented Memory Hook - Test") console.print() # Build sample messages from text - messages = [ - {"role": "user", "content": text} - ] + messages = [{"role": "user", "content": text}] console.print(f"[cyan]Test input:[/cyan] {text}") console.print(f"[cyan]Bypass checks:[/cyan] {bypass_checks}") @@ -1168,14 +1109,14 @@ def run_hook_test(args: List[str]) -> None: # If bypassing, set session state to allow surfacing if bypass_checks: - hook.SESSION_STATE['messages_since_last'] = 100 - hook.SESSION_STATE['last_surface_time'] = 0 + hook.SESSION_STATE["messages_since_last"] = 100 + hook.SESSION_STATE["last_surface_time"] = 0 # Extract context first console.print("[bold]Step 1: Context Extraction[/bold]") context = extract_conversation_context(messages) - if context.get('success'): + if context.get("success"): console.print(f" [green]Keywords:[/green] {context.get('keywords', [])}") console.print(f" [green]Mood:[/green] {context.get('mood', 'neutral')}") console.print(f" [green]Themes:[/green] {context.get('themes', [])}") @@ -1189,16 +1130,16 @@ def run_hook_test(args: List[str]) -> None: console.print("[bold]Step 2: Fragment Search[/bold]") frag_result = find_relevant_fragments(context, n_results=3) - if frag_result.get('success'): - fragments = frag_result.get('fragments', []) + if frag_result.get("success"): + fragments = frag_result.get("fragments", []) console.print(f" [green]Query used:[/green] {frag_result.get('query_used', '')}") console.print(f" [green]Threshold:[/green] {frag_result.get('threshold_applied', 0.3)}") console.print(f" [green]Fragments found:[/green] {len(fragments)}") if fragments: for i, frag in enumerate(fragments, 1): - score = frag.get('relevance_score', frag.get('similarity', 0)) - content = frag.get('content', '')[:80] + score = frag.get("relevance_score", frag.get("similarity", 0)) + content = frag.get("content", "")[:80] console.print(f" [{i}] Score: {score:.2%} - {content}...") else: console.print(f" [yellow]No fragments:[/yellow] {frag_result.get('message', frag_result.get('error', ''))}") @@ -1217,17 +1158,13 @@ def run_hook_test(args: List[str]) -> None: console.print("[bold]Step 4: Full Hook Process[/bold]") result = process_hook(messages) - if result.get('success'): - if result.get('surfaced'): + if result.get("success"): + if result.get("surfaced"): console.print("[green]Fragment surfaced![/green]") console.print() - recall = result.get('recall', '') - console.print(Panel( - recall, - title="Memory Recall", - border_style="green" - )) + recall = result.get("recall", "") + console.print(Panel(recall, title="Memory Recall", border_style="green")) console.print() console.print(f"[dim]Fragment ID: {result.get('fragment_id')}[/dim]") @@ -1241,10 +1178,10 @@ def run_hook_test(args: List[str]) -> None: # v2 Format Preview - show how found fragments would look with v2 formatting console.print("[bold]Step 5: v2 Format Preview[/bold]") - if frag_result.get('success') and frag_result.get('fragments'): - for i, frag in enumerate(frag_result['fragments'], 1): - frag_metadata = frag.get('metadata', {}) - if frag_metadata.get('schema_version') == 'v2': + if frag_result.get("success") and frag_result.get("fragments"): + for i, frag in enumerate(frag_result["fragments"], 1): + frag_metadata = frag.get("metadata", {}) + if frag_metadata.get("schema_version") == "v2": recall_preview = format_fragment_recall(frag) console.print(f" [green]Fragment {i} (v2):[/green] {recall_preview}") else: @@ -1262,7 +1199,9 @@ def run_hook_test(args: List[str]) -> None: console.print(f" [dim]Fragments surfaced:[/dim] {state.get('fragments_surfaced', 0)}") console.print(f" [dim]Messages since last:[/dim] {state.get('messages_since_last', 0)}") console.print() - json_handler.log_operation("symbolic_hook_test", {"surfaced": result.get('surfaced', False), "success": result.get('success', False)}) + json_handler.log_operation( + "symbolic_hook_test", {"surfaced": result.get("surfaced", False), "success": result.get("success", False)} + ) def analyze_file(file_path: str) -> None: @@ -1275,11 +1214,11 @@ def analyze_file(file_path: str) -> None: return read_result = memory_files.read_memory_file(path) - if not read_result.get('success'): + if not read_result.get("success"): console.print(f"[red]Error:[/red] {read_result.get('error', 'Failed to read JSON')}") return - chat_history = read_result.get('data') + chat_history = read_result.get("data") if not isinstance(chat_history, list): console.print("[red]Error:[/red] Expected JSON array of messages") @@ -1291,9 +1230,9 @@ def analyze_file(file_path: str) -> None: result = analyze_conversation(chat_history) - if result['success']: - dims = result['dimensions'] - meta = result['metadata'] + if result["success"]: + dims = result["dimensions"] + meta = result["metadata"] console.print("[green]✓[/green] Analysis complete") console.print() @@ -1311,7 +1250,7 @@ def analyze_file(file_path: str) -> None: console.print(f" [dim]Words:[/dim] {meta.get('total_words', 0)}") console.print(f" [dim]Depth:[/dim] {meta.get('depth', 'unknown')}") console.print() - json_handler.log_operation("symbolic_analyze", {"file": path.name, "messages": result['message_count']}) + json_handler.log_operation("symbolic_analyze", {"file": path.name, "messages": result["message_count"]}) else: console.print(f"[red]✗[/red] Analysis failed: {result.get('error', 'Unknown error')}") @@ -1335,11 +1274,11 @@ def extract_file(file_path: str, source_branch: str | None = None) -> None: return read_result = memory_files.read_memory_file(path) - if not read_result.get('success'): + if not read_result.get("success"): console.print(f"[red]Error:[/red] {read_result.get('error', 'Failed to read JSON')}") return - chat_history = read_result.get('data') + chat_history = read_result.get("data") if not isinstance(chat_history, list): console.print("[red]Error:[/red] Expected JSON array of messages") @@ -1359,7 +1298,7 @@ def extract_file(file_path: str, source_branch: str | None = None) -> None: result = extract_and_store_llm(chat_history, source_branch=source_branch) - if result.get('success'): + if result.get("success"): console.print("[green]Pipeline complete[/green]") console.print() console.print(f" [cyan]Processed:[/cyan] {result.get('processed', 0)}") @@ -1367,18 +1306,21 @@ def extract_file(file_path: str, source_branch: str | None = None) -> None: console.print(f" [yellow]Updated:[/yellow] {result.get('updated', 0)}") console.print(f" [dim]Skipped:[/dim] {result.get('skipped', 0)}") - if result.get('errors'): + if result.get("errors"): console.print() console.print(f" [red]Errors ({len(result['errors'])}):[/red]") - for err in result['errors']: + for err in result["errors"]: console.print(f" - {err}") else: console.print(f"[red]Pipeline failed:[/red] {result.get('errors', ['Unknown error'])}") console.print() logger.info(f"[symbolic] extract_file complete: {result}") - if result.get('success'): - json_handler.log_operation("symbolic_extract", {"file": path.name, "added": result.get('added', 0), "updated": result.get('updated', 0)}) + if result.get("success"): + json_handler.log_operation( + "symbolic_extract", + {"file": path.name, "added": result.get("added", 0), "updated": result.get("updated", 0)}, + ) # ============================================================================= @@ -1402,7 +1344,7 @@ def _parse_jsonl_to_chat_history(jsonl_path: Path) -> List[Dict[str, Any]]: """ messages = [] try: - with open(jsonl_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(jsonl_path, "r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() if not line: @@ -1413,38 +1355,34 @@ def _parse_jsonl_to_chat_history(jsonl_path: Path) -> List[Dict[str, Any]]: logger.info("[symbolic] Skipping malformed JSONL line during bootstrap parse") continue - msg_type = entry.get('type', '') - msg_data = entry.get('message', {}) - role = msg_data.get('role', '') - content = msg_data.get('content', '') + msg_type = entry.get("type", "") + msg_data = entry.get("message", {}) + role = msg_data.get("role", "") + content = msg_data.get("content", "") - if msg_type == 'user' and role == 'user': + if msg_type == "user" and role == "user": if isinstance(content, str) and content.strip(): - messages.append({'role': 'user', 'content': content.strip()}) + messages.append({"role": "user", "content": content.strip()}) elif isinstance(content, list): texts = [ - item.get('text', '').strip() + item.get("text", "").strip() for item in content - if isinstance(item, dict) - and item.get('type') == 'text' - and item.get('text', '').strip() + if isinstance(item, dict) and item.get("type") == "text" and item.get("text", "").strip() ] if texts: - messages.append({'role': 'user', 'content': ' '.join(texts)}) + messages.append({"role": "user", "content": " ".join(texts)}) - elif msg_type == 'assistant' and role == 'assistant': + elif msg_type == "assistant" and role == "assistant": if isinstance(content, str) and content.strip(): - messages.append({'role': 'assistant', 'content': content.strip()}) + messages.append({"role": "assistant", "content": content.strip()}) elif isinstance(content, list): texts = [ - item.get('text', '').strip() + item.get("text", "").strip() for item in content - if isinstance(item, dict) - and item.get('type') == 'text' - and item.get('text', '').strip() + if isinstance(item, dict) and item.get("type") == "text" and item.get("text", "").strip() ] if texts: - messages.append({'role': 'assistant', 'content': ' '.join(texts)}) + messages.append({"role": "assistant", "content": " ".join(texts)}) except OSError as e: logger.error(f"[symbolic] Failed to read JSONL: {e}") @@ -1471,14 +1409,14 @@ def _find_bootstrap_sessions(max_sessions: int = 8) -> List[Path]: # Priority branch directories (diverse content sources) priority_dirs = [ - '-home-patrick-Projects-AIPass-src-aipass-memory', - '-home-patrick-Projects-AIPass-src-aipass-devpulse', - '-home-patrick-Projects-AIPass-src-aipass-seedgo', - '-home-patrick-Projects-AIPass-src-aipass-drone', - '-home-patrick-Projects-AIPass-src-aipass-flow', - '-home-patrick-Projects-AIPass-src-aipass-prax', - '-home-patrick-Projects-AIPass-src-aipass-ai-mail', - '-home-patrick-Projects-AIPass-src-aipass-api', + "-home-patrick-Projects-AIPass-src-aipass-memory", + "-home-patrick-Projects-AIPass-src-aipass-devpulse", + "-home-patrick-Projects-AIPass-src-aipass-seedgo", + "-home-patrick-Projects-AIPass-src-aipass-drone", + "-home-patrick-Projects-AIPass-src-aipass-flow", + "-home-patrick-Projects-AIPass-src-aipass-prax", + "-home-patrick-Projects-AIPass-src-aipass-ai-mail", + "-home-patrick-Projects-AIPass-src-aipass-api", ] selected = [] @@ -1564,12 +1502,12 @@ def bootstrap_from_jsonl(max_sessions: int = 8) -> None: # Derive branch name from parent directory branch_dir = jsonl_path.parent.name # New layout: -home-patrick-Projects-AIPass-src-aipass- - branch_name = branch_dir.rsplit('-aipass-', 1)[-1].replace('-', '_').upper() + branch_name = branch_dir.rsplit("-aipass-", 1)[-1].replace("-", "_").upper() # Legacy layout fallback - if branch_name.startswith('AIPASS_CORE_'): - branch_name = branch_name.replace('AIPASS_CORE_', '') - if branch_name.startswith('AIPASS_OS_'): - branch_name = branch_name.replace('AIPASS_OS_', '') + if branch_name.startswith("AIPASS_CORE_"): + branch_name = branch_name.replace("AIPASS_CORE_", "") + if branch_name.startswith("AIPASS_OS_"): + branch_name = branch_name.replace("AIPASS_OS_", "") file_size_kb = jsonl_path.stat().st_size / 1024 console.print( @@ -1585,22 +1523,16 @@ def bootstrap_from_jsonl(max_sessions: int = 8) -> None: continue console.print(f" {len(chat_history)} messages, extracting...") - logger.info( - f"[symbolic] bootstrap [{i}/{len(sessions)}]: " - f"{branch_name} ({len(chat_history)} msgs)" - ) + logger.info(f"[symbolic] bootstrap [{i}/{len(sessions)}]: {branch_name} ({len(chat_history)} msgs)") # Run the extraction pipeline - result = extract_and_store_llm( - chat_history, - source_branch=branch_name - ) + result = extract_and_store_llm(chat_history, source_branch=branch_name) - if result.get('success'): - a = result.get('added', 0) - u = result.get('updated', 0) - s = result.get('skipped', 0) - e = len(result.get('errors', [])) + if result.get("success"): + a = result.get("added", 0) + u = result.get("updated", 0) + s = result.get("skipped", 0) + e = len(result.get("errors", [])) total_added += a total_updated += u total_skipped += s @@ -1614,7 +1546,7 @@ def bootstrap_from_jsonl(max_sessions: int = 8) -> None: ) else: total_errors += 1 - err_msg = result.get('errors', ['Unknown']) + err_msg = result.get("errors", ["Unknown"]) console.print(f" [red]Failed: {err_msg}[/red]") # Brief pause between API calls to avoid rate limiting @@ -1636,10 +1568,9 @@ def bootstrap_from_jsonl(max_sessions: int = 8) -> None: # Verify collection count try: import chromadb - client = chromadb.PersistentClient( - path=str(Path(__file__).resolve().parent.parent.parent / '.chroma') - ) - col = client.get_collection('symbolic_fragments') + + client = chromadb.PersistentClient(path=str(Path(__file__).resolve().parent.parent.parent / ".chroma")) + col = client.get_collection("symbolic_fragments") console.print(f" [bold green]Collection total: {col.count()} fragments[/bold green]") except Exception as e: logger.warning(f"[symbolic] Failed to read ChromaDB collection count: {e}") @@ -1650,7 +1581,9 @@ def bootstrap_from_jsonl(max_sessions: int = 8) -> None: f"{total_added} added, {total_updated} updated, " f"{total_skipped} skipped, {total_errors} errors" ) - json_handler.log_operation("symbolic_bootstrap", {"sessions": processed_count, "added": total_added, "errors": total_errors}) + json_handler.log_operation( + "symbolic_bootstrap", {"sessions": processed_count, "added": total_added, "errors": total_errors} + ) # ============================================================================= @@ -1659,8 +1592,8 @@ def bootstrap_from_jsonl(max_sessions: int = 8) -> None: if __name__ == "__main__": # Handle --help before argparse (module standard) - if len(sys.argv) < 2 or sys.argv[1] in ('--help', '-h', 'help'): - handle_command('help', []) + if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"): + handle_command("help", []) sys.exit(0) # Execute command via handle_command diff --git a/src/aipass/memory/apps/modules/templates.py b/src/aipass/memory/apps/modules/templates.py index 006b03b4..e3437e36 100644 --- a/src/aipass/memory/apps/modules/templates.py +++ b/src/aipass/memory/apps/modules/templates.py @@ -87,18 +87,18 @@ def handle_command(command: str, args: List[str]) -> bool: True if command handled, False otherwise """ # Top-level help (backward compat -- entry point may send these) - if command in ('--help', '-h', 'help'): + if command in ("--help", "-h", "help"): print_help() return True - if command == 'templates': + if command == "templates": # No args -> introspection (seedgo standard) if not args: print_introspection() return True # --help / -h / help -> full help - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True @@ -106,8 +106,8 @@ def handle_command(command: str, args: List[str]) -> bool: sub = args[0] remaining = args[1:] - if sub == 'push-templates': - dry_run = '--dry-run' in remaining + if sub == "push-templates": + dry_run = "--dry-run" in remaining try: _display_push_results(push_templates(dry_run=dry_run), dry_run) except Exception as e: @@ -121,11 +121,11 @@ def handle_command(command: str, args: List[str]) -> bool: logger.error(f"[templates] spawn push crashed: {e}") return True - if sub == 'diff-templates': + if sub == "diff-templates": branch_name: str | None = None i = 0 while i < len(remaining): - if remaining[i] == '--branch' and i + 1 < len(remaining): + if remaining[i] == "--branch" and i + 1 < len(remaining): branch_name = remaining[i + 1] i += 2 else: @@ -133,7 +133,7 @@ def handle_command(command: str, args: List[str]) -> bool: _display_diff_results(branch_name) return True - if sub == 'template-status': + if sub == "template-status": try: _display_status(get_template_status()) except Exception as e: @@ -149,8 +149,8 @@ def handle_command(command: str, args: List[str]) -> bool: return True # Backward-compatible top-level commands (entry point still routes these) - if command == 'push-templates': - dry_run = '--dry-run' in args + if command == "push-templates": + dry_run = "--dry-run" in args try: _display_push_results(push_templates(dry_run=dry_run), dry_run) except Exception as e: @@ -164,11 +164,11 @@ def handle_command(command: str, args: List[str]) -> bool: logger.error(f"[templates] spawn push crashed: {e}") return True - elif command == 'diff-templates': + elif command == "diff-templates": branch_name = None i = 0 while i < len(args): - if args[i] == '--branch' and i + 1 < len(args): + if args[i] == "--branch" and i + 1 < len(args): branch_name = args[i + 1] i += 2 else: @@ -176,7 +176,7 @@ def handle_command(command: str, args: List[str]) -> bool: _display_diff_results(branch_name) return True - elif command == 'template-status': + elif command == "template-status": try: _display_status(get_template_status()) except Exception as e: @@ -190,11 +190,11 @@ def handle_command(command: str, args: List[str]) -> bool: def print_help() -> None: """Display templates module help""" console.print() - console.print(Panel.fit( - "[bold cyan]Templates Module - Living Template Management[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print( + Panel.fit( + "[bold cyan]Templates Module - Living Template Management[/bold cyan]", border_style="cyan", box=box.ROUNDED + ) + ) console.print() console.print("[bold]USAGE:[/bold]") console.print(" drone @memory templates ") @@ -213,24 +213,23 @@ def print_help() -> None: # DISPLAY: PUSH RESULTS # ============================================================================= + def _display_push_results(result: dict, dry_run: bool) -> None: """Format and display push_templates() handler result.""" console.print() mode_label = "DRY RUN" if dry_run else "Push" - console.print(Panel.fit( - f"[bold cyan]Memory - Template {mode_label}[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print( + Panel.fit(f"[bold cyan]Memory - Template {mode_label}[/bold cyan]", border_style="cyan", box=box.ROUNDED) + ) console.print() if dry_run: console.print("[yellow]DRY RUN MODE[/yellow] - no files will be modified") console.print() - if not result.get('success'): + if not result.get("success"): error("Template push failed") - for err in result.get('errors', []): + for err in result.get("errors", []): error(err) logger.error(f"[templates] Push failed: {result.get('errors')}") console.print() @@ -243,14 +242,14 @@ def _display_push_results(result: dict, dry_run: bool) -> None: console.print() # Change details per branch/file - changes = result.get('changes', []) + changes = result.get("changes", []) if changes: console.print(f"[yellow]Changes ({len(changes)} files):[/yellow]") console.print() for entry in changes: - branch = entry.get('branch', 'UNKNOWN') - file_name = entry.get('file', 'unknown') - file_changes = entry.get('changes', []) + branch = entry.get("branch", "UNKNOWN") + file_name = entry.get("file", "unknown") + file_changes = entry.get("changes", []) console.print(f" [bold]{branch}[/bold]/{file_name}:") for chg in file_changes: console.print(f" [green]+[/green] {chg}") @@ -260,7 +259,7 @@ def _display_push_results(result: dict, dry_run: bool) -> None: console.print() # Errors - errors = result.get('errors', []) + errors = result.get("errors", []) if errors: console.print(f"[red]Errors ({len(errors)}):[/red]") for err in errors: @@ -269,16 +268,13 @@ def _display_push_results(result: dict, dry_run: bool) -> None: logger.error(f"[templates] Push completed with {len(errors)} errors") # Final status - if result['branches_updated'] > 0 and not dry_run: + if result["branches_updated"] > 0 and not dry_run: console.print( f"[green]Template push complete:[/green] " f"{result['branches_updated']}/{result['branches_scanned']} branches updated" ) elif dry_run and changes: - console.print( - f"[yellow]Dry run complete:[/yellow] " - f"{result['branches_updated']} branches would be updated" - ) + console.print(f"[yellow]Dry run complete:[/yellow] {result['branches_updated']} branches would be updated") else: console.print("[green]No updates needed.[/green]") @@ -287,7 +283,9 @@ def _display_push_results(result: dict, dry_run: bool) -> None: f"{result['branches_updated']}/{result['branches_scanned']} branches, " f"{result['files_modified']} files" ) - json_handler.log_operation("templates_push", {"branches_updated": result['branches_updated'], "files_modified": result['files_modified']}) + json_handler.log_operation( + "templates_push", {"branches_updated": result["branches_updated"], "files_modified": result["files_modified"]} + ) console.print() @@ -295,6 +293,7 @@ def _display_push_results(result: dict, dry_run: bool) -> None: # DISPLAY: SPAWN PUSH RESULTS # ============================================================================= + def _display_spawn_push_results(result: dict, dry_run: bool) -> None: """Format and display spawn template push results.""" if not result.get("success"): @@ -311,14 +310,10 @@ def _display_spawn_push_results(result: dict, dry_run: bool) -> None: return mode = "would update" if dry_run else "updated" - console.print( - f"[cyan]Spawn templates:[/cyan] {sets_updated}/{len(sets_found)} sets {mode}, " - f"{files_mod} files" - ) + console.print(f"[cyan]Spawn templates:[/cyan] {sets_updated}/{len(sets_found)} sets {mode}, {files_mod} files") for change in result.get("changes", []): console.print( - f" [green]+[/green] {change.get('template_set')}/{change.get('file')} " - f"[dim]({change.get('action')})[/dim]" + f" [green]+[/green] {change.get('template_set')}/{change.get('file')} [dim]({change.get('action')})[/dim]" ) logger.info( @@ -331,6 +326,7 @@ def _display_spawn_push_results(result: dict, dry_run: bool) -> None: # DISPLAY: DIFF RESULTS # ============================================================================= + def _display_diff_results(branch_name: str | None = None) -> None: """ Call differ handler for all/specific branches, display results. @@ -339,11 +335,7 @@ def _display_diff_results(branch_name: str | None = None) -> None: branch_name: Optional branch name filter (None = all branches) """ console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Template Diff[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Template Diff[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() # Load registry to get branch paths @@ -356,10 +348,7 @@ def _display_diff_results(branch_name: str | None = None) -> None: # Filter if branch specified if branch_name: - branches = [ - b for b in branches - if b.get('name', '').upper() == branch_name.upper() - ] + branches = [b for b in branches if b.get("name", "").upper() == branch_name.upper()] if not branches: error(f"Branch not found: {branch_name}") console.print() @@ -373,8 +362,8 @@ def _display_diff_results(branch_name: str | None = None) -> None: total_errors = 0 for branch in branches: - name = branch.get('name', 'UNKNOWN') - path = branch.get('path', '') + name = branch.get("name", "UNKNOWN") + path = branch.get("path", "") if not path or not Path(path).exists(): error(f"{name}: path not found ({path})") @@ -391,9 +380,9 @@ def _display_diff_results(branch_name: str | None = None) -> None: total_errors += 1 continue - local_diffs = result.get('local', []) - obs_diffs = result.get('observations', []) - errors = result.get('errors', []) + local_diffs = result.get("local", []) + obs_diffs = result.get("observations", []) + errors = result.get("errors", []) branch_has_diffs = bool(local_diffs or obs_diffs) if branch_has_diffs: @@ -426,7 +415,9 @@ def _display_diff_results(branch_name: str | None = None) -> None: console.print(f"[red]{total_errors} errors encountered[/red]") logger.info(f"[templates] Diff complete: {total_diffs} branches with diffs, {total_errors} errors") - json_handler.log_operation("templates_diff", {"branches_compared": len(branches), "branches_with_diffs": total_diffs}) + json_handler.log_operation( + "templates_diff", {"branches_compared": len(branches), "branches_with_diffs": total_diffs} + ) console.print() @@ -434,14 +425,14 @@ def _display_file_diffs(file_diffs: list) -> None: """Display diff entries for a list of files.""" for entry in file_diffs: console.print(f" [dim]{entry['file']}:[/dim]") - if entry.get('additions'): - for a in entry['additions']: + if entry.get("additions"): + for a in entry["additions"]: console.print(f" [green]+ {a}[/green]") - if entry.get('removals'): - for r in entry['removals']: + if entry.get("removals"): + for r in entry["removals"]: console.print(f" [red]- {r}[/red]") - if entry.get('modifications'): - for m in entry['modifications']: + if entry.get("modifications"): + for m in entry["modifications"]: console.print(f" [yellow]~ {m}[/yellow]") @@ -449,19 +440,16 @@ def _display_file_diffs(file_diffs: list) -> None: # DISPLAY: STATUS # ============================================================================= + def _display_status(status: dict) -> None: """Format and display get_template_status() handler result.""" console.print() - console.print(Panel.fit( - "[bold cyan]Memory - Template Status[/bold cyan]", - border_style="cyan", - box=box.ROUNDED - )) + console.print(Panel.fit("[bold cyan]Memory - Template Status[/bold cyan]", border_style="cyan", box=box.ROUNDED)) console.print() # Template files - local_icon = "[green]found[/green]" if status.get('local_template_exists') else "[red]MISSING[/red]" - obs_icon = "[green]found[/green]" if status.get('observations_template_exists') else "[red]MISSING[/red]" + local_icon = "[green]found[/green]" if status.get("local_template_exists") else "[red]MISSING[/red]" + obs_icon = "[green]found[/green]" if status.get("observations_template_exists") else "[red]MISSING[/red]" console.print(f"[cyan]Templates directory:[/cyan] {status.get('templates_dir', 'unknown')}") console.print(f"[cyan]LOCAL template:[/cyan] {local_icon}") @@ -469,16 +457,16 @@ def _display_status(status: dict) -> None: console.print() # Version info - version = status.get('version') or 'unknown' - last_push = status.get('last_push') or 'never' + version = status.get("version") or "unknown" + last_push = status.get("last_push") or "never" console.print(f"[cyan]Schema version:[/cyan] {version}") console.print(f"[cyan]Last push:[/cyan] {last_push}") # Branches pushed - pushed = status.get('last_push_branches', []) + pushed = status.get("last_push_branches", []) if pushed: - preview = ', '.join(pushed[:8]) - suffix = f'... (+{len(pushed) - 8} more)' if len(pushed) > 8 else '' + preview = ", ".join(pushed[:8]) + suffix = f"... (+{len(pushed) - 8} more)" if len(pushed) > 8 else "" console.print(f"[cyan]Branches pushed:[/cyan] {len(pushed)} ({preview}{suffix})") else: console.print("[cyan]Branches pushed:[/cyan] none") @@ -492,6 +480,7 @@ def _display_status(status: dict) -> None: # HELPERS # ============================================================================= + def _load_branches_from_registry() -> list | None: """ Load active branches from AIPASS_REGISTRY.json. @@ -507,15 +496,15 @@ def _load_branches_from_registry() -> list | None: data = read_memory_file_data(REGISTRY_PATH) if data is None: return None - branches = data.get('branches', []) + branches = data.get("branches", []) # Resolve relative paths against repo root for branch in branches: - raw_path = branch.get('path', '') + raw_path = branch.get("path", "") resolved = Path(raw_path) if not resolved.is_absolute(): resolved = _REPO_ROOT / raw_path - branch['path'] = str(resolved) - return [b for b in branches if b.get('status') == 'active'] + branch["path"] = str(resolved) + return [b for b in branches if b.get("status") == "active"] except Exception as e: logger.error(f"[templates] Failed to load AIPASS_REGISTRY.json: {e}") return None @@ -525,6 +514,7 @@ def _load_branches_from_registry() -> list | None: # INTROSPECTION # ============================================================================= + def _discover_handlers() -> dict[str, list[str]]: """Auto-discover handler directories and their Python files. @@ -541,10 +531,7 @@ def _discover_handlers() -> dict[str, list[str]]: for d in sorted(handlers_dir.iterdir()): if not d.is_dir() or d.name.startswith("__"): continue - py_files = sorted( - f.name for f in d.iterdir() - if f.is_file() and f.suffix == ".py" and f.name != "__init__.py" - ) + py_files = sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix == ".py" and f.name != "__init__.py") if py_files: result[d.name] = py_files return result @@ -597,12 +584,12 @@ if __name__ == "__main__": # No args -> introspection (seedgo standard) if len(sys.argv) < 2: - handle_command('templates', []) + handle_command("templates", []) sys.exit(0) # --help -> full help - if sys.argv[1] in ('--help', '-h', 'help'): - handle_command('templates', ['--help']) + if sys.argv[1] in ("--help", "-h", "help"): + handle_command("templates", ["--help"]) sys.exit(0) # Execute command via handle_command diff --git a/src/aipass/memory/apps/modules/verify.py b/src/aipass/memory/apps/modules/verify.py index 0fbdc812..f62a5179 100644 --- a/src/aipass/memory/apps/modules/verify.py +++ b/src/aipass/memory/apps/modules/verify.py @@ -54,6 +54,7 @@ def _get_memory_python() -> str: # COMMAND HANDLERS # ============================================================================= + def handle_command(command: str, args: List[str]) -> bool: """ Handle verify commands with seedgo-compliant introspection. @@ -71,18 +72,18 @@ def handle_command(command: str, args: List[str]) -> bool: True if command handled, False otherwise """ # Top-level help (backward compat -- entry point may send these) - if command in ('--help', '-h', 'help'): + if command in ("--help", "-h", "help"): print_help() return True - if command == 'verify': + if command == "verify": # No args -> introspection (seedgo standard) if not args: print_introspection() return True # --help / -h / help -> full help - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True @@ -98,6 +99,7 @@ def handle_command(command: str, args: List[str]) -> bool: # VERIFICATION LOGIC # ============================================================================= + def _check_plan_subprocess(plan_label: str) -> dict: """ Check plan vectorization via chroma_subprocess. @@ -109,33 +111,31 @@ def _check_plan_subprocess(plan_label: str) -> dict: Dict with success, found, count, source_files """ python_path = _get_memory_python() - input_data = json.dumps({ - 'operation': 'check_plan', - 'plan_label': plan_label, - }) + input_data = json.dumps( + { + "operation": "check_plan", + "plan_label": plan_label, + } + ) try: result = subprocess.run( - [python_path, str(CHROMA_SUBPROCESS_SCRIPT)], - input=input_data, - capture_output=True, - text=True, - timeout=60 + [python_path, str(CHROMA_SUBPROCESS_SCRIPT)], input=input_data, capture_output=True, text=True, timeout=60 ) if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Subprocess failed'} + return {"success": False, "error": result.stderr or "Subprocess failed"} return json.loads(result.stdout) except subprocess.TimeoutExpired: logger.warning("[verify] Plan check subprocess timed out") - return {'success': False, 'error': 'Check operation timed out'} + return {"success": False, "error": "Check operation timed out"} except json.JSONDecodeError as e: logger.warning(f"[verify] Invalid JSON from plan check subprocess: {e}") - return {'success': False, 'error': f'Invalid JSON response: {e}'} + return {"success": False, "error": f"Invalid JSON response: {e}"} except Exception as e: logger.error(f"[verify] Plan check subprocess failed: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} def is_plan_vectorized(plan_label: str) -> dict: @@ -162,13 +162,13 @@ def _verify_plan(plan_label: str) -> None: """ result = _check_plan_subprocess(plan_label) - if not result.get('success'): - error(result.get('error', 'Unknown error')) + if not result.get("success"): + error(result.get("error", "Unknown error")) json_handler.log_operation("verify_plan", {"plan_label": plan_label, "success": False}) return - found = result.get('found', False) - count = result.get('count', 0) + found = result.get("found", False) + count = result.get("count", 0) console.print() if found: @@ -177,18 +177,22 @@ def _verify_plan(plan_label: str) -> None: console.print(f" Plan {plan_label}: [red]NOT vectorized[/red]") console.print() - json_handler.log_operation("verify_plan", { - "plan_label": plan_label, - "found": found, - "count": count, - "success": True, - }) + json_handler.log_operation( + "verify_plan", + { + "plan_label": plan_label, + "found": found, + "count": count, + "success": True, + }, + ) # ============================================================================= # INTROSPECTION # ============================================================================= + def _discover_handlers() -> dict[str, list[str]]: """Auto-discover handler directories and their Python files. @@ -205,10 +209,7 @@ def _discover_handlers() -> dict[str, list[str]]: for d in sorted(handlers_dir.iterdir()): if not d.is_dir() or d.name.startswith("__"): continue - py_files = sorted( - f.name for f in d.iterdir() - if f.is_file() and f.suffix == ".py" and f.name != "__init__.py" - ) + py_files = sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix == ".py" and f.name != "__init__.py") if py_files: result[d.name] = py_files return result @@ -238,7 +239,7 @@ def print_introspection() -> None: # Next-step hints console.print("[yellow]Next:[/yellow]") - console.print(' [green]drone @memory verify FPLAN-0126[/green] [dim]# Check if plan is vectorized[/dim]') + console.print(" [green]drone @memory verify FPLAN-0126[/green] [dim]# Check if plan is vectorized[/dim]") console.print(" [green]drone @memory verify --help[/green] [dim]# Full usage guide[/dim]") console.print() @@ -273,12 +274,12 @@ def print_help() -> None: if __name__ == "__main__": # No args -> introspection (seedgo standard) if len(sys.argv) < 2: - handle_command('verify', []) + handle_command("verify", []) sys.exit(0) # --help -> full help - if sys.argv[1] in ('--help', '-h', 'help'): - handle_command('verify', ['--help']) + if sys.argv[1] in ("--help", "-h", "help"): + handle_command("verify", ["--help"]) sys.exit(0) # Execute command via handle_command diff --git a/src/aipass/memory/tests/conftest.py b/src/aipass/memory/tests/conftest.py index 0932914c..4b47d4e5 100644 --- a/src/aipass/memory/tests/conftest.py +++ b/src/aipass/memory/tests/conftest.py @@ -7,6 +7,7 @@ # ============================================= """Shared pytest fixtures for memory tests.""" + import os import tempfile @@ -99,12 +100,10 @@ def sample_memory_data() -> dict: "managed_by": "TEST", "tags": ["test"], "limits": {"max_sessions": 20, "max_key_learnings": 25}, - "status": {"health": "healthy", "current_lines": 50} + "status": {"health": "healthy", "current_lines": 50}, }, "key_learnings": {"test_learning": "This is a test."}, - "sessions": [ - {"session_number": 1, "date": "2026-01-01", "summary": "Test session", "status": "completed"} - ] + "sessions": [{"session_number": 1, "date": "2026-01-01", "summary": "Test session", "status": "completed"}], } @@ -118,15 +117,15 @@ def sample_registry_data() -> dict: "path": "src/aipass/test_branch", "module": "aipass.test_branch", "email": "@test_branch", - "status": "active" + "status": "active", }, { "name": "MEMORY", "path": "src/aipass/memory", "module": "aipass.memory", "email": "@memory", - "status": "active" - } + "status": "active", + }, ] } @@ -138,15 +137,21 @@ def temp_branch(tmp_path, sample_memory_data): trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) (trinity / "local.json").write_text(json.dumps(sample_memory_data, indent=2), encoding="utf-8") - (trinity / "passport.json").write_text(json.dumps({ - "branch_info": {"branch_name": "test_branch", "path": "src/aipass/test_branch"}, - "identity": {"role": "test", "purpose": "testing"}, - "citizenship": {"registered": True} - }, indent=2), encoding="utf-8") - (trinity / "observations.json").write_text(json.dumps({ - "document_metadata": {"document_type": "collaboration_patterns"}, - "observations": [] - }, indent=2), encoding="utf-8") + (trinity / "passport.json").write_text( + json.dumps( + { + "branch_info": {"branch_name": "test_branch", "path": "src/aipass/test_branch"}, + "identity": {"role": "test", "purpose": "testing"}, + "citizenship": {"registered": True}, + }, + indent=2, + ), + encoding="utf-8", + ) + (trinity / "observations.json").write_text( + json.dumps({"document_metadata": {"document_type": "collaboration_patterns"}, "observations": []}, indent=2), + encoding="utf-8", + ) return branch_dir diff --git a/src/aipass/memory/tests/test_archive.py b/src/aipass/memory/tests/test_archive.py index 250e36a8..cdeb0345 100644 --- a/src/aipass/memory/tests/test_archive.py +++ b/src/aipass/memory/tests/test_archive.py @@ -24,6 +24,7 @@ from pathlib import Path # Import helper # --------------------------------------------------------------------------- + def _import_indexer(monkeypatch, tmp_path): """Import indexer with mocked dependencies and paths pointed at tmp_path.""" sys.modules.pop("aipass.memory.apps.handlers.archive.indexer", None) @@ -46,6 +47,7 @@ def _import_indexer(monkeypatch, tmp_path): # Tests: extract_file_info # =========================================================================== + class TestExtractFileInfo: """Test extract_file_info metadata extraction.""" @@ -159,6 +161,7 @@ class TestExtractFileInfo: # Tests: get_archive_files # =========================================================================== + class TestGetArchiveFiles: """Test get_archive_files directory scanning.""" @@ -231,6 +234,7 @@ class TestGetArchiveFiles: # Tests: load_index # =========================================================================== + class TestLoadIndex: """Test load_index file reading.""" @@ -245,9 +249,7 @@ class TestLoadIndex: "categories": {"utils": ["helper.py"]}, "files": {"helper.py": {"filename": "helper.py"}}, } - (archive_dir / "index.json").write_text( - json.dumps(index_data), encoding="utf-8" - ) + (archive_dir / "index.json").write_text(json.dumps(index_data), encoding="utf-8") result = indexer.load_index() @@ -285,6 +287,7 @@ class TestLoadIndex: # Tests: save_index # =========================================================================== + class TestSaveIndex: """Test save_index file writing.""" @@ -330,6 +333,7 @@ class TestSaveIndex: # Tests: build_index # =========================================================================== + class TestBuildIndex: """Test build_index full scan.""" @@ -382,6 +386,7 @@ class TestBuildIndex: # Tests: check_for_new_files # =========================================================================== + class TestCheckForNewFiles: """Test check_for_new_files sync logic.""" @@ -397,9 +402,7 @@ class TestCheckForNewFiles: "categories": {}, "files": {}, } - (archive_dir / "index.json").write_text( - json.dumps(empty_index), encoding="utf-8" - ) + (archive_dir / "index.json").write_text(json.dumps(empty_index), encoding="utf-8") # Now create a file on disk (archive_dir / "new_file.py").write_text("pass\n", encoding="utf-8") @@ -425,9 +428,7 @@ class TestCheckForNewFiles: "ghost.py": {"filename": "ghost.py", "path": "ghost.py"}, }, } - (archive_dir / "index.json").write_text( - json.dumps(index_with_ghost), encoding="utf-8" - ) + (archive_dir / "index.json").write_text(json.dumps(index_with_ghost), encoding="utf-8") result = indexer.check_for_new_files() @@ -451,9 +452,7 @@ class TestCheckForNewFiles: "existing.py": {"filename": "existing.py", "path": "existing.py"}, }, } - (archive_dir / "index.json").write_text( - json.dumps(index), encoding="utf-8" - ) + (archive_dir / "index.json").write_text(json.dumps(index), encoding="utf-8") result = indexer.check_for_new_files() @@ -476,9 +475,7 @@ class TestCheckForNewFiles: "old_file.py": {"filename": "old_file.py", "path": "old_file.py"}, }, } - (archive_dir / "index.json").write_text( - json.dumps(index), encoding="utf-8" - ) + (archive_dir / "index.json").write_text(json.dumps(index), encoding="utf-8") # Disk has new_file.py but not old_file.py (archive_dir / "new_file.py").write_text("pass\n", encoding="utf-8") @@ -495,6 +492,7 @@ class TestCheckForNewFiles: # Tests: get_index_status # =========================================================================== + class TestGetIndexStatus: """Test get_index_status reporting.""" @@ -516,9 +514,7 @@ class TestGetIndexStatus: str(Path("utils") / "b.py"): {"filename": "b.py"}, }, } - (archive_dir / "index.json").write_text( - json.dumps(index), encoding="utf-8" - ) + (archive_dir / "index.json").write_text(json.dumps(index), encoding="utf-8") status = indexer.get_index_status() diff --git a/src/aipass/memory/tests/test_central_writer.py b/src/aipass/memory/tests/test_central_writer.py index 5be6e389..d974211c 100644 --- a/src/aipass/memory/tests/test_central_writer.py +++ b/src/aipass/memory/tests/test_central_writer.py @@ -27,6 +27,7 @@ from unittest.mock import MagicMock # Import helper # --------------------------------------------------------------------------- + def _import_central_writer(monkeypatch, tmp_path): """Import central_writer with mocked dependencies and paths at tmp_path.""" sys.modules.pop("aipass.memory.apps.handlers.central_writer", None) @@ -49,6 +50,7 @@ def _import_central_writer(monkeypatch, tmp_path): # Helper: create a tiny SQLite DB with embeddings table # --------------------------------------------------------------------------- + def _create_chroma_db(db_path: Path, num_rows: int = 5) -> None: """Create a minimal SQLite DB mimicking ChromaDB structure.""" db_path.parent.mkdir(parents=True, exist_ok=True) @@ -65,6 +67,7 @@ def _create_chroma_db(db_path: Path, num_rows: int = 5) -> None: # Tests: count_chroma_vectors # =========================================================================== + class TestCountChromaVectors: """Test count_chroma_vectors SQLite reading.""" @@ -114,6 +117,7 @@ class TestCountChromaVectors: # Tests: count_archive_files # =========================================================================== + class TestCountArchiveFiles: """Test count_archive_files .md counting.""" @@ -171,6 +175,7 @@ class TestCountArchiveFiles: # Tests: get_last_rollover_timestamp # =========================================================================== + class TestGetLastRolloverTimestamp: """Test get_last_rollover_timestamp file stat reading.""" @@ -208,6 +213,7 @@ class TestGetLastRolloverTimestamp: # Tests: collect_stats # =========================================================================== + class TestCollectStats: """Test collect_stats aggregation.""" @@ -243,6 +249,7 @@ class TestCollectStats: # Tests: read_central_file # =========================================================================== + class TestReadCentralFile: """Test read_central_file JSON reading.""" @@ -269,9 +276,7 @@ class TestReadCentralFile: "stats": {"total_vectors": 42, "total_archives": 5, "last_rollover": "2026-02-28"}, "extra_field": "preserved", } - (central_dir / "MEMORY.central.json").write_text( - json.dumps(data), encoding="utf-8" - ) + (central_dir / "MEMORY.central.json").write_text(json.dumps(data), encoding="utf-8") result = cw.read_central_file() @@ -297,6 +302,7 @@ class TestReadCentralFile: # Tests: write_central_file # =========================================================================== + class TestWriteCentralFile: """Test write_central_file JSON writing.""" @@ -346,6 +352,7 @@ class TestWriteCentralFile: # Tests: update_central # =========================================================================== + class TestUpdateCentral: """Test update_central end-to-end flow.""" @@ -386,17 +393,13 @@ class TestUpdateCentral: "_note": "placeholder - not yet populated", "stats": {"total_vectors": 0, "total_archives": 0, "last_rollover": ""}, } - (central_dir / "MEMORY.central.json").write_text( - json.dumps(old_data), encoding="utf-8" - ) + (central_dir / "MEMORY.central.json").write_text(json.dumps(old_data), encoding="utf-8") result = cw.update_central() assert result["success"] is True - written = json.loads( - (central_dir / "MEMORY.central.json").read_text(encoding="utf-8") - ) + written = json.loads((central_dir / "MEMORY.central.json").read_text(encoding="utf-8")) assert "_note" not in written def test_preserves_extra_fields(self, monkeypatch, tmp_path): @@ -411,17 +414,13 @@ class TestUpdateCentral: "custom_field": "keep_me", "stats": {"total_vectors": 0, "total_archives": 0, "last_rollover": ""}, } - (central_dir / "MEMORY.central.json").write_text( - json.dumps(old_data), encoding="utf-8" - ) + (central_dir / "MEMORY.central.json").write_text(json.dumps(old_data), encoding="utf-8") result = cw.update_central() assert result["success"] is True - written = json.loads( - (central_dir / "MEMORY.central.json").read_text(encoding="utf-8") - ) + written = json.loads((central_dir / "MEMORY.central.json").read_text(encoding="utf-8")) assert written["custom_field"] == "keep_me" def test_failure_returns_error(self, monkeypatch, tmp_path): @@ -451,6 +450,7 @@ class TestUpdateCentral: # Tests: get_current_stats # =========================================================================== + class TestGetCurrentStats: """Test get_current_stats read-only stats collection.""" diff --git a/src/aipass/memory/tests/test_contracts.py b/src/aipass/memory/tests/test_contracts.py index 69145497..52c01891 100644 --- a/src/aipass/memory/tests/test_contracts.py +++ b/src/aipass/memory/tests/test_contracts.py @@ -101,9 +101,7 @@ class TestReturnTypeContracts: local_json.write_text("{}", encoding="utf-8") result = local_json - assert isinstance(result, Path), ( - f"Memory paths must be pathlib.Path, got {type(result)}" - ) + assert isinstance(result, Path), f"Memory paths must be pathlib.Path, got {type(result)}" assert result.exists() diff --git a/src/aipass/memory/tests/test_dashboard_push.py b/src/aipass/memory/tests/test_dashboard_push.py index 43a44a2d..0f49e3ad 100644 --- a/src/aipass/memory/tests/test_dashboard_push.py +++ b/src/aipass/memory/tests/test_dashboard_push.py @@ -33,6 +33,7 @@ from unittest.mock import MagicMock # Import helper # --------------------------------------------------------------------------- + def _import_dashboard_push(monkeypatch): """Import dashboard_push with mocked dependencies.""" sys.modules.pop("aipass.memory.apps.handlers.dashboard_push", None) @@ -49,6 +50,7 @@ def _import_dashboard_push(monkeypatch): # Tests: _read_central_stats # =========================================================================== + class TestReadCentralStats: """Test _read_central_stats helper.""" @@ -63,13 +65,12 @@ class TestReadCentralStats: def test_reads_valid_central_file(self, monkeypatch, tmp_path): mod = _import_dashboard_push(monkeypatch) central = tmp_path / "MEMORY.central.json" - central.write_text(json.dumps({ - "stats": { - "total_vectors": 1500, - "total_archives": 12, - "last_rollover": "2026-03-15T10:30:00" - } - }), encoding="utf-8") + central.write_text( + json.dumps( + {"stats": {"total_vectors": 1500, "total_archives": 12, "last_rollover": "2026-03-15T10:30:00"}} + ), + encoding="utf-8", + ) monkeypatch.setattr(mod, "CENTRAL_FILE", central) result = mod._read_central_stats() @@ -93,6 +94,7 @@ class TestReadCentralStats: # Tests: _get_collections_count # =========================================================================== + class TestGetCollectionsCount: """Test _get_collections_count helper.""" @@ -130,6 +132,7 @@ class TestGetCollectionsCount: # Tests: _get_rollover_config # =========================================================================== + class TestGetRolloverConfig: """Test _get_rollover_config helper.""" @@ -144,14 +147,17 @@ class TestGetRolloverConfig: def test_loads_valid_config(self, monkeypatch, tmp_path): mod = _import_dashboard_push(monkeypatch) config_file = tmp_path / "memory_bank.config.json" - config_file.write_text(json.dumps({ - "rollover": { - "defaults": {"max_lines": 800, "buffer": 150}, - "per_branch": { - "NEXUS": {"max_lines": 1200} + config_file.write_text( + json.dumps( + { + "rollover": { + "defaults": {"max_lines": 800, "buffer": 150}, + "per_branch": {"NEXUS": {"max_lines": 1200}}, + } } - } - }), encoding="utf-8") + ), + encoding="utf-8", + ) monkeypatch.setattr(mod, "CONFIG_PATH", config_file) result = mod._get_rollover_config() @@ -175,6 +181,7 @@ class TestGetRolloverConfig: # Tests: _get_max_lines_for_branch # =========================================================================== + class TestGetMaxLinesForBranch: """Test _get_max_lines_for_branch helper.""" @@ -188,10 +195,7 @@ class TestGetMaxLinesForBranch: def test_returns_override_when_present(self, monkeypatch): mod = _import_dashboard_push(monkeypatch) - config = { - "defaults": {"max_lines": 600}, - "per_branch": {"NEXUS": {"max_lines": 1200}} - } + config = {"defaults": {"max_lines": 600}, "per_branch": {"NEXUS": {"max_lines": 1200}}} result = mod._get_max_lines_for_branch("NEXUS", config) @@ -199,10 +203,7 @@ class TestGetMaxLinesForBranch: def test_falls_back_to_default_when_override_missing_max_lines(self, monkeypatch): mod = _import_dashboard_push(monkeypatch) - config = { - "defaults": {"max_lines": 600}, - "per_branch": {"DRONE": {"buffer": 200}} - } + config = {"defaults": {"max_lines": 600}, "per_branch": {"DRONE": {"buffer": 200}}} result = mod._get_max_lines_for_branch("DRONE", config) @@ -213,6 +214,7 @@ class TestGetMaxLinesForBranch: # Tests: _find_branches_near_rollover # =========================================================================== + class TestFindBranchesNearRollover: """Test _find_branches_near_rollover helper.""" @@ -231,27 +233,22 @@ class TestFindBranchesNearRollover: branch_dir = tmp_path / "src" / "aipass" / "test_branch" trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "local.json").write_text(json.dumps({ - "document_metadata": { - "schema_version": "1.0.0", - "status": {"current_lines": 550} - } - }), encoding="utf-8") + (trinity / "local.json").write_text( + json.dumps({"document_metadata": {"schema_version": "1.0.0", "status": {"current_lines": 550}}}), + encoding="utf-8", + ) # Registry pointing to branch registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "TEST_BRANCH", "path": str(branch_dir)} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps({"branches": [{"name": "TEST_BRANCH", "path": str(branch_dir)}]}), encoding="utf-8" + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) # Config: max_lines=600, so 600-550=50 remaining (<100 threshold) - monkeypatch.setattr(mod, "_get_rollover_config", lambda: { - "defaults": {"max_lines": 600, "buffer": 100}, - "per_branch": {} - }) + monkeypatch.setattr( + mod, "_get_rollover_config", lambda: {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + ) monkeypatch.setattr(mod, "NEAR_ROLLOVER_THRESHOLD", 100) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) @@ -269,24 +266,19 @@ class TestFindBranchesNearRollover: branch_dir = tmp_path / "src" / "aipass" / "safe_branch" trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "local.json").write_text(json.dumps({ - "document_metadata": { - "schema_version": "1.0.0", - "status": {"current_lines": 200} - } - }), encoding="utf-8") + (trinity / "local.json").write_text( + json.dumps({"document_metadata": {"schema_version": "1.0.0", "status": {"current_lines": 200}}}), + encoding="utf-8", + ) registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "SAFE_BRANCH", "path": str(branch_dir)} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps({"branches": [{"name": "SAFE_BRANCH", "path": str(branch_dir)}]}), encoding="utf-8" + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) - monkeypatch.setattr(mod, "_get_rollover_config", lambda: { - "defaults": {"max_lines": 600, "buffer": 100}, - "per_branch": {} - }) + monkeypatch.setattr( + mod, "_get_rollover_config", lambda: {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + ) monkeypatch.setattr(mod, "NEAR_ROLLOVER_THRESHOLD", 100) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) @@ -300,26 +292,28 @@ class TestFindBranchesNearRollover: branch_dir = tmp_path / "src" / "aipass" / "v2_branch" trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "local.json").write_text(json.dumps({ - "document_metadata": { - "schema_version": "2.0.0", - "limits": {"max_sessions": 20, "max_key_learnings": 25} - }, - "sessions": [{"id": i} for i in range(19)], # 19 of 20 sessions - "key_learnings": {"k1": "v1"} - }), encoding="utf-8") + (trinity / "local.json").write_text( + json.dumps( + { + "document_metadata": { + "schema_version": "2.0.0", + "limits": {"max_sessions": 20, "max_key_learnings": 25}, + }, + "sessions": [{"id": i} for i in range(19)], # 19 of 20 sessions + "key_learnings": {"k1": "v1"}, + } + ), + encoding="utf-8", + ) registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "V2_BRANCH", "path": str(branch_dir)} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps({"branches": [{"name": "V2_BRANCH", "path": str(branch_dir)}]}), encoding="utf-8" + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) - monkeypatch.setattr(mod, "_get_rollover_config", lambda: { - "defaults": {"max_lines": 600, "buffer": 100}, - "per_branch": {} - }) + monkeypatch.setattr( + mod, "_get_rollover_config", lambda: {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + ) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) result = mod._find_branches_near_rollover() @@ -337,26 +331,28 @@ class TestFindBranchesNearRollover: branch_dir = tmp_path / "src" / "aipass" / "kl_branch" trinity = branch_dir / ".trinity" trinity.mkdir(parents=True) - (trinity / "local.json").write_text(json.dumps({ - "document_metadata": { - "schema_version": "2.0.0", - "limits": {"max_sessions": 20, "max_key_learnings": 5} - }, - "sessions": [{"id": 1}], - "key_learnings": {f"k{i}": f"v{i}" for i in range(4)} # 4 of 5 - }), encoding="utf-8") + (trinity / "local.json").write_text( + json.dumps( + { + "document_metadata": { + "schema_version": "2.0.0", + "limits": {"max_sessions": 20, "max_key_learnings": 5}, + }, + "sessions": [{"id": 1}], + "key_learnings": {f"k{i}": f"v{i}" for i in range(4)}, # 4 of 5 + } + ), + encoding="utf-8", + ) registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "KL_BRANCH", "path": str(branch_dir)} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps({"branches": [{"name": "KL_BRANCH", "path": str(branch_dir)}]}), encoding="utf-8" + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) - monkeypatch.setattr(mod, "_get_rollover_config", lambda: { - "defaults": {"max_lines": 600, "buffer": 100}, - "per_branch": {} - }) + monkeypatch.setattr( + mod, "_get_rollover_config", lambda: {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + ) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) result = mod._find_branches_near_rollover() @@ -370,16 +366,13 @@ class TestFindBranchesNearRollover: mod = _import_dashboard_push(monkeypatch) registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "GHOST", "path": str(tmp_path / "nonexistent")} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps({"branches": [{"name": "GHOST", "path": str(tmp_path / "nonexistent")}]}), encoding="utf-8" + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) - monkeypatch.setattr(mod, "_get_rollover_config", lambda: { - "defaults": {"max_lines": 600, "buffer": 100}, - "per_branch": {} - }) + monkeypatch.setattr( + mod, "_get_rollover_config", lambda: {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + ) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) result = mod._find_branches_near_rollover() @@ -391,6 +384,7 @@ class TestFindBranchesNearRollover: # Tests: _get_template_version # =========================================================================== + class TestGetTemplateVersion: """Test _get_template_version helper.""" @@ -427,6 +421,7 @@ class TestGetTemplateVersion: # Tests: _get_last_rollover_info # =========================================================================== + class TestGetLastRolloverInfo: """Test _get_last_rollover_info helper.""" @@ -467,6 +462,7 @@ class TestGetLastRolloverInfo: # Tests: _get_all_branch_paths # =========================================================================== + class TestGetAllBranchPaths: """Test _get_all_branch_paths helper.""" @@ -487,13 +483,18 @@ class TestGetAllBranchPaths: branch_b.mkdir() registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "A", "path": str(branch_a)}, - {"name": "B", "path": str(branch_b)}, - {"name": "C", "path": str(tmp_path / "nonexistent")} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps( + { + "branches": [ + {"name": "A", "path": str(branch_a)}, + {"name": "B", "path": str(branch_b)}, + {"name": "C", "path": str(tmp_path / "nonexistent")}, + ] + } + ), + encoding="utf-8", + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) @@ -510,11 +511,9 @@ class TestGetAllBranchPaths: branch_dir.mkdir(parents=True) registry = tmp_path / "AIPASS_REGISTRY.json" - registry.write_text(json.dumps({ - "branches": [ - {"name": "TEST", "path": "src/aipass/test_branch"} - ] - }), encoding="utf-8") + registry.write_text( + json.dumps({"branches": [{"name": "TEST", "path": "src/aipass/test_branch"}]}), encoding="utf-8" + ) monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) @@ -528,20 +527,23 @@ class TestGetAllBranchPaths: # Tests: build_memory_bank_section (public) # =========================================================================== + class TestBuildMemoryBankSection: """Test build_memory_bank_section with mocked helpers.""" def test_assembles_section_data(self, monkeypatch): mod = _import_dashboard_push(monkeypatch) - monkeypatch.setattr(mod, "_read_central_stats", lambda: { - "total_vectors": 2500, - "total_archives": 15, - "last_rollover": "2026-03-20T12:00:00" - }) - monkeypatch.setattr(mod, "_find_branches_near_rollover", lambda: [ - {"branch": "NEXUS", "file_type": "local", "lines_remaining": 30} - ]) + monkeypatch.setattr( + mod, + "_read_central_stats", + lambda: {"total_vectors": 2500, "total_archives": 15, "last_rollover": "2026-03-20T12:00:00"}, + ) + monkeypatch.setattr( + mod, + "_find_branches_near_rollover", + lambda: [{"branch": "NEXUS", "file_type": "local", "lines_remaining": 30}], + ) monkeypatch.setattr(mod, "_get_last_rollover_info", lambda s: {"date": "2026-03-20"}) monkeypatch.setattr(mod, "_get_template_version", lambda: "2.0.4") monkeypatch.setattr(mod, "_get_collections_count", lambda: 8) @@ -561,6 +563,7 @@ class TestBuildMemoryBankSection: # Tests: push_memory_bank_dashboard (public) # =========================================================================== + class TestPushMemoryBankDashboard: """Test push_memory_bank_dashboard.""" @@ -599,17 +602,12 @@ class TestPushMemoryBankDashboard: mod.push_memory_bank_dashboard() - mock_jh.log_operation.assert_called_once_with( - "dashboard_push", - {"branches_updated": 3, "success": True} - ) + mock_jh.log_operation.assert_called_once_with("dashboard_push", {"branches_updated": 3, "success": True}) def test_returns_false_on_exception(self, monkeypatch): mod = _import_dashboard_push(monkeypatch) - monkeypatch.setattr(mod, "build_memory_bank_section", MagicMock( - side_effect=RuntimeError("boom") - )) + monkeypatch.setattr(mod, "build_memory_bank_section", MagicMock(side_effect=RuntimeError("boom"))) result = mod.push_memory_bank_dashboard() diff --git a/src/aipass/memory/tests/test_detector.py b/src/aipass/memory/tests/test_detector.py index 1fac5fbb..71951440 100644 --- a/src/aipass/memory/tests/test_detector.py +++ b/src/aipass/memory/tests/test_detector.py @@ -24,6 +24,7 @@ from unittest.mock import MagicMock # Autouse fixture -- mock heavy infrastructure before detector is imported # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _mock_detector_infrastructure(monkeypatch): """Mock prax logger and json_handler so detector.py can be imported.""" @@ -42,20 +43,17 @@ def _mock_detector_infrastructure(monkeypatch): json_pkg = MagicMock() json_pkg.json_handler = mock_json_handler monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler - ) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler) # Force fresh import every test - monkeypatch.delitem( - sys.modules, "aipass.memory.apps.handlers.monitor.detector", raising=False - ) + monkeypatch.delitem(sys.modules, "aipass.memory.apps.handlers.monitor.detector", raising=False) # =========================================================================== # _get_memory_file_path # =========================================================================== + class TestGetMemoryFilePath: """Tests for _get_memory_file_path(branch, memory_type).""" @@ -121,6 +119,7 @@ class TestGetMemoryFilePath: # _load_config # =========================================================================== + class TestLoadConfig: """Tests for _load_config().""" @@ -191,6 +190,7 @@ class TestLoadConfig: # check_single_file # =========================================================================== + class TestCheckSingleFile: """Tests for check_single_file(file_path).""" @@ -298,6 +298,7 @@ class TestCheckSingleFile: # _read_registry # =========================================================================== + class TestReadRegistry: """Tests for _read_registry().""" diff --git a/src/aipass/memory/tests/test_handlers.py b/src/aipass/memory/tests/test_handlers.py index a34885ba..2ce12188 100644 --- a/src/aipass/memory/tests/test_handlers.py +++ b/src/aipass/memory/tests/test_handlers.py @@ -26,6 +26,7 @@ from unittest.mock import MagicMock, patch # Import helpers -- each handler has module-level imports that need mocking # --------------------------------------------------------------------------- + def _import_extractor(monkeypatch): """Import extractor with mocked infrastructure dependencies.""" mock_json_handler = MagicMock() @@ -108,6 +109,7 @@ def _import_normalize(monkeypatch): # Tests: rollover/extractor.py # =========================================================================== + class TestDetectGrowingArray: """Test _detect_growing_array helper.""" @@ -165,8 +167,9 @@ class TestDerivebranchAndType: class TestExtractItemsV2: """Test _extract_items_v2 entry-count based extraction.""" - def _make_v2_data(self, num_sessions: int = 5, num_learnings: int = 5, - max_sessions: int = 3, max_learnings: int = 3): + def _make_v2_data( + self, num_sessions: int = 5, num_learnings: int = 5, max_sessions: int = 3, max_learnings: int = 3 + ): """Build a v2 memory data dict with controllable counts.""" sessions = [ {"session_number": i, "date": f"2026-01-{i:02d}", "summary": f"Session {i}"} @@ -210,8 +213,7 @@ class TestExtractItemsV2: def test_trims_key_learnings_to_limit(self, monkeypatch, tmp_path): ext = _import_extractor(monkeypatch)[0] - data = self._make_v2_data(num_sessions=2, num_learnings=7, - max_sessions=3, max_learnings=4) + data = self._make_v2_data(num_sessions=2, num_learnings=7, max_sessions=3, max_learnings=4) mem_file = tmp_path / ".trinity" / "local.json" mem_file.parent.mkdir(parents=True) @@ -231,8 +233,7 @@ class TestExtractItemsV2: def test_skips_when_under_limits(self, monkeypatch, tmp_path): ext, _ = _import_extractor(monkeypatch) - data = self._make_v2_data(num_sessions=2, num_learnings=2, - max_sessions=5, max_learnings=5) + data = self._make_v2_data(num_sessions=2, num_learnings=2, max_sessions=5, max_learnings=5) mem_file = tmp_path / ".trinity" / "local.json" mem_file.parent.mkdir(parents=True) @@ -245,8 +246,7 @@ class TestExtractItemsV2: def test_extracts_oldest_sessions_from_end(self, monkeypatch, tmp_path): """Sessions are stored newest-first, oldest at end. Extraction takes from end.""" ext, _ = _import_extractor(monkeypatch) - data = self._make_v2_data(num_sessions=5, num_learnings=0, - max_sessions=3, max_learnings=100) + data = self._make_v2_data(num_sessions=5, num_learnings=0, max_sessions=3, max_learnings=100) mem_file = tmp_path / ".trinity" / "local.json" mem_file.parent.mkdir(parents=True) @@ -268,8 +268,7 @@ class TestExtractItemsV2: def test_extracts_oldest_key_learnings_by_insertion_order(self, monkeypatch, tmp_path): """First-inserted keys are oldest and should be extracted first.""" ext, _ = _import_extractor(monkeypatch) - data = self._make_v2_data(num_sessions=0, num_learnings=5, - max_sessions=100, max_learnings=3) + data = self._make_v2_data(num_sessions=0, num_learnings=5, max_sessions=100, max_learnings=3) mem_file = tmp_path / ".trinity" / "local.json" mem_file.parent.mkdir(parents=True) @@ -334,7 +333,7 @@ class TestCreateRolloverBackup: def test_restore_fails_without_backup(self, monkeypatch, tmp_path): ext, _ = _import_extractor(monkeypatch) mem_file = tmp_path / "local.json" - mem_file.write_text('{}', encoding="utf-8") + mem_file.write_text("{}", encoding="utf-8") result = ext.restore_from_backup(mem_file) assert result["success"] is False @@ -344,6 +343,7 @@ class TestCreateRolloverBackup: # Tests: tracking/line_counter.py # =========================================================================== + class TestCountPhysicalLines: """Test _count_physical_lines helper.""" @@ -385,9 +385,7 @@ class TestUpdateLineCount: def test_reports_failure_when_metadata_update_fails(self, monkeypatch, tmp_path): lc, mocks = _import_line_counter(monkeypatch) - mocks["memory_files"].update_metadata.return_value = { - "success": False, "error": "write error" - } + mocks["memory_files"].update_metadata.return_value = {"success": False, "error": "write error"} f = tmp_path / "test.json" f.write_text("{}\n", encoding="utf-8") @@ -400,6 +398,7 @@ class TestUpdateLineCount: # Tests: schema/normalize.py # =========================================================================== + class TestNormalizeMemoryFile: """Test normalize_memory_file function.""" @@ -414,11 +413,14 @@ class TestNormalizeMemoryFile: def test_moves_root_limits_into_metadata(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) f = tmp_path / "test.json" - self._write_json(f, { - "document_metadata": {"status": {"current_lines": 10}}, - "limits": {"max_lines": 600}, - "sessions": [], - }) + self._write_json( + f, + { + "document_metadata": {"status": {"current_lines": 10}}, + "limits": {"max_lines": 600}, + "sessions": [], + }, + ) result = norm.normalize_memory_file(f) assert result["success"] is True @@ -429,14 +431,17 @@ class TestNormalizeMemoryFile: def test_merges_root_limits_preserving_metadata_values(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) f = tmp_path / "test.json" - self._write_json(f, { - "document_metadata": { - "limits": {"max_lines": 500}, - "status": {"current_lines": 10}, + self._write_json( + f, + { + "document_metadata": { + "limits": {"max_lines": 500}, + "status": {"current_lines": 10}, + }, + "limits": {"max_lines": 600, "extra_field": 42}, + "sessions": [], }, - "limits": {"max_lines": 600, "extra_field": 42}, - "sessions": [], - }) + ) result = norm.normalize_memory_file(f) assert result["success"] is True @@ -449,11 +454,14 @@ class TestNormalizeMemoryFile: def test_removes_root_status(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) f = tmp_path / "test.json" - self._write_json(f, { - "document_metadata": {"status": {"current_lines": 10}}, - "status": {"health": "ok", "current_lines": 5}, - "sessions": [], - }) + self._write_json( + f, + { + "document_metadata": {"status": {"current_lines": 10}}, + "status": {"health": "ok", "current_lines": 5}, + "sessions": [], + }, + ) result = norm.normalize_memory_file(f) assert result["success"] is True @@ -464,12 +472,15 @@ class TestNormalizeMemoryFile: def test_removes_auto_compress_at(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) f = tmp_path / "test.json" - self._write_json(f, { - "document_metadata": { - "status": {"current_lines": 10, "auto_compress_at": 500}, + self._write_json( + f, + { + "document_metadata": { + "status": {"current_lines": 10, "auto_compress_at": 500}, + }, + "sessions": [], }, - "sessions": [], - }) + ) result = norm.normalize_memory_file(f) assert result["success"] is True @@ -497,13 +508,16 @@ class TestNormalizeMemoryFile: def test_no_changes_when_already_normalized(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) f = tmp_path / "test.json" - self._write_json(f, { - "document_metadata": { - "limits": {"max_sessions": 20}, - "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + self._write_json( + f, + { + "document_metadata": { + "limits": {"max_sessions": 20}, + "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + }, + "sessions": [], }, - "sessions": [], - }) + ) result = norm.normalize_memory_file(f) assert result["success"] is True assert result["changes"] == [] @@ -511,13 +525,16 @@ class TestNormalizeMemoryFile: def test_removes_unused_limit_fields(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) f = tmp_path / "test.json" - self._write_json(f, { - "document_metadata": { - "limits": {"max_lines": 600, "max_word_count": 9999, "max_token_count": 5000}, - "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + self._write_json( + f, + { + "document_metadata": { + "limits": {"max_lines": 600, "max_word_count": 9999, "max_token_count": 5000}, + "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + }, + "sessions": [], }, - "sessions": [], - }) + ) result = norm.normalize_memory_file(f) assert result["success"] is True diff --git a/src/aipass/memory/tests/test_intake.py b/src/aipass/memory/tests/test_intake.py index a409b682..1821bdc9 100644 --- a/src/aipass/memory/tests/test_intake.py +++ b/src/aipass/memory/tests/test_intake.py @@ -31,6 +31,7 @@ from unittest.mock import MagicMock # Import helper # --------------------------------------------------------------------------- + def _import_pool_processor(monkeypatch): """Import pool_processor with mocked dependencies.""" sys.modules.pop("aipass.memory.apps.handlers.intake.pool_processor", None) @@ -47,6 +48,7 @@ def _import_pool_processor(monkeypatch): # Tests: find_source_file # =========================================================================== + class TestFindSourceFile: """Test find_source_file function.""" @@ -105,19 +107,17 @@ class TestFindSourceFile: # Tests: load_config # =========================================================================== + class TestLoadConfig: """Test load_config function.""" def test_loads_valid_config(self, monkeypatch, tmp_path): mod = _import_pool_processor(monkeypatch) config_file = tmp_path / "memory_bank.config.json" - config_file.write_text(json.dumps({ - "memory_pool": { - "enabled": True, - "keep_recent": 5, - "collection_name": "test_pool" - } - }), encoding="utf-8") + config_file.write_text( + json.dumps({"memory_pool": {"enabled": True, "keep_recent": 5, "collection_name": "test_pool"}}), + encoding="utf-8", + ) monkeypatch.setattr(mod, "CONFIG_PATH", config_file) result = mod.load_config() @@ -150,6 +150,7 @@ class TestLoadConfig: # Tests: get_pool_files # =========================================================================== + class TestGetPoolFiles: """Test get_pool_files function.""" @@ -180,6 +181,7 @@ class TestGetPoolFiles: old_file = pool / "old.md" old_file.write_text("old content", encoding="utf-8") import os + os.utime(str(old_file), (1000000, 1000000)) new_file = pool / "new.md" @@ -213,6 +215,7 @@ class TestGetPoolFiles: # Tests: read_file_content # =========================================================================== + class TestReadFileContent: """Test read_file_content function.""" @@ -243,6 +246,7 @@ class TestReadFileContent: # Tests: chunk_content # =========================================================================== + class TestChunkContent: """Test chunk_content function.""" @@ -313,6 +317,7 @@ class TestChunkContent: # Tests: process_file_to_vectors # =========================================================================== + class TestProcessFileToVectors: """Test process_file_to_vectors with mocked chromadb.""" @@ -366,7 +371,7 @@ class TestProcessFileToVectors: monkeypatch.delitem(sys.modules, "sentence_transformers", raising=False) # Patch the builtins __import__ to raise for chromadb - original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ + original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ def fake_import(name, *args, **kwargs): if name == "chromadb": @@ -385,6 +390,7 @@ class TestProcessFileToVectors: # Tests: archive_old_files # =========================================================================== + class TestArchiveOldFiles: """Test archive_old_files function.""" @@ -398,9 +404,7 @@ class TestArchiveOldFiles: monkeypatch.setattr(mod, "_MEMORY_ROOT", tmp_path) # Mock load_config to return supported extensions - monkeypatch.setattr(mod, "load_config", lambda: { - "supported_extensions": [".md", ".txt"] - }) + monkeypatch.setattr(mod, "load_config", lambda: {"supported_extensions": [".md", ".txt"]}) result = mod.archive_old_files(keep_recent=5) @@ -423,9 +427,7 @@ class TestArchiveOldFiles: f.write_text(f"content {i}", encoding="utf-8") os.utime(str(f), (1000000 + (3 - i) * 1000, 1000000 + (3 - i) * 1000)) - monkeypatch.setattr(mod, "load_config", lambda: { - "supported_extensions": [".md"] - }) + monkeypatch.setattr(mod, "load_config", lambda: {"supported_extensions": [".md"]}) archive_dir_name = "test_archive" result = mod.archive_old_files(keep_recent=2, archive_path=archive_dir_name) @@ -459,9 +461,7 @@ class TestArchiveOldFiles: archive_dir.mkdir() (archive_dir / "file2.md").write_text("pre-existing", encoding="utf-8") - monkeypatch.setattr(mod, "load_config", lambda: { - "supported_extensions": [".md"] - }) + monkeypatch.setattr(mod, "load_config", lambda: {"supported_extensions": [".md"]}) result = mod.archive_old_files(keep_recent=1, archive_path="test_archive") @@ -477,6 +477,7 @@ class TestArchiveOldFiles: # Tests: process_memory_pool # =========================================================================== + class TestProcessMemoryPool: """Test process_memory_pool main entry point.""" @@ -494,15 +495,19 @@ class TestProcessMemoryPool: mod = _import_pool_processor(monkeypatch) pool = tmp_path / "pool" monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) - monkeypatch.setattr(mod, "load_config", lambda: { - "enabled": True, - "keep_recent": 10, - "collection_name": "test", - "chunk_size": 1000, - "chunk_overlap": 100, - "supported_extensions": [".md"], - "archive_path": "archive" - }) + monkeypatch.setattr( + mod, + "load_config", + lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test", + "chunk_size": 1000, + "chunk_overlap": 100, + "supported_extensions": [".md"], + "archive_path": "archive", + }, + ) monkeypatch.setattr(mod, "get_pool_files", lambda extensions=None: []) result = mod.process_memory_pool() @@ -519,27 +524,30 @@ class TestProcessMemoryPool: test_file = tmp_path / "test.md" test_file.write_text("content", encoding="utf-8") - monkeypatch.setattr(mod, "load_config", lambda: { - "enabled": True, - "keep_recent": 10, - "collection_name": "test", - "chunk_size": 1000, - "chunk_overlap": 100, - "supported_extensions": [".md"], - "archive_path": "archive" - }) + monkeypatch.setattr( + mod, + "load_config", + lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test", + "chunk_size": 1000, + "chunk_overlap": 100, + "supported_extensions": [".md"], + "archive_path": "archive", + }, + ) monkeypatch.setattr(mod, "get_pool_files", lambda extensions=None: [test_file]) - monkeypatch.setattr(mod, "process_file_to_vectors", lambda fp, cn, cs=1000, co=100: { - "success": True, - "file": fp.name, - "chunks_stored": 3, - "collection": cn - }) - monkeypatch.setattr(mod, "archive_old_files", lambda keep, archive_path="": { - "success": True, - "archived_count": 0, - "kept_count": 1 - }) + monkeypatch.setattr( + mod, + "process_file_to_vectors", + lambda fp, cn, cs=1000, co=100: {"success": True, "file": fp.name, "chunks_stored": 3, "collection": cn}, + ) + monkeypatch.setattr( + mod, + "archive_old_files", + lambda keep, archive_path="": {"success": True, "archived_count": 0, "kept_count": 1}, + ) monkeypatch.setattr(mod, "_update_central_and_dashboard", lambda: None) mock_jh = MagicMock() @@ -561,25 +569,30 @@ class TestProcessMemoryPool: test_file = tmp_path / "bad.md" test_file.write_text("content", encoding="utf-8") - monkeypatch.setattr(mod, "load_config", lambda: { - "enabled": True, - "keep_recent": 10, - "collection_name": "test", - "chunk_size": 1000, - "chunk_overlap": 100, - "supported_extensions": [".md"], - "archive_path": "archive" - }) + monkeypatch.setattr( + mod, + "load_config", + lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test", + "chunk_size": 1000, + "chunk_overlap": 100, + "supported_extensions": [".md"], + "archive_path": "archive", + }, + ) monkeypatch.setattr(mod, "get_pool_files", lambda extensions=None: [test_file]) - monkeypatch.setattr(mod, "process_file_to_vectors", lambda fp, cn, cs=1000, co=100: { - "success": False, - "error": "chromadb failed" - }) - monkeypatch.setattr(mod, "archive_old_files", lambda keep, archive_path="": { - "success": True, - "archived_count": 0, - "kept_count": 0 - }) + monkeypatch.setattr( + mod, + "process_file_to_vectors", + lambda fp, cn, cs=1000, co=100: {"success": False, "error": "chromadb failed"}, + ) + monkeypatch.setattr( + mod, + "archive_old_files", + lambda keep, archive_path="": {"success": True, "archived_count": 0, "kept_count": 0}, + ) mock_notify = MagicMock() monkeypatch.setattr(mod, "_notify_failure", mock_notify) @@ -597,6 +610,7 @@ class TestProcessMemoryPool: # Tests: get_pool_status # =========================================================================== + class TestGetPoolStatus: """Test get_pool_status function.""" @@ -608,12 +622,16 @@ class TestGetPoolStatus: monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") - monkeypatch.setattr(mod, "load_config", lambda: { - "enabled": True, - "keep_recent": 10, - "collection_name": "test_pool", - "supported_extensions": [".md"] - }) + monkeypatch.setattr( + mod, + "load_config", + lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test_pool", + "supported_extensions": [".md"], + }, + ) # Mock chromadb mock_collection = MagicMock() @@ -642,13 +660,10 @@ class TestGetPoolStatus: monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") - monkeypatch.setattr(mod, "load_config", lambda: { - "enabled": False, - "supported_extensions": [".md"] - }) + monkeypatch.setattr(mod, "load_config", lambda: {"enabled": False, "supported_extensions": [".md"]}) # Make chromadb import raise - original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ + original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ def fake_import(name, *args, **kwargs): if name == "chromadb": @@ -671,11 +686,11 @@ class TestGetPoolStatus: monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") - monkeypatch.setattr(mod, "load_config", lambda: { - "enabled": True, - "supported_extensions": [".md"], - "collection_name": "test_pool" - }) + monkeypatch.setattr( + mod, + "load_config", + lambda: {"enabled": True, "supported_extensions": [".md"], "collection_name": "test_pool"}, + ) # chromadb returns empty collection list mock_client = MagicMock() diff --git a/src/aipass/memory/tests/test_json_handler.py b/src/aipass/memory/tests/test_json_handler.py index ecb23fc3..0010b0ac 100644 --- a/src/aipass/memory/tests/test_json_handler.py +++ b/src/aipass/memory/tests/test_json_handler.py @@ -38,6 +38,7 @@ import pytest # Per-test fixture: import json_handler with mocks in place # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _fresh_json_handler(monkeypatch): """Ensure json_handler module is freshly imported each test. diff --git a/src/aipass/memory/tests/test_learnings.py b/src/aipass/memory/tests/test_learnings.py index c236f68a..77c97e32 100644 --- a/src/aipass/memory/tests/test_learnings.py +++ b/src/aipass/memory/tests/test_learnings.py @@ -29,6 +29,7 @@ import pytest # Helper: import learnings manager with mocked dependencies # --------------------------------------------------------------------------- + def _import_learnings_manager(monkeypatch): """Import manager with mocked dependencies.""" # Mock memory_files since it's imported at module level @@ -52,6 +53,7 @@ def _import_learnings_manager(monkeypatch): # Fixture: fresh manager per test # --------------------------------------------------------------------------- + @pytest.fixture() def mgr(monkeypatch): """Yield (manager_module, mock_memory_files) with a fresh import.""" @@ -109,9 +111,7 @@ class TestAddTimestamp: def test_replaces_existing_date(self, mgr): manager, _ = mgr - result = manager.add_timestamp( - "old learning [2025-01-01]", date="2026-04-03" - ) + result = manager.add_timestamp("old learning [2025-01-01]", date="2026-04-03") assert result == "old learning [2026-04-03]" def test_defaults_to_today(self, mgr): @@ -180,11 +180,7 @@ class TestGetMaxRecentlyCompleted: def test_with_limits_set(self, mgr): manager, _ = mgr - data = { - "document_metadata": { - "limits": {"max_recently_completed": 10} - } - } + data = {"document_metadata": {"limits": {"max_recently_completed": 10}}} assert manager.get_max_recently_completed(data) == 10 def test_without_limits_returns_default(self, mgr): @@ -407,9 +403,7 @@ class TestEnforceLimitCompleted: data = { "recently_completed": ["Task [2026-01-01]"], - "document_metadata": { - "limits": {"max_recently_completed": 10} - }, + "document_metadata": {"limits": {"max_recently_completed": 10}}, } mock_mf.read_memory_file_data.return_value = data @@ -431,9 +425,7 @@ class TestEnforceLimitCompleted: f"Another old [{old_date}]", f"Recent task [{new_date}]", ], - "document_metadata": { - "limits": {"max_recently_completed": 2} - }, + "document_metadata": {"limits": {"max_recently_completed": 2}}, } mock_mf.read_memory_file_data.return_value = data mock_mf.write_memory_file_simple.return_value = None @@ -499,9 +491,7 @@ class TestAddLearning: fp.write_text("{}", encoding="utf-8") data = { - "key_learnings": { - "existing_key": "old value [2025-01-01]" - }, + "key_learnings": {"existing_key": "old value [2025-01-01]"}, "document_metadata": {"limits": {"max_learnings": 100}}, } mock_mf.read_memory_file_data.return_value = data diff --git a/src/aipass/memory/tests/test_memory_files.py b/src/aipass/memory/tests/test_memory_files.py index ce3784cf..8197ad25 100644 --- a/src/aipass/memory/tests/test_memory_files.py +++ b/src/aipass/memory/tests/test_memory_files.py @@ -35,6 +35,7 @@ import pytest # Per-test fixture: force-reimport memory_files with fresh mocks # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _fresh_memory_files(monkeypatch): """Ensure memory_files module is freshly imported each test. @@ -149,12 +150,8 @@ class TestReadMemoryFile: from aipass.memory.apps.handlers.json.memory_files import read_memory_file data = { - "document_metadata": { - "status": {"health": "healthy", "current_lines": 120} - }, - "sessions": [ - {"session_number": 1, "entries": [{"type": "learning", "text": "test"}]} - ], + "document_metadata": {"status": {"health": "healthy", "current_lines": 120}}, + "sessions": [{"session_number": 1, "entries": [{"type": "learning", "text": "test"}]}], } file_path = tmp_path / "nested.json" file_path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/src/aipass/memory/tests/test_rollover.py b/src/aipass/memory/tests/test_rollover.py index f7dce021..34480fea 100644 --- a/src/aipass/memory/tests/test_rollover.py +++ b/src/aipass/memory/tests/test_rollover.py @@ -22,6 +22,7 @@ from unittest.mock import MagicMock, patch # Helpers: build the full mock graph that rollover.py needs at import time # --------------------------------------------------------------------------- + def _prepare_rollover_mocks(monkeypatch): """Insert mocks for every module-level import rollover.py touches. @@ -52,9 +53,9 @@ def _prepare_rollover_mocks(monkeypatch): # aipass.memory handler sub-packages mock_detector = MagicMock() mock_detector.check_all_branches = MagicMock(return_value={"success": True, "triggers": []}) - mock_detector.get_rollover_stats = MagicMock(return_value={ - "success": True, "total_branches": 0, "files_checked": 0, "files_ready": 0, "branches": {} - }) + mock_detector.get_rollover_stats = MagicMock( + return_value={"success": True, "total_branches": 0, "files_checked": 0, "files_ready": 0, "branches": {}} + ) mock_orchestrator = MagicMock() mock_orchestrator.execute_rollover = MagicMock(return_value={"success": True, "triggers_count": 0}) @@ -83,7 +84,9 @@ def _prepare_rollover_mocks(monkeypatch): # intake (lazy import inside process_plans_command) mock_plans_processor = MagicMock() - mock_plans_processor.process_plans = MagicMock(return_value={"success": True, "files_processed": 0, "total_chunks": 0}) + mock_plans_processor.process_plans = MagicMock( + return_value={"success": True, "files_processed": 0, "total_chunks": 0} + ) intake_pkg = MagicMock() intake_pkg.plans_processor = mock_plans_processor monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.intake", intake_pkg) @@ -125,6 +128,7 @@ def _import_rollover(monkeypatch): # Tests: _SUBCOMMANDS dict # =========================================================================== + class TestSubcommands: """Verify the _SUBCOMMANDS dict exists with expected keys.""" @@ -159,6 +163,7 @@ class TestSubcommands: # Tests: handle_command routing # =========================================================================== + class TestHandleCommand: """Verify handle_command routes subcommands correctly.""" @@ -249,6 +254,7 @@ class TestHandleCommand: # Tests: _discover_handlers # =========================================================================== + class TestDiscoverHandlers: """Verify _discover_handlers scans handler directories correctly.""" diff --git a/src/aipass/memory/tests/test_search.py b/src/aipass/memory/tests/test_search.py index 4cf8fe25..2c451156 100644 --- a/src/aipass/memory/tests/test_search.py +++ b/src/aipass/memory/tests/test_search.py @@ -22,6 +22,7 @@ from unittest.mock import MagicMock # Helpers: build the full mock graph that search.py needs at import time # --------------------------------------------------------------------------- + def _prepare_search_mocks(monkeypatch): """Insert mocks for every module-level import search.py touches. @@ -50,19 +51,21 @@ def _prepare_search_mocks(monkeypatch): monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules_mod) # aipass.memory.apps.handlers.search.query_executor - mock_execute_search = MagicMock(return_value={ - "success": True, - "collections_searched": 2, - "total_results": 1, - "results": [ - { - "collection": "seed_observations", - "document": "Test document content", - "metadata": {"timestamp": "2026-01-01", "source": "local.json"}, - "similarity": 0.85, - } - ], - }) + mock_execute_search = MagicMock( + return_value={ + "success": True, + "collections_searched": 2, + "total_results": 1, + "results": [ + { + "collection": "seed_observations", + "document": "Test document content", + "metadata": {"timestamp": "2026-01-01", "source": "local.json"}, + "similarity": 0.85, + } + ], + } + ) mock_query_executor = MagicMock() mock_query_executor.execute_search = mock_execute_search @@ -113,6 +116,7 @@ def _import_search(monkeypatch): # handle_command: routing # --------------------------------------------------------------------------- + class TestHandleCommandRouting: """Verify that handle_command routes known commands and rejects unknown.""" @@ -136,9 +140,7 @@ class TestHandleCommandRouting: result = search_mod.handle_command("search", ["hello", "world"]) assert result is True - mock_show.assert_called_once_with( - "hello world", branch=None, memory_type=None, n_results=5 - ) + mock_show.assert_called_once_with("hello world", branch=None, memory_type=None, n_results=5) def test_unknown_command_returns_false(self, monkeypatch): """An unrecognised command should return False.""" @@ -161,6 +163,7 @@ class TestHandleCommandRouting: # handle_command: help flags # --------------------------------------------------------------------------- + class TestHandleCommandHelp: """Verify help flags route to print_help.""" @@ -235,6 +238,7 @@ class TestHandleCommandHelp: # handle_command: argument parsing # --------------------------------------------------------------------------- + class TestHandleCommandArgParsing: """Verify argument parsing: --branch, --type, --n, and edge cases.""" @@ -246,9 +250,7 @@ class TestHandleCommandArgParsing: search_mod.handle_command("search", ["my", "query", "--branch", "SEEDGO"]) - mock_show.assert_called_once_with( - "my query", branch="SEEDGO", memory_type=None, n_results=5 - ) + mock_show.assert_called_once_with("my query", branch="SEEDGO", memory_type=None, n_results=5) def test_type_option_parsed(self, monkeypatch): """--type value should be forwarded to show_search_results.""" @@ -258,9 +260,7 @@ class TestHandleCommandArgParsing: search_mod.handle_command("search", ["test", "--type", "observations"]) - mock_show.assert_called_once_with( - "test", branch=None, memory_type="observations", n_results=5 - ) + mock_show.assert_called_once_with("test", branch=None, memory_type="observations", n_results=5) def test_n_option_parsed(self, monkeypatch): """--n value should override the default n_results.""" @@ -270,9 +270,7 @@ class TestHandleCommandArgParsing: search_mod.handle_command("search", ["test", "--n", "10"]) - mock_show.assert_called_once_with( - "test", branch=None, memory_type=None, n_results=10 - ) + mock_show.assert_called_once_with("test", branch=None, memory_type=None, n_results=10) def test_all_options_combined(self, monkeypatch): """All options together should be correctly parsed.""" @@ -285,9 +283,7 @@ class TestHandleCommandArgParsing: ["find", "stuff", "--branch", "CLI", "--type", "local", "--n", "3"], ) - mock_show.assert_called_once_with( - "find stuff", branch="CLI", memory_type="local", n_results=3 - ) + mock_show.assert_called_once_with("find stuff", branch="CLI", memory_type="local", n_results=3) def test_invalid_n_shows_error(self, monkeypatch): """Non-numeric --n value should call error() and return True.""" @@ -323,6 +319,7 @@ class TestHandleCommandArgParsing: # _discover_handlers: handler directory scanning # --------------------------------------------------------------------------- + class TestDiscoverHandlers: """Verify handler auto-discovery logic with synthetic directory trees.""" @@ -447,6 +444,7 @@ class TestDiscoverHandlers: # show_search_results: display path # --------------------------------------------------------------------------- + class TestShowSearchResults: """Verify show_search_results calls the handler and renders output.""" @@ -457,9 +455,7 @@ class TestShowSearchResults: result = search_mod.show_search_results("test query") assert result is True - mocks["execute_search"].assert_called_once_with( - query="test query", branch=None, memory_type=None, n_results=5 - ) + mocks["execute_search"].assert_called_once_with(query="test query", branch=None, memory_type=None, n_results=5) def test_failed_search_returns_false(self, monkeypatch): """If the handler returns success=False, show_search_results returns False.""" @@ -493,13 +489,9 @@ class TestShowSearchResults: """Branch, memory_type, and n_results should be forwarded.""" search_mod, mocks = _import_search(monkeypatch) - search_mod.show_search_results( - "q", branch="SEEDGO", memory_type="local", n_results=3 - ) + search_mod.show_search_results("q", branch="SEEDGO", memory_type="local", n_results=3) - mocks["execute_search"].assert_called_once_with( - query="q", branch="SEEDGO", memory_type="local", n_results=3 - ) + mocks["execute_search"].assert_called_once_with(query="q", branch="SEEDGO", memory_type="local", n_results=3) def test_handler_timeout_returns_false(self, monkeypatch): """When the handler returns a timeout error, show_search_results returns False.""" @@ -531,10 +523,9 @@ class TestShowSearchResults: def test_handler_exception_does_not_crash(self, monkeypatch): """If the handler raises an unexpected exception, it should not propagate.""" import subprocess + search_mod, mocks = _import_search(monkeypatch) - mocks["execute_search"].side_effect = subprocess.TimeoutExpired( - cmd="python embed_subprocess.py", timeout=120 - ) + mocks["execute_search"].side_effect = subprocess.TimeoutExpired(cmd="python embed_subprocess.py", timeout=120) result = search_mod.show_search_results("crash query") diff --git a/src/aipass/memory/tests/test_storage.py b/src/aipass/memory/tests/test_storage.py index 802a9f24..6d7c7bea 100644 --- a/src/aipass/memory/tests/test_storage.py +++ b/src/aipass/memory/tests/test_storage.py @@ -26,6 +26,7 @@ from unittest.mock import MagicMock # Import helper -- chromadb must be mocked before importing chroma module # --------------------------------------------------------------------------- + def _import_chroma(monkeypatch): """Import chroma module with mocked chromadb dependency.""" mock_chromadb = MagicMock() @@ -53,6 +54,7 @@ def _reset_globals(chroma): # Tests: ChromaService class # =========================================================================== + class TestChromaServiceCollectionName: """Test ChromaService.get_collection_name.""" @@ -184,6 +186,7 @@ class TestChromaServiceListCollections: # Tests: Public API — store_vectors # =========================================================================== + class TestPublicStoreVectors: """Test public store_vectors function.""" @@ -201,7 +204,8 @@ class TestPublicStoreVectors: _reset_globals(chroma) result = chroma.store_vectors( - "SEEDGO", "observations", + "SEEDGO", + "observations", embeddings=[[0.1, 0.2]], documents=["doc1", "doc2"], metadatas=[{"k": "v"}], @@ -222,7 +226,8 @@ class TestPublicStoreVectors: mock_chromadb.PersistentClient.return_value = mock_client result = chroma.store_vectors( - "SEEDGO", "observations", + "SEEDGO", + "observations", embeddings=[[0.1, 0.2], [0.3, 0.4]], documents=["doc1", "doc2"], metadatas=[{"branch": "SEEDGO"}, {"branch": "SEEDGO"}], @@ -246,7 +251,8 @@ class TestPublicStoreVectors: str_path = str(tmp_path / ".chroma") result = chroma.store_vectors( - "CLI", "local", + "CLI", + "local", embeddings=[[0.1]], documents=["doc1"], metadatas=[{"branch": "CLI"}], @@ -264,7 +270,8 @@ class TestPublicStoreVectors: mock_chromadb.PersistentClient.side_effect = RuntimeError("DB error") result = chroma.store_vectors( - "SEEDGO", "observations", + "SEEDGO", + "observations", embeddings=[[0.1]], documents=["doc1"], metadatas=[{"k": "v"}], @@ -278,6 +285,7 @@ class TestPublicStoreVectors: # Tests: Public API — get_collection_stats # =========================================================================== + class TestPublicGetCollectionStats: """Test public get_collection_stats function.""" @@ -322,6 +330,7 @@ class TestPublicGetCollectionStats: # Tests: Public API — list_all_collections # =========================================================================== + class TestPublicListAllCollections: """Test public list_all_collections function.""" @@ -349,6 +358,7 @@ class TestPublicListAllCollections: # Tests: Public API — get_database_info # =========================================================================== + class TestPublicGetDatabaseInfo: """Test public get_database_info function.""" @@ -380,6 +390,7 @@ class TestPublicGetDatabaseInfo: # Tests: Public API — search_vectors # =========================================================================== + class TestPublicSearchVectors: """Test public search_vectors function.""" @@ -529,7 +540,9 @@ class TestPublicSearchVectors: setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) result = chroma.search_vectors( - [0.1] * 384, branch="test", memory_type="local", + [0.1] * 384, + branch="test", + memory_type="local", ) assert result["success"] is True @@ -541,6 +554,7 @@ class TestPublicSearchVectors: # Tests: Singleton / get_client # =========================================================================== + class TestGetClient: """Test get_client singleton behaviour.""" diff --git a/src/aipass/memory/tests/test_symbolic.py b/src/aipass/memory/tests/test_symbolic.py index bacb720a..6f1d7e19 100644 --- a/src/aipass/memory/tests/test_symbolic.py +++ b/src/aipass/memory/tests/test_symbolic.py @@ -38,6 +38,7 @@ _handler_mocks = types.SimpleNamespace( # Autouse fixture -- mock all heavy imports before symbolic.py is loaded # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _mock_symbolic_infrastructure(monkeypatch): """Replace handler modules with MagicMock before importing symbolic.py.""" @@ -62,9 +63,7 @@ def _mock_symbolic_infrastructure(monkeypatch): json_pkg = MagicMock() json_pkg.json_handler = mock_json_handler monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler - ) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler) # -- symbolic handler sub-modules (the delegation targets) -------------- mock_extractor = MagicMock() @@ -81,27 +80,15 @@ def _mock_symbolic_infrastructure(monkeypatch): symbolic_pkg.deduplicator = mock_deduplicator monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.symbolic", symbolic_pkg) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.symbolic.extractor", mock_extractor - ) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.symbolic.storage", mock_storage - ) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.symbolic.retriever", mock_retriever - ) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.symbolic.hook", mock_hook - ) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.symbolic.deduplicator", mock_deduplicator - ) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.symbolic.extractor", mock_extractor) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.symbolic.storage", mock_storage) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.symbolic.retriever", mock_retriever) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.symbolic.hook", mock_hook) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.symbolic.deduplicator", mock_deduplicator) # -- vector embedder (imported by storage handler) ---------------------- monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.vector", MagicMock()) - monkeypatch.setitem( - sys.modules, "aipass.memory.apps.handlers.vector.embedder", MagicMock() - ) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.vector.embedder", MagicMock()) # -- trigger (lazy import inside create_fragment) ----------------------- mock_trigger_core = MagicMock() @@ -126,6 +113,7 @@ def _mock_symbolic_infrastructure(monkeypatch): # Helpers # --------------------------------------------------------------------------- + def _sample_chat() -> list: """Return a minimal chat history list.""" return [ @@ -151,6 +139,7 @@ def _import_symbolic(): delattr(parent, "symbolic") from aipass.memory.apps.modules import symbolic + return symbolic @@ -158,6 +147,7 @@ def _import_symbolic(): # EXTRACTION DELEGATION TESTS # =========================================================================== + class TestExtractTechnicalFlow: """extract_technical_flow delegates to extractor handler.""" @@ -168,9 +158,7 @@ class TestExtractTechnicalFlow: result = symbolic.extract_technical_flow(_sample_chat()) - _handler_mocks.extractor.extract_technical_flow.assert_called_once_with( - _sample_chat() - ) + _handler_mocks.extractor.extract_technical_flow.assert_called_once_with(_sample_chat()) assert result == expected def test_returns_handler_result_unchanged(self): @@ -191,9 +179,7 @@ class TestExtractEmotionalJourney: result = symbolic.extract_emotional_journey(_sample_chat()) - _handler_mocks.extractor.extract_emotional_journey.assert_called_once_with( - _sample_chat() - ) + _handler_mocks.extractor.extract_emotional_journey.assert_called_once_with(_sample_chat()) assert result == expected @@ -207,9 +193,7 @@ class TestExtractCollaborationPatterns: result = symbolic.extract_collaboration_patterns(_sample_chat()) - _handler_mocks.extractor.extract_collaboration_patterns.assert_called_once_with( - _sample_chat() - ) + _handler_mocks.extractor.extract_collaboration_patterns.assert_called_once_with(_sample_chat()) assert result == expected @@ -223,9 +207,7 @@ class TestExtractKeyLearnings: result = symbolic.extract_key_learnings(_sample_chat()) - _handler_mocks.extractor.extract_key_learnings.assert_called_once_with( - _sample_chat() - ) + _handler_mocks.extractor.extract_key_learnings.assert_called_once_with(_sample_chat()) assert result == expected @@ -239,9 +221,7 @@ class TestExtractContextTriggers: result = symbolic.extract_context_triggers(_sample_chat()) - _handler_mocks.extractor.extract_context_triggers.assert_called_once_with( - _sample_chat() - ) + _handler_mocks.extractor.extract_context_triggers.assert_called_once_with(_sample_chat()) assert result == expected @@ -259,9 +239,7 @@ class TestExtractSymbolicDimensions: result = symbolic.extract_symbolic_dimensions(_sample_chat()) - _handler_mocks.extractor.extract_symbolic_dimensions.assert_called_once_with( - _sample_chat() - ) + _handler_mocks.extractor.extract_symbolic_dimensions.assert_called_once_with(_sample_chat()) assert result == expected @@ -269,6 +247,7 @@ class TestExtractSymbolicDimensions: # STORAGE DELEGATION TESTS # =========================================================================== + class TestCreateFragment: """create_fragment delegates to storage handler and fires trigger.""" @@ -283,9 +262,7 @@ class TestCreateFragment: result = symbolic.create_fragment(analysis, content="hello", source_branch="memory") - _handler_mocks.storage.create_fragment.assert_called_once_with( - analysis, "hello", "memory" - ) + _handler_mocks.storage.create_fragment.assert_called_once_with(analysis, "hello", "memory") assert result == fragment_result def test_fires_trigger_on_success(self): @@ -348,6 +325,7 @@ class TestCreateFragment: # EMPTY INPUT / EDGE CASE TESTS # =========================================================================== + class TestEmptyChatHistory: """Verify wrapper functions forward empty lists without crashing.""" diff --git a/src/aipass/memory/tests/test_templates.py b/src/aipass/memory/tests/test_templates.py index dfe82984..87a75a5d 100644 --- a/src/aipass/memory/tests/test_templates.py +++ b/src/aipass/memory/tests/test_templates.py @@ -24,6 +24,7 @@ from unittest.mock import MagicMock, patch # Helpers: build the full mock graph that templates.py needs at import time # --------------------------------------------------------------------------- + def _prepare_templates_mocks(monkeypatch): """Insert mocks for every module-level import templates.py touches. @@ -58,38 +59,62 @@ def _prepare_templates_mocks(monkeypatch): # -- aipass.memory.apps.handlers.templates.pusher -- mock_pusher = MagicMock() - mock_pusher.push_templates = MagicMock(return_value={ - "success": True, "branches_scanned": 5, "branches_updated": 2, - "files_modified": 3, "changes": [], "errors": [], - }) - mock_pusher.get_template_status = MagicMock(return_value={ - "version": "2.0.0", "last_push": "2026-03-20", - "local_template_exists": True, "observations_template_exists": True, - "templates_dir": "/tmp/templates", "last_push_branches": [], - }) + mock_pusher.push_templates = MagicMock( + return_value={ + "success": True, + "branches_scanned": 5, + "branches_updated": 2, + "files_modified": 3, + "changes": [], + "errors": [], + } + ) + mock_pusher.get_template_status = MagicMock( + return_value={ + "version": "2.0.0", + "last_push": "2026-03-20", + "local_template_exists": True, + "observations_template_exists": True, + "templates_dir": "/tmp/templates", + "last_push_branches": [], + } + ) monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.templates.pusher", mock_pusher) # -- aipass.memory.apps.handlers.templates.differ -- mock_differ = MagicMock() - mock_differ.diff_template_vs_branch = MagicMock(return_value={ - "local": [], "observations": [], "errors": [], - }) + mock_differ.diff_template_vs_branch = MagicMock( + return_value={ + "local": [], + "observations": [], + "errors": [], + } + ) monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.templates.differ", mock_differ) # -- aipass.memory.apps.handlers.templates.spawn_pusher -- mock_spawn_pusher = MagicMock() - mock_spawn_pusher.push_to_spawn_templates = MagicMock(return_value={ - "success": True, "template_sets_found": [], "template_sets_updated": 0, - "files_modified": 0, "changes": [], - }) + mock_spawn_pusher.push_to_spawn_templates = MagicMock( + return_value={ + "success": True, + "template_sets_found": [], + "template_sets_updated": 0, + "files_modified": 0, + "changes": [], + } + ) monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.templates.spawn_pusher", mock_spawn_pusher) # -- parent packages that Python needs to resolve dotted imports -- - monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.templates", MagicMock( - pusher=mock_pusher, - differ=mock_differ, - spawn_pusher=mock_spawn_pusher, - )) + monkeypatch.setitem( + sys.modules, + "aipass.memory.apps.handlers.templates", + MagicMock( + pusher=mock_pusher, + differ=mock_differ, + spawn_pusher=mock_spawn_pusher, + ), + ) return { "console": mock_console, @@ -127,6 +152,7 @@ def _import_templates(monkeypatch): # Tests: _find_repo_root # =========================================================================== + class TestFindRepoRoot: """Tests for _find_repo_root -- walks up from __file__ to find AIPASS_REGISTRY.json.""" @@ -196,6 +222,7 @@ class TestFindRepoRoot: # Tests: _discover_handlers # =========================================================================== + class TestDiscoverHandlers: """Tests for _discover_handlers -- auto-discovers handler directories.""" @@ -337,6 +364,7 @@ class TestDiscoverHandlers: # Tests: handle_command routing # =========================================================================== + class TestHandleCommand: """Tests for handle_command -- routes subcommands correctly.""" @@ -573,6 +601,7 @@ class TestHandleCommand: # Tests: _load_branches_from_registry # =========================================================================== + class TestLoadBranchesFromRegistry: """Tests for _load_branches_from_registry -- loads active branches from registry.""" @@ -676,6 +705,7 @@ class TestLoadBranchesFromRegistry: # Tests: _SUBCOMMANDS dict # =========================================================================== + class TestSubcommands: """Verify the _SUBCOMMANDS dict exists with expected keys.""" diff --git a/src/aipass/memory/tests/test_vector.py b/src/aipass/memory/tests/test_vector.py index 88e85e9c..06c631cb 100644 --- a/src/aipass/memory/tests/test_vector.py +++ b/src/aipass/memory/tests/test_vector.py @@ -29,6 +29,7 @@ import numpy as np # Import helper -- torch and sentence_transformers must be mocked # --------------------------------------------------------------------------- + def _import_embedder(monkeypatch): """Import embedder module with mocked ML dependencies. @@ -68,6 +69,7 @@ def _reset_globals(embedder) -> None: # Tests: Public API -- encode_batch # =========================================================================== + class TestPublicEncodeBatch: """Test public encode_batch function.""" @@ -121,6 +123,7 @@ class TestPublicEncodeBatch: # Tests: Public API -- encode_memories # =========================================================================== + class TestPublicEncodeMemories: """Test public encode_memories function.""" @@ -227,6 +230,7 @@ class TestPublicEncodeMemories: # Tests: Public API -- get_model_info # =========================================================================== + class TestPublicGetModelInfo: """Test public get_model_info function.""" @@ -258,6 +262,7 @@ class TestPublicGetModelInfo: # Tests: EmbeddingService class -- encode_batch internals # =========================================================================== + class TestEmbeddingServiceEncodeBatch: """Test EmbeddingService.encode_batch pre-sort and order restoration.""" @@ -320,6 +325,7 @@ class TestEmbeddingServiceEncodeBatch: # Tests: EmbeddingService -- GPU path # =========================================================================== + class TestEmbeddingServiceGPU: """Test EmbeddingService GPU detection and cleanup.""" diff --git a/src/aipass/memory/tests/test_verify.py b/src/aipass/memory/tests/test_verify.py index 4cc92b8e..1765f372 100644 --- a/src/aipass/memory/tests/test_verify.py +++ b/src/aipass/memory/tests/test_verify.py @@ -19,11 +19,11 @@ from pathlib import Path from unittest.mock import MagicMock - # --------------------------------------------------------------------------- # Helper: mock CLI modules that verify.py imports at module level # --------------------------------------------------------------------------- + def _mock_cli_modules(monkeypatch): """Inject mocks for aipass.cli.apps.modules (console, error).""" mock_console = MagicMock() @@ -40,6 +40,7 @@ def _mock_cli_modules(monkeypatch): def _import_verify(): """Import verify module inside the test (after mocks are in place).""" from aipass.memory.apps.modules import verify + return verify @@ -85,9 +86,7 @@ class TestGetMemoryPython: monkeypatch.delenv("AIPASS_MEMORY_PYTHON", raising=False) # Point to a path that does not exist - monkeypatch.setattr( - verify, "_MEMORY_VENV_PYTHON", Path("/nonexistent/.venv/bin/python") - ) + monkeypatch.setattr(verify, "_MEMORY_VENV_PYTHON", Path("/nonexistent/.venv/bin/python")) result = verify._get_memory_python() assert result == sys.executable @@ -98,9 +97,7 @@ class TestGetMemoryPython: monkeypatch.setenv("AIPASS_MEMORY_PYTHON", "") # Empty string is falsy, so it should NOT be returned - monkeypatch.setattr( - verify, "_MEMORY_VENV_PYTHON", Path("/nonexistent/.venv/bin/python") - ) + monkeypatch.setattr(verify, "_MEMORY_VENV_PYTHON", Path("/nonexistent/.venv/bin/python")) result = verify._get_memory_python() # Falls through to venv check (missing) then sys.executable assert result == sys.executable diff --git a/src/aipass/prax/__init__.py b/src/aipass/prax/__init__.py index e2609a6e..2e63903a 100644 --- a/src/aipass/prax/__init__.py +++ b/src/aipass/prax/__init__.py @@ -1,4 +1,5 @@ """Prax - Monitoring and logging for AIPass.""" + try: from aipass.prax.apps.modules.logger import system_logger as logger except Exception: diff --git a/src/aipass/prax/apps/handlers/__init__.py b/src/aipass/prax/apps/handlers/__init__.py index 2eec073a..bab78987 100755 --- a/src/aipass/prax/apps/handlers/__init__.py +++ b/src/aipass/prax/apps/handlers/__init__.py @@ -62,8 +62,10 @@ def _guard_branch_access(): # DEBUG: Print what we found import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) @@ -89,9 +91,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -105,7 +107,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/prax/apps/handlers/config/ignore_patterns.py b/src/aipass/prax/apps/handlers/config/ignore_patterns.py index 958a2560..d1524208 100755 --- a/src/aipass/prax/apps/handlers/config/ignore_patterns.py +++ b/src/aipass/prax/apps/handlers/config/ignore_patterns.py @@ -45,16 +45,26 @@ PRAX_LOGGER_CONFIG_FILE = PRAX_JSON_DIR / "prax_logger_config.json" # Hardcoded fallback patterns DEFAULT_IGNORE_FOLDERS = { - '.git', '__pycache__', '.venv', 'vendor', 'node_modules', - 'Archive', 'Backups', 'External_Code_Sources', 'WorkShop', - '.claude-server-commander-logs', - 'backup', 'backups', 'archive.local' + ".git", + "__pycache__", + ".venv", + "vendor", + "node_modules", + "Archive", + "Backups", + "External_Code_Sources", + "WorkShop", + ".claude-server-commander-logs", + "backup", + "backups", + "archive.local", } # ============================================= # HANDLER FUNCTION # ============================================= + def load_ignore_patterns_from_config() -> Set[str]: """Load ignore patterns from prax_logger config file @@ -74,14 +84,16 @@ def load_ignore_patterns_from_config() -> Set[str]: if not PRAX_LOGGER_CONFIG_FILE.exists(): return DEFAULT_IGNORE_FOLDERS - with open(PRAX_LOGGER_CONFIG_FILE, 'r', encoding='utf-8') as f: + with open(PRAX_LOGGER_CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) - patterns = config.get('config', {}).get('ignore_patterns', []) + patterns = config.get("config", {}).get("ignore_patterns", []) if patterns: json_handler.log_operation("ignore_patterns_loaded", {"pattern_count": len(patterns)}) return set(patterns) except Exception as e: - logger.warning("ignore_patterns: failed to load config from '%s', using defaults: %s", PRAX_LOGGER_CONFIG_FILE, e) + logger.warning( + "ignore_patterns: failed to load config from '%s', using defaults: %s", PRAX_LOGGER_CONFIG_FILE, e + ) return DEFAULT_IGNORE_FOLDERS diff --git a/src/aipass/prax/apps/handlers/config/load.py b/src/aipass/prax/apps/handlers/config/load.py index 9b04ef21..32df2313 100755 --- a/src/aipass/prax/apps/handlers/config/load.py +++ b/src/aipass/prax/apps/handlers/config/load.py @@ -31,6 +31,7 @@ import inspect import json import logging import os + logger = logging.getLogger(__name__) from pathlib import Path from typing import Dict, Any, Optional @@ -48,6 +49,7 @@ PRAX_ROOT = Path(__file__).resolve().parents[3] # config/load.py → handlers/ ECOSYSTEM_ROOT = PRAX_ROOT.parent # prax/ → aipass/ (contains all sibling modules) PRAX_JSON_DIR = PRAX_ROOT / "prax_json" + def _find_repo_root() -> Path: """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -56,10 +58,12 @@ def _find_repo_root() -> Path: return parent return Path.cwd() + # Lazy SYSTEM_LOGS_DIR — resolved on first access, not at import time. # Callers should use get_system_logs_dir() for guaranteed initialization. _system_logs_dir_cache: Path | None = None + def get_system_logs_dir() -> Path: """Lazily resolve and create system_logs directory (package-relative). @@ -83,14 +87,18 @@ def _warn_routing(module_name: str, destination: object) -> None: """Log routing warning when a module's log path falls outside ECOSYSTEM_ROOT.""" try: from aipass.prax.apps.modules.logger import get_direct_logger + get_direct_logger().warning( "[get_module_logs_dir] '%s' not in ECOSYSTEM_ROOT; routing to %s", - module_name, destination, + module_name, + destination, ) except Exception as e: logger.warning( "[get_module_logs_dir] '%s' routing to %s (logger unavailable: %s)", - module_name, destination, e, + module_name, + destination, + e, ) @@ -149,8 +157,11 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path: if caller_cwd: caller_path = Path(caller_cwd) project_root = next( - (c for c in [caller_path, *caller_path.parents] - if (c / ".git").exists() or (c / "pyproject.toml").exists()), + ( + c + for c in [caller_path, *caller_path.parents] + if (c / ".git").exists() or (c / "pyproject.toml").exists() + ), None, ) if project_root: @@ -166,30 +177,24 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path: _warn_routing(module_name, "system_logs/external/") return logs_dir + # Config file PRAX_LOGGER_CONFIG_FILE = PRAX_JSON_DIR / "prax_logger_config.json" # Default configuration constants -LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -DATE_FORMAT = '%Y-%m-%d %H:%M:%S' +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" DEFAULT_LOG_LEVEL = "INFO" -DEFAULT_SYSTEM_LOGS = { - "max_lines": 1000, - "backup_count": 1, - "log_level": "INFO" -} +DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 1, "log_level": "INFO"} -DEFAULT_LOCAL_LOGS = { - "max_lines": 250, - "backup_count": 1, - "log_level": "INFO" -} +DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 1, "log_level": "INFO"} # ============================================= # HANDLER FUNCTIONS # ============================================= + def lines_to_bytes(num_lines: int, avg_line_length: int = 200) -> int: """Convert number of lines to approximate bytes for log rotation @@ -202,6 +207,7 @@ def lines_to_bytes(num_lines: int, avg_line_length: int = 200) -> int: """ return num_lines * avg_line_length + def get_debug_prints_enabled() -> bool: """Check if debug prints are enabled in config @@ -210,13 +216,14 @@ def get_debug_prints_enabled() -> bool: """ try: if PRAX_LOGGER_CONFIG_FILE.exists(): - with open(PRAX_LOGGER_CONFIG_FILE, 'r', encoding='utf-8') as f: + with open(PRAX_LOGGER_CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) - return config.get('config', {}).get('debug_prints_enabled', False) + return config.get("config", {}).get("debug_prints_enabled", False) except (json.JSONDecodeError, OSError) as e: logger.info(f"Config load error (using defaults): {e}") return False + def load_log_config() -> Dict[str, Any]: """Load logging config from JSON, fallback to defaults @@ -246,18 +253,18 @@ def load_log_config() -> Dict[str, Any]: """ try: if PRAX_LOGGER_CONFIG_FILE.exists(): - with open(PRAX_LOGGER_CONFIG_FILE, 'r', encoding='utf-8') as f: + with open(PRAX_LOGGER_CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) # Extract system and local log settings - system_logs = config.get('config', {}).get('system_logs', DEFAULT_SYSTEM_LOGS) - local_logs = config.get('config', {}).get('local_logs', DEFAULT_LOCAL_LOGS) + system_logs = config.get("config", {}).get("system_logs", DEFAULT_SYSTEM_LOGS) + local_logs = config.get("config", {}).get("local_logs", DEFAULT_LOCAL_LOGS) result = { - 'system_logs': system_logs, - 'local_logs': local_logs, - 'log_format': config.get('config', {}).get('log_format', LOG_FORMAT), - 'date_format': config.get('config', {}).get('date_format', DATE_FORMAT) + "system_logs": system_logs, + "local_logs": local_logs, + "log_format": config.get("config", {}).get("log_format", LOG_FORMAT), + "date_format": config.get("config", {}).get("date_format", DATE_FORMAT), } json_handler.log_operation("config_loaded", {"source": str(PRAX_LOGGER_CONFIG_FILE)}) return result @@ -266,8 +273,8 @@ def load_log_config() -> Dict[str, Any]: # Fallback to code defaults return { - 'system_logs': DEFAULT_SYSTEM_LOGS, - 'local_logs': DEFAULT_LOCAL_LOGS, - 'log_format': LOG_FORMAT, - 'date_format': DATE_FORMAT + "system_logs": DEFAULT_SYSTEM_LOGS, + "local_logs": DEFAULT_LOCAL_LOGS, + "log_format": LOG_FORMAT, + "date_format": DATE_FORMAT, } diff --git a/src/aipass/prax/apps/handlers/dashboard/__init__.py b/src/aipass/prax/apps/handlers/dashboard/__init__.py index 0601fb0e..a59b858f 100644 --- a/src/aipass/prax/apps/handlers/dashboard/__init__.py +++ b/src/aipass/prax/apps/handlers/dashboard/__init__.py @@ -1,4 +1,3 @@ - """ Dashboard Handlers Package @@ -7,4 +6,4 @@ Provides dashboard write-through for PRAX-managed sections. from .agent_status_writer import push_agent_status_dashboard -__all__ = ['push_agent_status_dashboard'] +__all__ = ["push_agent_status_dashboard"] diff --git a/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py b/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py index d474bf7e..2a86d209 100644 --- a/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py +++ b/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py @@ -37,6 +37,7 @@ from aipass.prax.apps.handlers.json import json_handler # CONSTANTS # ============================================================================= + def _find_repo_root() -> Path: """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -45,10 +46,12 @@ def _find_repo_root() -> Path: return parent return Path.cwd() + def _get_aipass_registry() -> Path: """Lazily resolve AIPASS_REGISTRY.json path.""" return _find_repo_root() / "AIPASS_REGISTRY.json" + STALE_THRESHOLD_MINUTES = 120 @@ -56,6 +59,7 @@ STALE_THRESHOLD_MINUTES = 120 # DATA COLLECTION # ============================================================================= + def _get_all_branches() -> List[Dict[str, Any]]: """ Load all branches from AIPASS_REGISTRY.json. @@ -73,10 +77,7 @@ def _get_all_branches() -> List[Dict[str, Any]]: for branch in data.get("branches", []): branch_path = Path(branch.get("path", "")) if branch_path.exists(): - branches.append({ - "name": branch.get("name", ""), - "path": branch_path - }) + branches.append({"name": branch.get("name", ""), "path": branch_path}) return branches except Exception as e: logger.warning("Failed to load AIPASS_REGISTRY.json: %s", e) @@ -173,20 +174,22 @@ def _scan_active_agents() -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: # Validate PID is still alive if not _is_pid_alive(pid): # Stale lock — process died without cleanup - stale_agents.append({ - "branch": branch["name"], - "pid": pid, - "started": timestamp, - "runtime_minutes": round(runtime_minutes, 1), - "status": "dead_process" - }) + stale_agents.append( + { + "branch": branch["name"], + "pid": pid, + "started": timestamp, + "runtime_minutes": round(runtime_minutes, 1), + "status": "dead_process", + } + ) continue agent_info = { "branch": branch["name"], "pid": pid, "started": timestamp, - "runtime_minutes": round(runtime_minutes, 1) + "runtime_minutes": round(runtime_minutes, 1), } if runtime_minutes > STALE_THRESHOLD_MINUTES: @@ -202,6 +205,7 @@ def _scan_active_agents() -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: # PUBLIC API # ============================================================================= + def build_agent_status_section() -> Dict[str, Any]: """ Build the agent_status dashboard section data. @@ -220,7 +224,7 @@ def build_agent_status_section() -> Dict[str, Any]: "active_agents": active_agents, "agent_count": len(active_agents), "stale_agents": stale_agents, - "last_updated": datetime.now().isoformat() + "last_updated": datetime.now().isoformat(), } @@ -234,8 +238,7 @@ def _get_all_branch_paths() -> List[Path]: return [b["path"] for b in _get_all_branches()] -def _write_section_to_all_branches(section_name: str, section_data: Dict, - branch_paths: List[Path]) -> int: +def _write_section_to_all_branches(section_name: str, section_data: Dict, branch_paths: List[Path]) -> int: """ Write a dashboard section to multiple branches via a single subprocess. @@ -268,18 +271,12 @@ def _write_section_to_all_branches(section_name: str, section_data: Dict, "print(ok)\n" ) - input_data = json.dumps({ - "section_name": section_name, - "section_data": section_data, - "branch_paths": [str(p) for p in branch_paths] - }) + input_data = json.dumps( + {"section_name": section_name, "section_data": section_data, "branch_paths": [str(p) for p in branch_paths]} + ) result = subprocess.run( - [sys.executable, "-c", script], - input=input_data, - capture_output=True, - text=True, - timeout=60 + [sys.executable, "-c", script], input=input_data, capture_output=True, text=True, timeout=60 ) if result.returncode == 0 and result.stdout.strip().isdigit(): @@ -311,16 +308,17 @@ def push_agent_status_dashboard() -> bool: if not branch_paths: return False - success_count = _write_section_to_all_branches( - "agent_status", section_data, branch_paths - ) + success_count = _write_section_to_all_branches("agent_status", section_data, branch_paths) - json_handler.log_operation("agent_status_written", { - "branches_targeted": len(branch_paths), - "branches_updated": success_count, - "active_agents": section_data.get("agent_count", 0), - "stale_agents": len(section_data.get("stale_agents", [])), - }) + json_handler.log_operation( + "agent_status_written", + { + "branches_targeted": len(branch_paths), + "branches_updated": success_count, + "active_agents": section_data.get("agent_count", 0), + "stale_agents": len(section_data.get("stale_agents", [])), + }, + ) return success_count > 0 diff --git a/src/aipass/prax/apps/handlers/dashboard/operations.py b/src/aipass/prax/apps/handlers/dashboard/operations.py index 95f15590..d5bb4749 100644 --- a/src/aipass/prax/apps/handlers/dashboard/operations.py +++ b/src/aipass/prax/apps/handlers/dashboard/operations.py @@ -130,9 +130,7 @@ def create_fresh_dashboard(branch_path: Path) -> Dict: template = json.loads(template_file.read_text()) now = datetime.now().isoformat() # Replace placeholders - dashboard = json.loads( - json.dumps(template).replace("{{BRANCHNAME}}", branch_path.name.upper()) - ) + dashboard = json.loads(json.dumps(template).replace("{{BRANCHNAME}}", branch_path.name.upper())) dashboard["last_updated"] = now return dashboard except (json.JSONDecodeError, OSError) as e: @@ -149,17 +147,19 @@ def create_fresh_dashboard(branch_path: Path) -> Dict: "ai_mail": {"managed_by": "ai_mail", "new": 0, "opened": 0, "total": 0, "last_updated": ""}, "flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""}, "memory": {"managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": ""}, - "commons_activity": {"managed_by": "the_commons", "mentions": 0, "new_posts_since_last_visit": 0, "new_comments_since_last_visit": 0, "last_updated": ""} - } + "commons_activity": { + "managed_by": "the_commons", + "mentions": 0, + "new_posts_since_last_visit": 0, + "new_comments_since_last_visit": 0, + "last_updated": "", + }, + }, } def update_section( - branch_path: Path, - section_name: str, - section_data: Dict, - template: Dict, - calculate_status_func + branch_path: Path, section_name: str, section_data: Dict, template: Dict, calculate_status_func ) -> bool: """ Update a specific section in branch dashboard (legacy interface). @@ -242,7 +242,7 @@ def _calculate_quick_status_standalone(sections: Dict) -> Dict: "active_plans": active_plans, "commons_mentions": mentions, "action_required": action_required, - "summary": ", ".join(parts) if parts else "All clear" + "summary": ", ".join(parts) if parts else "All clear", } @@ -311,10 +311,13 @@ def write_section(branch_path: Path, section_name: str, section_data: Dict) -> b # Save saved = save_dashboard(branch_path, dashboard) - json_handler.log_operation("section_updated", { - "section": section_name, - "branch": branch_path.name, - }) + json_handler.log_operation( + "section_updated", + { + "section": section_name, + "branch": branch_path.name, + }, + ) return saved diff --git a/src/aipass/prax/apps/handlers/dashboard/refresh.py b/src/aipass/prax/apps/handlers/dashboard/refresh.py index cbcf0d92..d023646d 100644 --- a/src/aipass/prax/apps/handlers/dashboard/refresh.py +++ b/src/aipass/prax/apps/handlers/dashboard/refresh.py @@ -91,15 +91,14 @@ def _extract_flow_section(centrals: Dict, branch_name: str) -> Dict: recently_closed_raw = plans_data.get("recently_closed", []) # Simplify for dashboard display (just id and subject) recently_closed = [ - {"plan_id": p.get("plan_id", ""), "subject": p.get("subject", "")} - for p in recently_closed_raw[:5] + {"plan_id": p.get("plan_id", ""), "subject": p.get("subject", "")} for p in recently_closed_raw[:5] ] return { "managed_by": "flow", "active_plans": len(branch_plans), "recently_closed": recently_closed, - "last_updated": plans_data.get("generated_at", plans_data.get("last_updated", datetime.now().isoformat())) + "last_updated": plans_data.get("generated_at", plans_data.get("last_updated", datetime.now().isoformat())), } @@ -116,7 +115,7 @@ def _extract_ai_mail_section(centrals: Dict, branch_name: str) -> Dict: "managed_by": "ai_mail", "unread": stats.get("unread", 0), "total": stats.get("total", 0), - "last_updated": mail_data.get("last_updated", datetime.now().isoformat()) + "last_updated": mail_data.get("last_updated", datetime.now().isoformat()), } @@ -137,6 +136,7 @@ def _extract_memory_section(centrals: Dict, branch_path: Path) -> Dict: sqlite_file = chroma_dir / "chroma.sqlite3" if sqlite_file.exists(): import sqlite3 + conn = sqlite3.connect(str(sqlite_file)) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM embeddings") @@ -149,12 +149,7 @@ def _extract_memory_section(centrals: Dict, branch_path: Path) -> Dict: mb_data = centrals.get("memory", {}) mb_last_updated = mb_data.get("last_updated", datetime.now().isoformat()) - return { - "managed_by": "memory", - "vectors_stored": local_vectors, - "notes": {}, - "last_updated": mb_last_updated - } + return {"managed_by": "memory", "vectors_stored": local_vectors, "notes": {}, "last_updated": mb_last_updated} def _extract_commons_section(centrals: Dict, branch_name: str) -> Optional[Dict]: @@ -183,7 +178,7 @@ def _extract_commons_section(centrals: Dict, branch_name: str) -> Optional[Dict] "mentions": stats.get("mentions", 0), "new_posts_since_last_visit": stats.get("new_posts_since_last_visit", 0), "new_comments_since_last_visit": stats.get("new_comments_since_last_visit", 0), - "last_updated": stats.get("last_updated", "") + "last_updated": stats.get("last_updated", ""), } @@ -228,7 +223,7 @@ def _calculate_quick_status(sections: Dict) -> Dict: "active_plans": active_plans, "commons_mentions": mentions, "action_required": action_required, - "summary": ", ".join(parts) if parts else "All clear" + "summary": ", ".join(parts) if parts else "All clear", } @@ -286,12 +281,7 @@ def refresh_all_dashboards() -> Dict: branch_paths = _load_branch_paths() except Exception as e: logger.error("Failed to load branch paths: %s", e) - return { - "status": "error", - "branches_updated": 0, - "branches_failed": 0, - "errors": [str(e)] - } + return {"status": "error", "branches_updated": 0, "branches_failed": 0, "errors": [str(e)]} # Update each branch for branch_path in branch_paths: @@ -329,17 +319,20 @@ def refresh_all_dashboards() -> Dict: else: status = "error" - json_handler.log_operation("dashboard_refreshed", { - "status": status, - "branches_updated": branches_updated, - "branches_failed": branches_failed, - }) + json_handler.log_operation( + "dashboard_refreshed", + { + "status": status, + "branches_updated": branches_updated, + "branches_failed": branches_failed, + }, + ) return { "status": status, "branches_updated": branches_updated, "branches_failed": branches_failed, - "errors": errors + "errors": errors, } diff --git a/src/aipass/prax/apps/handlers/dashboard/status.py b/src/aipass/prax/apps/handlers/dashboard/status.py index 62f0618d..c1449556 100644 --- a/src/aipass/prax/apps/handlers/dashboard/status.py +++ b/src/aipass/prax/apps/handlers/dashboard/status.py @@ -74,14 +74,17 @@ def calculate_quick_status(sections: Dict) -> Dict: "active_plans": active_plans, "commons_mentions": mentions, "action_required": action_required, - "summary": ", ".join(summary_parts) if summary_parts else "All clear" + "summary": ", ".join(summary_parts) if summary_parts else "All clear", } - json_handler.log_operation("status_calculated", { - "action_required": action_required, - "new_mail": new_mail, - "active_plans": active_plans, - }) + json_handler.log_operation( + "status_calculated", + { + "action_required": action_required, + "new_mail": new_mail, + "active_plans": active_plans, + }, + ) return result diff --git a/src/aipass/prax/apps/handlers/dashboard/template_differ.py b/src/aipass/prax/apps/handlers/dashboard/template_differ.py index 6639fc07..6f3ee363 100644 --- a/src/aipass/prax/apps/handlers/dashboard/template_differ.py +++ b/src/aipass/prax/apps/handlers/dashboard/template_differ.py @@ -63,15 +63,14 @@ DEPRECATED_SECTIONS = ["bulletin_board", "devpulse"] DEPRECATED_QUICK_STATUS_KEYS = ["pending_bulletins"] # Required sections (from template) -REQUIRED_SECTIONS = [ - "ai_mail", "flow", "memory", "commons_activity" -] +REQUIRED_SECTIONS = ["ai_mail", "flow", "memory", "commons_activity"] # ============================================================================= # DIFF LOGIC # ============================================================================= + def _diff_branch(branch_name: str, branch_path: Path, template: dict) -> Dict[str, Any]: """ Compare a single branch's dashboard against the template. @@ -90,7 +89,7 @@ def _diff_branch(branch_name: str, branch_path: Path, template: dict) -> Dict[st "additions": [], "removals": [], "modifications": [], - "status": "up_to_date" + "status": "up_to_date", } dashboard_path = branch_path / "DASHBOARD.local.json" @@ -146,8 +145,7 @@ def _diff_branch(branch_name: str, branch_path: Path, template: dict) -> Dict[st result["modifications"].append(f"quick_status: remove {dep_key}") # Check for missing required quick_status keys - required_qs_keys = ["new_mail", "opened_mail", "active_plans", - "commons_mentions", "action_required", "summary"] + required_qs_keys = ["new_mail", "opened_mail", "active_plans", "commons_mentions", "action_required", "summary"] for key in required_qs_keys: if key not in quick_status: result["additions"].append(f"quick_status.{key}") @@ -163,6 +161,7 @@ def _diff_branch(branch_name: str, branch_path: Path, template: dict) -> Dict[st # PUBLIC API # ============================================================================= + def diff_dashboard_template(branch_name: Optional[str] = None) -> Dict[str, Any]: """ Compare dashboard template against branch dashboards. @@ -178,12 +177,7 @@ def diff_dashboard_template(branch_name: Optional[str] = None) -> Dict[str, Any] """ result: Dict[str, Any] = { "branches": [], - "summary": { - "needs_update": 0, - "up_to_date": 0, - "missing": 0, - "invalid_json": 0 - } + "summary": {"needs_update": 0, "up_to_date": 0, "missing": 0, "invalid_json": 0}, } # Load template @@ -214,11 +208,7 @@ def diff_dashboard_template(branch_name: Optional[str] = None) -> Dict[str, Any] target = branch_name.upper() branches = [b for b in branches if b.get("name", "").upper() == target] if not branches: - return { - "error": f"Branch '{target}' not found in registry", - "branches": [], - "summary": {} - } + return {"error": f"Branch '{target}' not found in registry", "branches": [], "summary": {}} repo_root = _find_repo_root() @@ -234,7 +224,7 @@ def diff_dashboard_template(branch_name: Optional[str] = None) -> Dict[str, Any] "additions": [], "removals": [], "modifications": [], - "status": "missing" + "status": "missing", } else: branch_diff = _diff_branch(bname, bpath, template) @@ -244,12 +234,15 @@ def diff_dashboard_template(branch_name: Optional[str] = None) -> Dict[str, Any] if status in result["summary"]: result["summary"][status] += 1 - json_handler.log_operation("template_diffed", { - "branch_filter": branch_name, - "branches_scanned": len(result["branches"]), - "needs_update": result["summary"].get("needs_update", 0), - "up_to_date": result["summary"].get("up_to_date", 0), - }) + json_handler.log_operation( + "template_diffed", + { + "branch_filter": branch_name, + "branches_scanned": len(result["branches"]), + "needs_update": result["summary"].get("needs_update", 0), + "up_to_date": result["summary"].get("up_to_date", 0), + }, + ) return result @@ -258,8 +251,9 @@ def diff_dashboard_template(branch_name: Optional[str] = None) -> Dict[str, Any] # CLI INTERFACE # ============================================================================= -if __name__ == '__main__': +if __name__ == "__main__": import sys as _sys + _out = _sys.stdout.write args = _sys.argv[1:] diff --git a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py index e97e6e46..124d2475 100644 --- a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py +++ b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py @@ -68,32 +68,16 @@ DEPRECATED_QUICK_STATUS_KEYS = ["pending_bulletins"] # Required sections with their default data (must match template) REQUIRED_SECTIONS = { - "ai_mail": { - "managed_by": "ai_mail", - "new": 0, - "opened": 0, - "total": 0, - "last_updated": "" - }, - "flow": { - "managed_by": "flow", - "active_plans": 0, - "recently_closed": [], - "last_updated": "" - }, - "memory": { - "managed_by": "memory", - "vectors_stored": 0, - "notes": {}, - "last_updated": "" - }, + "ai_mail": {"managed_by": "ai_mail", "new": 0, "opened": 0, "total": 0, "last_updated": ""}, + "flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""}, + "memory": {"managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": ""}, "commons_activity": { "managed_by": "the_commons", "mentions": 0, "new_posts_since_last_visit": 0, "new_comments_since_last_visit": 0, - "last_updated": "" - } + "last_updated": "", + }, } @@ -101,6 +85,7 @@ REQUIRED_SECTIONS = { # PLACEHOLDER REPLACEMENT # ============================================================================= + def _replace_placeholders(template: dict, branch_name: str) -> dict: """ Recursively replace {{BRANCHNAME}} with branch_name in all string values. @@ -112,6 +97,7 @@ def _replace_placeholders(template: dict, branch_name: str) -> dict: Returns: New dict with placeholders replaced """ + def _walk(val: Any) -> Any: if isinstance(val, str): return val.replace("{{BRANCHNAME}}", branch_name) @@ -130,6 +116,7 @@ def _replace_placeholders(template: dict, branch_name: str) -> dict: # QUICK STATUS CALCULATION (SELF-CONTAINED) # ============================================================================= + def _calculate_quick_status(sections: Dict) -> Dict: """ Calculate quick_status from live section data. @@ -178,7 +165,7 @@ def _calculate_quick_status(sections: Dict) -> Dict: "active_plans": active_plans, "commons_mentions": mentions, "action_required": action_required, - "summary": ", ".join(parts) if parts else "All clear" + "summary": ", ".join(parts) if parts else "All clear", } @@ -186,6 +173,7 @@ def _calculate_quick_status(sections: Dict) -> Dict: # HELPER FUNCTIONS # ============================================================================= + def _create_from_template(template: dict, branch_name: str) -> dict: """Create a new dashboard from template with placeholders replaced.""" return _replace_placeholders(template, branch_name) @@ -208,9 +196,7 @@ def _safe_write_dashboard( return False -def _apply_structural_updates( - data: dict, template: dict, branch_actions: List[str] -) -> tuple: +def _apply_structural_updates(data: dict, template: dict, branch_actions: List[str]) -> tuple: """Apply structural updates from template to existing dashboard data. Returns (changed: bool, branch_actions: list). @@ -261,6 +247,7 @@ def _apply_structural_updates( # MAIN PUSH FUNCTION # ============================================================================= + def push_dashboard_template(dry_run: bool = False) -> Dict[str, Any]: """ Push dashboard template to all registered branches. @@ -369,12 +356,15 @@ def push_dashboard_template(dry_run: bool = False) -> Dict[str, Any]: if not dry_run and branches_updated_list: _update_version_file(branches_updated_list) - json_handler.log_operation("template_pushed", { - "dry_run": dry_run, - "branches_scanned": result["branches_scanned"], - "branches_updated": result["branches_updated"], - "branches_created": result["branches_created"], - }) + json_handler.log_operation( + "template_pushed", + { + "dry_run": dry_run, + "branches_scanned": result["branches_scanned"], + "branches_updated": result["branches_updated"], + "branches_created": result["branches_created"], + }, + ) return result @@ -383,6 +373,7 @@ def push_dashboard_template(dry_run: bool = False) -> Dict[str, Any]: # VERSION TRACKING # ============================================================================= + def _update_version_file(branches_pushed: List[str]) -> bool: """ Update .dashboard_version.json with push timestamp and branch list. @@ -427,7 +418,7 @@ def get_template_status() -> Dict[str, Any]: "updated_by": None, "changes": [], "last_push": None, - "last_push_branches": [] + "last_push_branches": [], } if VERSION_FILE.exists(): @@ -450,8 +441,9 @@ def get_template_status() -> Dict[str, Any]: # CLI INTERFACE # ============================================================================= -if __name__ == '__main__': +if __name__ == "__main__": import sys as _sys + _out = _sys.stdout.write args = _sys.argv[1:] @@ -498,8 +490,8 @@ if __name__ == '__main__': _out(f"Last push: {tmpl_status.get('last_push', 'never')}\n") pushed = tmpl_status.get("last_push_branches", []) if pushed: - preview = ', '.join(pushed[:5]) - suffix = '...' if len(pushed) > 5 else '' + preview = ", ".join(pushed[:5]) + suffix = "..." if len(pushed) > 5 else "" _out(f"Branches pushed: {len(pushed)} ({preview}{suffix})\n") _out("\n") _sys.exit(0) diff --git a/src/aipass/prax/apps/handlers/discovery/filtering.py b/src/aipass/prax/apps/handlers/discovery/filtering.py index 1fb9429a..e534562a 100755 --- a/src/aipass/prax/apps/handlers/discovery/filtering.py +++ b/src/aipass/prax/apps/handlers/discovery/filtering.py @@ -18,6 +18,7 @@ from pathlib import Path from aipass.prax.apps.handlers.config.ignore_patterns import load_ignore_patterns_from_config from aipass.prax.apps.handlers.json import json_handler + def should_ignore_path(path: Path) -> bool: """Check if path should be ignored based on patterns from config diff --git a/src/aipass/prax/apps/handlers/discovery/scanner.py b/src/aipass/prax/apps/handlers/discovery/scanner.py index d503593d..b92429ee 100755 --- a/src/aipass/prax/apps/handlers/discovery/scanner.py +++ b/src/aipass/prax/apps/handlers/discovery/scanner.py @@ -22,16 +22,13 @@ from typing import Dict, Any logger = logging.getLogger(__name__) # Import from prax config -from aipass.prax.apps.handlers.config.load import ( - ECOSYSTEM_ROOT, - get_system_logs_dir, - get_module_logs_dir -) +from aipass.prax.apps.handlers.config.load import ECOSYSTEM_ROOT, get_system_logs_dir, get_module_logs_dir # Import filtering from aipass.prax.apps.handlers.discovery.filtering import should_ignore_path from aipass.prax.apps.handlers.json import json_handler + def scan_directory_safely(directory: Path, modules: Dict, max_depth: int = 10): """Safely scan directory with depth limit @@ -51,7 +48,7 @@ def scan_directory_safely(directory: Path, modules: Dict, max_depth: int = 10): if should_ignore_path(item): continue - if item.is_file() and item.suffix == '.py': + if item.is_file() and item.suffix == ".py": module_name = item.stem relative_path = item.relative_to(ECOSYSTEM_ROOT) @@ -63,7 +60,7 @@ def scan_directory_safely(directory: Path, modules: Dict, max_depth: int = 10): "discovered_time": datetime.now(timezone.utc).isoformat(), "size": item.stat().st_size, "modified_time": datetime.fromtimestamp(item.stat().st_mtime).isoformat(), - "enabled": True + "enabled": True, } elif item.is_dir(): @@ -74,6 +71,7 @@ def scan_directory_safely(directory: Path, modules: Dict, max_depth: int = 10): except Exception as e: logger.warning(f"[scanner] Error scanning directory {directory}: {e}") + def discover_python_modules() -> Dict[str, Dict[str, Any]]: """Discover all Python modules in the ecosystem diff --git a/src/aipass/prax/apps/handlers/discovery/watcher.py b/src/aipass/prax/apps/handlers/discovery/watcher.py index fdf0d775..dee54354 100755 --- a/src/aipass/prax/apps/handlers/discovery/watcher.py +++ b/src/aipass/prax/apps/handlers/discovery/watcher.py @@ -16,6 +16,7 @@ No console output - follows 3-tier handler pattern. """ import logging + logger = logging.getLogger(__name__) from pathlib import Path @@ -27,11 +28,7 @@ from watchdog.observers import Observer as WatchdogObserver from watchdog.events import FileSystemEventHandler # Import from prax config -from aipass.prax.apps.handlers.config.load import ( - ECOSYSTEM_ROOT, - get_system_logs_dir, - get_module_logs_dir -) +from aipass.prax.apps.handlers.config.load import ECOSYSTEM_ROOT, get_system_logs_dir, get_module_logs_dir # Import from prax registry handlers from aipass.prax.apps.handlers.registry.load import load_module_registry @@ -44,6 +41,7 @@ from aipass.prax.apps.handlers.json import json_handler # Trigger integration - graceful fallback if trigger not available try: from aipass.trigger.apps.modules.core import trigger + _HAS_TRIGGER = True except ImportError as e: logger.info(f"[watcher] trigger module not available, falling back: {e}") @@ -59,7 +57,7 @@ class PythonFileWatcher(FileSystemEventHandler): def on_created(self, event): """Handle new file creation events""" - if not event.is_directory and str(event.src_path).endswith('.py'): + if not event.is_directory and str(event.src_path).endswith(".py"): py_file = Path(str(event.src_path)) # Skip ignored paths @@ -89,7 +87,7 @@ class PythonFileWatcher(FileSystemEventHandler): "discovered_time": datetime.now(timezone.utc).isoformat(), "size": py_file.stat().st_size, "modified_time": datetime.fromtimestamp(py_file.stat().st_mtime).isoformat(), - "enabled": True + "enabled": True, } # Save updated registry @@ -98,10 +96,11 @@ class PythonFileWatcher(FileSystemEventHandler): # Fire trigger event for module discovery if _HAS_TRIGGER: try: - trigger.fire('module_discovered', # type: ignore[union-attr] + trigger.fire( + "module_discovered", # type: ignore[union-attr] module_name=module_name, file_path=str(py_file), - relative_path=str(relative_path) + relative_path=str(relative_path), ) except (OSError, Exception) as e: logger.warning(f"[watcher] trigger.fire('module_discovered') failed for {module_name}: {e}") diff --git a/src/aipass/prax/apps/handlers/json/json_handler.py b/src/aipass/prax/apps/handlers/json/json_handler.py index 2122e98b..dafeab18 100755 --- a/src/aipass/prax/apps/handlers/json/json_handler.py +++ b/src/aipass/prax/apps/handlers/json/json_handler.py @@ -23,9 +23,9 @@ import inspect logger = logging.getLogger(__name__) # Resolve paths relative to this file (no hardcoded paths) -_HANDLER_DIR = Path(__file__).resolve().parent # .../handlers/json/ -_HANDLERS_DIR = _HANDLER_DIR.parent # .../handlers/ -_PRAX_ROOT = _HANDLERS_DIR.parent.parent # .../prax/ +_HANDLER_DIR = Path(__file__).resolve().parent # .../handlers/json/ +_HANDLERS_DIR = _HANDLER_DIR.parent # .../handlers/ +_PRAX_ROOT = _HANDLERS_DIR.parent.parent # .../prax/ PRAX_JSON_DIR = _PRAX_ROOT / "prax_json" JSON_TEMPLATES_DIR = _HANDLERS_DIR / "json_templates" @@ -46,7 +46,7 @@ def _get_caller_module_name() -> str: module_name = caller_path.stem # Validate module name - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name # Fallback @@ -64,7 +64,7 @@ def load_template(json_type: str, module_name: str) -> Any: return None try: - with open(template_path, 'r', encoding='utf-8') as f: + with open(template_path, "r", encoding="utf-8") as f: template = json.load(f) # Replace placeholders @@ -112,7 +112,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: if json_path.exists(): try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if validate_json_structure(data, json_type): @@ -127,7 +127,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: return False try: - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(template, f, indent=2, ensure_ascii=False) return True except Exception as e: @@ -143,7 +143,7 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: json_path = get_json_path(module_name, json_type) try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.warning("json_handler: failed to load json '%s/%s': %s", module_name, json_type, e) @@ -161,9 +161,9 @@ def _atomic_write(json_path: Path, content: str) -> None: import tempfile import time - fd, tmp_path = tempfile.mkstemp(dir=json_path.parent, suffix='.tmp') + fd, tmp_path = tempfile.mkstemp(dir=json_path.parent, suffix=".tmp") try: - with os.fdopen(fd, 'w', encoding='utf-8') as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(content) f.flush() os.fsync(f.fileno()) @@ -175,8 +175,10 @@ def _atomic_write(json_path: Path, content: str) -> None: return except PermissionError as exc: last_exc = exc - logger.info("json_handler: os.replace attempt %d failed (PermissionError), retrying: %s", attempt + 1, exc) - time.sleep(0.05 * (2 ** attempt)) + logger.info( + "json_handler: os.replace attempt %d failed (PermissionError), retrying: %s", attempt + 1, exc + ) + time.sleep(0.05 * (2**attempt)) if last_exc is not None: raise last_exc except Exception: @@ -248,10 +250,7 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam log = [] # Create new entry - entry: Dict[str, Any] = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } + entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation} if data: entry["data"] = data @@ -267,9 +266,9 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam if __name__ == "__main__": - print("\n" + "="*70) + print("\n" + "=" * 70) print("JSON HANDLER - Working Implementation") - print("="*70) + print("=" * 70) print("\n[TESTING] Creating prax JSONs...") # Test auto-creation @@ -279,4 +278,4 @@ if __name__ == "__main__": print(" - prax_config.json") print(" - prax_data.json") print(" - prax_log.json") - print("\n" + "="*70 + "\n") + print("\n" + "=" * 70 + "\n") diff --git a/src/aipass/prax/apps/handlers/logging/direct.py b/src/aipass/prax/apps/handlers/logging/direct.py index 8110ca28..b8710192 100644 --- a/src/aipass/prax/apps/handlers/logging/direct.py +++ b/src/aipass/prax/apps/handlers/logging/direct.py @@ -45,6 +45,8 @@ class RotatingFileHandler(_BaseRotatingFileHandler): super().doRollover() except (PermissionError, OSError) as exc: logger.warning("Log rotation skipped (file locked): %s", exc) + + from pathlib import Path from typing import Dict, Optional, Tuple @@ -53,7 +55,7 @@ from aipass.prax.apps.handlers.config.load import ( get_module_logs_dir, DEFAULT_LOG_LEVEL, load_log_config, - lines_to_bytes + lines_to_bytes, ) from aipass.prax.apps.handlers.logging.introspection import detect_branch_from_path from aipass.prax.apps.handlers.json import json_handler @@ -87,25 +89,21 @@ def _get_direct_caller_info() -> Tuple[str, Optional[str]]: current = current.f_back if current is None: break - path = current.f_globals.get('__file__', '') + path = current.f_globals.get("__file__", "") if not path: continue # Only skip our own internal frames - if '/logging/direct.py' in path or '/modules/logger.py' in path: + if "/logging/direct.py" in path or "/modules/logger.py" in path: continue module_name = Path(path).stem branch_path = detect_branch_from_path(path) return module_name, branch_path - return 'unknown_module', None + return "unknown_module", None finally: del frame -def _create_direct_logger( - module_name: str, - branch_name: str, - branch_path: Optional[str] -) -> logging.Logger: +def _create_direct_logger(module_name: str, branch_name: str, branch_path: Optional[str]) -> logging.Logger: """Create a standalone logger with dual RotatingFileHandlers. Same dual-logging setup as setup_individual_logger but with NO @@ -128,33 +126,30 @@ def _create_direct_logger( logger.propagate = False # Critical: no root logger propagation config = load_log_config() - formatter = logging.Formatter( - config['log_format'], - config['date_format'] - ) + formatter = logging.Formatter(config["log_format"], config["date_format"]) target = branch_name if branch_path else "prax" # Handler 1: System-wide log (central aggregation) sys_log_file = get_system_logs_dir() / f"{target}_{module_name}.log" - sys_limits = config['system_logs'] + sys_limits = config["system_logs"] sys_handler = RotatingFileHandler( sys_log_file, - maxBytes=lines_to_bytes(sys_limits['max_lines']), - backupCount=sys_limits['backup_count'], - encoding='utf-8' + maxBytes=lines_to_bytes(sys_limits["max_lines"]), + backupCount=sys_limits["backup_count"], + encoding="utf-8", ) sys_handler.setFormatter(formatter) logger.addHandler(sys_handler) # Handler 2: Module-local log (local debugging) local_log_file = get_module_logs_dir(target) / f"{module_name}.log" - local_limits = config['local_logs'] + local_limits = config["local_logs"] local_handler = RotatingFileHandler( local_log_file, - maxBytes=lines_to_bytes(local_limits['max_lines']), - backupCount=local_limits['backup_count'], - encoding='utf-8' + maxBytes=lines_to_bytes(local_limits["max_lines"]), + backupCount=local_limits["backup_count"], + encoding="utf-8", ) local_handler.setFormatter(formatter) logger.addHandler(local_handler) @@ -162,10 +157,7 @@ def _create_direct_logger( return logger -def _get_or_create_logger( - module_name: str, - branch_path: Optional[str] -) -> logging.Logger: +def _get_or_create_logger(module_name: str, branch_path: Optional[str]) -> logging.Logger: """Get cached logger or create a new one. Args: @@ -178,9 +170,7 @@ def _get_or_create_logger( branch_name = Path(branch_path).name if branch_path else "unknown" key = f"{branch_name}_{module_name}" if key not in _direct_loggers: - _direct_loggers[key] = _create_direct_logger( - module_name, branch_name, branch_path - ) + _direct_loggers[key] = _create_direct_logger(module_name, branch_name, branch_path) return _direct_loggers[key] @@ -209,9 +199,7 @@ class DirectLogger: The cached direct logger instance. """ if self._logger is None: - self._logger = _get_or_create_logger( - self._module_name, self._branch_path - ) + self._logger = _get_or_create_logger(self._module_name, self._branch_path) return self._logger def info(self, message: str, *args, **kwargs) -> None: diff --git a/src/aipass/prax/apps/handlers/logging/introspection.py b/src/aipass/prax/apps/handlers/logging/introspection.py index d92f2eb3..0f63c009 100755 --- a/src/aipass/prax/apps/handlers/logging/introspection.py +++ b/src/aipass/prax/apps/handlers/logging/introspection.py @@ -14,6 +14,7 @@ Used by logger_setup.py to route logs to correct files. """ import logging + logger = logging.getLogger(__name__) from pathlib import Path from typing import Optional @@ -21,10 +22,10 @@ from typing import Optional from aipass.prax.apps.handlers.json import json_handler _PRAX_INTERNAL_MARKERS = ( - '/prax/apps/modules/logger.py', - '/prax/apps/handlers/', - 'prax_logger.py', - 'prax_handlers.py', + "/prax/apps/modules/logger.py", + "/prax/apps/handlers/", + "prax_logger.py", + "prax_handlers.py", ) @@ -46,7 +47,7 @@ def _find_external_caller_path() -> Optional[str]: frame_count += 1 if not current_frame: break - module_path = current_frame.f_globals.get('__file__', '') + module_path = current_frame.f_globals.get("__file__", "") if not module_path or module_path == __file__: continue if not _is_prax_internal(module_path): @@ -65,7 +66,7 @@ def get_calling_module() -> str: caller_path = _find_external_caller_path() if caller_path: return Path(caller_path).stem - return 'unknown_module' + return "unknown_module" def get_calling_module_path() -> Optional[str]: @@ -89,14 +90,16 @@ def get_caller_info() -> tuple: """ caller_path = _find_external_caller_path() if not caller_path: - return ('unknown_module', None, None) + return ("unknown_module", None, None) module_name = Path(caller_path).stem branch = detect_branch_from_path(caller_path) return (module_name, caller_path, branch) + _AIPASS_PKG_ROOT = Path(__file__).resolve().parents[4] # logging/ → handlers/ → apps/ → prax/ → aipass/ _SRC_ROOT = _AIPASS_PKG_ROOT.parent # aipass/ → src/ (contains branches outside aipass namespace) + def detect_branch_from_path(module_path: str) -> Optional[str]: """Detect branch name from module file path @@ -124,7 +127,11 @@ def detect_branch_from_path(module_path: str) -> Optional[str]: # relative is like: flow/apps/module.py → parts[0] = "flow" if len(relative.parts) >= 2: branch = relative.parts[0] - json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch}, module_name="prax_introspection") + json_handler.log_operation( + "introspection_resolved", + {"module_path": module_path, "branch": branch}, + module_name="prax_introspection", + ) return branch except ValueError: logger.info("Path %s is not relative to aipass package root", module_path) @@ -134,7 +141,11 @@ def detect_branch_from_path(module_path: str) -> Optional[str]: relative = path.relative_to(_SRC_ROOT) if len(relative.parts) >= 2 and relative.parts[0] != "aipass": branch = relative.parts[0] - json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch, "outside_aipass": True}, module_name="prax_introspection") + json_handler.log_operation( + "introspection_resolved", + {"module_path": module_path, "branch": branch, "outside_aipass": True}, + module_name="prax_introspection", + ) return branch except ValueError: logger.info("Path %s is not relative to src root", module_path) diff --git a/src/aipass/prax/apps/handlers/logging/lifecycle.py b/src/aipass/prax/apps/handlers/logging/lifecycle.py index b61bfae7..c6f37500 100644 --- a/src/aipass/prax/apps/handlers/logging/lifecycle.py +++ b/src/aipass/prax/apps/handlers/logging/lifecycle.py @@ -74,13 +74,8 @@ def run_initialize(module_name: str) -> Dict[str, Any]: # Log system startup system_logger_instance.info("Prax logging system initialized") - system_logger_instance.info( - f"System logs: {get_system_logs_dir()}, " - f"Module logs: {get_module_logs_dir('prax')}" - ) - system_logger_instance.info( - f"Found {len(modules)} modules for logging setup" - ) + system_logger_instance.info(f"System logs: {get_system_logs_dir()}, Module logs: {get_module_logs_dir('prax')}") + system_logger_instance.info(f"Found {len(modules)} modules for logging setup") # Install logger override install_logger_override() @@ -89,10 +84,7 @@ def run_initialize(module_name: str) -> Dict[str, Any]: # Start file watcher start_file_watcher() - log_operation("Logging system initialized", { - "modules_discovered": len(modules), - "consolidated_logger": True - }) + log_operation("Logging system initialized", {"modules_discovered": len(modules), "consolidated_logger": True}) json_handler.log_operation("lifecycle_event", {"event": "initialized", "modules_count": len(modules)}) diff --git a/src/aipass/prax/apps/handlers/logging/log_watchdog.py b/src/aipass/prax/apps/handlers/logging/log_watchdog.py index f1401ba8..20290203 100644 --- a/src/aipass/prax/apps/handlers/logging/log_watchdog.py +++ b/src/aipass/prax/apps/handlers/logging/log_watchdog.py @@ -22,6 +22,7 @@ Two modes: """ import logging + logger = logging.getLogger(__name__) import sys from datetime import datetime @@ -35,6 +36,7 @@ from aipass.prax.apps.handlers.json import json_handler # CONSTANTS # ============================================================================= + def _find_repo_root() -> Path: """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" current = Path(__file__).resolve().parent @@ -43,8 +45,10 @@ def _find_repo_root() -> Path: return parent return Path.cwd() + _system_logs_dir_cache: Path | None = None + def _get_system_logs_dir() -> Path: """Lazily resolve system_logs directory (package-relative).""" global _system_logs_dir_cache @@ -52,9 +56,10 @@ def _get_system_logs_dir() -> Path: _system_logs_dir_cache = _find_repo_root() / "system_logs" return _system_logs_dir_cache + # Thresholds -WARN_THRESHOLD_LINES = 5000 # Fire warning at this line count -DEFAULT_MAX_LINES = 1000 # Truncate to this many lines (matches prax config) +WARN_THRESHOLD_LINES = 5000 # Fire warning at this line count +DEFAULT_MAX_LINES = 1000 # Truncate to this many lines (matches prax config) CRITICAL_THRESHOLD_LINES = 10000 # Immediate action recommended @@ -62,6 +67,7 @@ CRITICAL_THRESHOLD_LINES = 10000 # Immediate action recommended # SCANNING # ============================================================================= + def _count_lines(filepath: Path) -> int: """ Count lines in a file efficiently. @@ -73,7 +79,7 @@ def _count_lines(filepath: Path) -> int: Line count, or 0 on error """ try: - with open(filepath, 'rb') as f: + with open(filepath, "rb") as f: return sum(1 for _ in f) except OSError as e: logger.info("Failed to count lines in %s: %s", filepath, e) @@ -121,13 +127,15 @@ def scan_log_files() -> List[Dict[str, Any]]: else: status = "ok" - results.append({ - "path": str(log_file), - "name": log_file.name, - "lines": lines, - "size_kb": round(size_kb, 1), - "status": status - }) + results.append( + { + "path": str(log_file), + "name": log_file.name, + "lines": lines, + "size_kb": round(size_kb, 1), + "status": status, + } + ) # Sort by line count descending (biggest problems first) results.sort(key=lambda x: x["lines"], reverse=True) @@ -152,6 +160,7 @@ def get_oversized_files(threshold: int = WARN_THRESHOLD_LINES) -> List[Dict[str, # ENFORCEMENT # ============================================================================= + def truncate_log_file(filepath: Path, keep_lines: int = DEFAULT_MAX_LINES) -> Tuple[int, int]: """ Truncate a log file to keep only the last N lines. @@ -167,7 +176,7 @@ def truncate_log_file(filepath: Path, keep_lines: int = DEFAULT_MAX_LINES) -> Tu Tuple of (original_lines, new_lines) """ try: - with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + with open(filepath, "r", encoding="utf-8", errors="replace") as f: all_lines = f.readlines() original_count = len(all_lines) @@ -184,7 +193,7 @@ def truncate_log_file(filepath: Path, keep_lines: int = DEFAULT_MAX_LINES) -> Tu f"| was {original_count} lines, kept last {keep_lines} ---\n" ) - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, "w", encoding="utf-8") as f: f.write(marker) f.writelines(kept_lines) @@ -195,8 +204,9 @@ def truncate_log_file(filepath: Path, keep_lines: int = DEFAULT_MAX_LINES) -> Tu return 0, 0 -def enforce_log_limits(max_lines: int = DEFAULT_MAX_LINES, - threshold: int = WARN_THRESHOLD_LINES) -> List[Dict[str, Any]]: +def enforce_log_limits( + max_lines: int = DEFAULT_MAX_LINES, threshold: int = WARN_THRESHOLD_LINES +) -> List[Dict[str, Any]]: """ Scan and truncate all oversized log files. @@ -218,12 +228,9 @@ def enforce_log_limits(max_lines: int = DEFAULT_MAX_LINES, filepath = Path(file_info["path"]) original, new = truncate_log_file(filepath, max_lines) - actions.append({ - "name": file_info["name"], - "original_lines": original, - "new_lines": new, - "truncated": original != new - }) + actions.append( + {"name": file_info["name"], "original_lines": original, "new_lines": new, "truncated": original != new} + ) return actions @@ -232,6 +239,7 @@ def enforce_log_limits(max_lines: int = DEFAULT_MAX_LINES, # HEALTH CHECK (for monitoring integration) # ============================================================================= + def log_health_summary() -> Dict[str, Any]: """ Generate a health summary of system logs. @@ -250,7 +258,7 @@ def log_health_summary() -> Dict[str, Any]: "critical_count": 0, "largest_file": None, "largest_lines": 0, - "healthy": True + "healthy": True, } total_lines = sum(f["lines"] for f in files) @@ -265,7 +273,7 @@ def log_health_summary() -> Dict[str, Any]: "critical_count": len(critical), "largest_file": largest["name"] if largest else None, "largest_lines": largest["lines"] if largest else 0, - "healthy": len(oversized) == 0 + "healthy": len(oversized) == 0, } diff --git a/src/aipass/prax/apps/handlers/logging/monitoring.py b/src/aipass/prax/apps/handlers/logging/monitoring.py index cc9fc0d4..958baee4 100755 --- a/src/aipass/prax/apps/handlers/logging/monitoring.py +++ b/src/aipass/prax/apps/handlers/logging/monitoring.py @@ -24,9 +24,7 @@ from aipass.prax.apps.handlers.json import json_handler def run_monitoring_loop( - status_callback: Callable[[], Dict[str, Any]], - interval: int = 5, - status_interval: int = 300 + status_callback: Callable[[], Dict[str, Any]], interval: int = 5, status_interval: int = 300 ) -> None: """Run continuous monitoring loop - HANDLER implements logic @@ -59,9 +57,11 @@ def run_monitoring_loop( if counter % status_interval == 0: logger.info("\n" + "=" * 60) status = status_callback() - modules_count = status.get('total_modules', 0) - loggers_count = status.get('individual_loggers', 0) - logger.info(f"[{module_name}] Status: {modules_count} modules discovered, {loggers_count} loggers active") + modules_count = status.get("total_modules", 0) + loggers_count = status.get("individual_loggers", 0) + logger.info( + f"[{module_name}] Status: {modules_count} modules discovered, {loggers_count} loggers active" + ) logger.info("=" * 60) sys.stdout.flush() diff --git a/src/aipass/prax/apps/handlers/logging/operations.py b/src/aipass/prax/apps/handlers/logging/operations.py index 055d289c..bcbf3dd3 100755 --- a/src/aipass/prax/apps/handlers/logging/operations.py +++ b/src/aipass/prax/apps/handlers/logging/operations.py @@ -12,7 +12,6 @@ PRAX Logging Operations Operation logging and configuration management for prax logger. """ - import json from datetime import datetime, timezone from typing import Dict, Optional @@ -30,6 +29,7 @@ CONFIG_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_config.json" DATA_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_data.json" LOG_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_log.json" + def log_operation(message: str, data: Optional[Dict] = None): """Log prax_logging operations to JSON log file @@ -37,17 +37,13 @@ def log_operation(message: str, data: Optional[Dict] = None): message: Operation description data: Optional operation data dict """ - entry = { - "timestamp": datetime.now(timezone.utc).isoformat(), - "operation": message, - "data": data or {} - } + entry = {"timestamp": datetime.now(timezone.utc).isoformat(), "operation": message, "data": data or {}} # Load existing log log_entries = [] if LOG_FILE.exists(): try: - with open(LOG_FILE, 'r', encoding='utf-8') as f: + with open(LOG_FILE, "r", encoding="utf-8") as f: log_entries = json.load(f) except Exception as e: logger.warning("Failed to load log file %s, resetting entries: %s", LOG_FILE, e) @@ -61,10 +57,11 @@ def log_operation(message: str, data: Optional[Dict] = None): log_entries = log_entries[-1000:] # Save log - with open(LOG_FILE, 'w', encoding='utf-8') as f: + with open(LOG_FILE, "w", encoding="utf-8") as f: json.dump(log_entries, f, indent=2, ensure_ascii=False) json_handler.log_operation("log_operation_performed", {"message": message}) + def create_config_file(): """Create default config file if it doesn't exist""" if not CONFIG_FILE.exists(): @@ -81,15 +78,11 @@ def create_config_file(): "file_output": True, "rotation_enabled": True, "debug_prints": False, - "log_directories": [ - "system_logs", - "skill_logs", - "error_logs" - ] - } + "log_directories": ["system_logs", "skill_logs", "error_logs"], + }, } try: - with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(default_config, f, indent=2, ensure_ascii=False) logger.info("Config file created: %s", CONFIG_FILE) except Exception as e: diff --git a/src/aipass/prax/apps/handlers/logging/override.py b/src/aipass/prax/apps/handlers/logging/override.py index 014da76f..21c6b93f 100755 --- a/src/aipass/prax/apps/handlers/logging/override.py +++ b/src/aipass/prax/apps/handlers/logging/override.py @@ -13,16 +13,12 @@ Global logging.getLogger() override for automatic log routing. Intercepts logging.getLogger() calls and routes to module-specific logs. """ - import logging import sys from typing import Optional # Import from prax config -from aipass.prax.apps.handlers.config.load import ( - DEFAULT_LOG_LEVEL, - get_debug_prints_enabled -) +from aipass.prax.apps.handlers.config.load import DEFAULT_LOG_LEVEL, get_debug_prints_enabled # Import logging setup from aipass.prax.apps.handlers.logging.setup import setup_individual_logger @@ -36,6 +32,7 @@ from aipass.prax.apps.handlers.json import json_handler _original_getLogger = logging.getLogger _original_basicConfig = logging.basicConfig + def enhanced_getLogger(name: Optional[str] = None) -> logging.Logger: """Enhanced getLogger that redirects to our individual module loggers @@ -64,7 +61,7 @@ def enhanced_getLogger(name: Optional[str] = None) -> logging.Logger: sys.stderr.write(f"[DEBUG] Detected calling module: {module_name}\n") # If we can detect the module, add our custom handler - if module_name != 'unknown_module': + if module_name != "unknown_module": # sys.stderr used intentionally: runs during logging override setup if get_debug_prints_enabled(): sys.stderr.write(f"[DEBUG] Setting up individual logger for: {module_name}\n") @@ -86,6 +83,7 @@ def enhanced_getLogger(name: Optional[str] = None) -> logging.Logger: return original_logger + def install_logger_override(): """Install the enhanced getLogger function globally @@ -97,6 +95,7 @@ def install_logger_override(): sys.stderr.write("[prax] Global logger override installed\n") json_handler.log_operation("logger_override_applied", {"action": "install"}) + def restore_original_logger(): """Restore original getLogger function @@ -106,6 +105,7 @@ def restore_original_logger(): # sys.stderr used intentionally: logging system is being restored, cannot use prax logger here sys.stderr.write("[prax] Original logger function restored\n") + def is_override_active() -> bool: """Check if logger override is currently active diff --git a/src/aipass/prax/apps/handlers/logging/setup.py b/src/aipass/prax/apps/handlers/logging/setup.py index 80e01952..bcda2b5d 100755 --- a/src/aipass/prax/apps/handlers/logging/setup.py +++ b/src/aipass/prax/apps/handlers/logging/setup.py @@ -15,6 +15,7 @@ Handles dual logging (system-wide + branch-local) and terminal output. import logging import threading + logger = logging.getLogger(__name__) from pathlib import Path from typing import Dict, Optional @@ -26,14 +27,11 @@ from aipass.prax.apps.handlers.config.load import ( get_module_logs_dir, DEFAULT_LOG_LEVEL, load_log_config, - lines_to_bytes + lines_to_bytes, ) # Import introspection functions -from aipass.prax.apps.handlers.logging.introspection import ( - get_calling_module_path, - detect_branch_from_path -) +from aipass.prax.apps.handlers.logging.introspection import get_calling_module_path, detect_branch_from_path from aipass.prax.apps.handlers.json import json_handler @@ -49,12 +47,14 @@ _terminal_module_available = False try: from aipass.prax.apps.handlers.logging.terminal.formatting import create_terminal_handler from aipass.prax.apps.handlers.logging.terminal.filtering import should_display_terminal + _terminal_module_available = True except ImportError as e: logger.info(f"[setup] Terminal handler modules not available: {e}") create_terminal_handler = None # type: ignore[assignment] should_display_terminal = None # type: ignore[assignment] + class _WindowsSafeRotatingHandler(RotatingFileHandler): """RotatingFileHandler that catches PermissionError/OSError during rotation on Windows. @@ -71,6 +71,7 @@ class _WindowsSafeRotatingHandler(RotatingFileHandler): # Windows: file locked by another process. Skip rotation, keep writing. logger.warning("Log rotation skipped (file locked): %s", exc) + def _safe_rotating_handler(log_file: Path, max_bytes: int, backup_count: int) -> logging.Handler: """Create RotatingFileHandler — self-heals missing directories, never crashes.""" try: @@ -79,7 +80,7 @@ def _safe_rotating_handler(log_file: Path, max_bytes: int, backup_count: int) -> parent.mkdir(parents=True, exist_ok=True) if _system_logger: _system_logger.warning(f"Self-healed missing log directory: {parent}") - return _WindowsSafeRotatingHandler(log_file, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8') + return _WindowsSafeRotatingHandler(log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8") except OSError as e: logger.error("Log handler failed for %s: %s", log_file, e) return logging.NullHandler() @@ -123,6 +124,7 @@ def setup_individual_logger( # Use pre-resolved branch if provided, otherwise detect from stack import os + branch_name: Optional[str] = caller_branch if not branch_name: module_path = caller_path or get_calling_module_path() @@ -134,30 +136,29 @@ def setup_individual_logger( branch_name = os.environ.get("AIPASS_BRANCH_NAME") or "unknown_branch" # Create formatter (shared by all handlers) - formatter = logging.Formatter( - log_config['log_format'], - log_config['date_format'] - ) + formatter = logging.Formatter(log_config["log_format"], log_config["date_format"]) # HANDLER 1: System-wide log (central aggregation) system_log_file = get_system_logs_dir() / f"{branch_name}_{module_name}.log" - system_limits = log_config['system_logs'] - system_max_bytes = lines_to_bytes(system_limits['max_lines']) - system_handler = _safe_rotating_handler(system_log_file, system_max_bytes, system_limits['backup_count']) + system_limits = log_config["system_logs"] + system_max_bytes = lines_to_bytes(system_limits["max_lines"]) + system_handler = _safe_rotating_handler(system_log_file, system_max_bytes, system_limits["backup_count"]) system_handler.setFormatter(formatter) logger.addHandler(system_handler) # HANDLER 2: Branch-root local log (two-tier: system_logs/ + branch logs/) local_logs_dir = get_module_logs_dir(branch_name) module_log_file = local_logs_dir / f"{module_name}.log" - local_limits = log_config['local_logs'] - local_max_bytes = lines_to_bytes(local_limits['max_lines']) - local_handler = _safe_rotating_handler(module_log_file, local_max_bytes, local_limits['backup_count']) + local_limits = log_config["local_logs"] + local_max_bytes = lines_to_bytes(local_limits["max_lines"]) + local_handler = _safe_rotating_handler(module_log_file, local_max_bytes, local_limits["backup_count"]) local_handler.setFormatter(formatter) logger.addHandler(local_handler) if _system_logger: - _system_logger.info(f"Logger created for {module_name} → system: {system_log_file} ({system_limits['max_lines']} lines), local: {module_log_file} ({local_limits['max_lines']} lines)") + _system_logger.info( + f"Logger created for {module_name} → system: {system_log_file} ({system_limits['max_lines']} lines), local: {module_log_file} ({local_limits['max_lines']} lines)" + ) # HANDLER 3: Terminal output (if enabled) if _terminal_output_enabled and _terminal_module_available: @@ -171,6 +172,7 @@ def setup_individual_logger( return logger + def setup_system_logger() -> logging.Logger: """Setup prax_logger's own logging @@ -193,24 +195,21 @@ def setup_system_logger() -> logging.Logger: _system_logger.handlers.clear() # Formatter shared by both handlers - log_config_fmt = logging.Formatter( - log_config['log_format'], - log_config['date_format'] - ) + log_config_fmt = logging.Formatter(log_config["log_format"], log_config["date_format"]) # HANDLER 1: System-wide log (central aggregation) system_log_file = get_system_logs_dir() / "prax_logger.log" - system_limits = log_config['system_logs'] - system_max_bytes = lines_to_bytes(system_limits['max_lines']) - system_handler = _safe_rotating_handler(system_log_file, system_max_bytes, system_limits['backup_count']) + system_limits = log_config["system_logs"] + system_max_bytes = lines_to_bytes(system_limits["max_lines"]) + system_handler = _safe_rotating_handler(system_log_file, system_max_bytes, system_limits["backup_count"]) system_handler.setFormatter(log_config_fmt) _system_logger.addHandler(system_handler) # HANDLER 2: Module-local log (local debugging) local_log_file = get_module_logs_dir("prax") / "prax_logger.log" - local_limits = log_config['local_logs'] - local_max_bytes = lines_to_bytes(local_limits['max_lines']) - local_handler = _safe_rotating_handler(local_log_file, local_max_bytes, local_limits['backup_count']) + local_limits = log_config["local_logs"] + local_max_bytes = lines_to_bytes(local_limits["max_lines"]) + local_handler = _safe_rotating_handler(local_log_file, local_max_bytes, local_limits["backup_count"]) local_handler.setFormatter(log_config_fmt) _system_logger.addHandler(local_handler) @@ -218,10 +217,13 @@ def setup_system_logger() -> logging.Logger: _system_logger.info("Prax system logger initialized successfully") _system_logger.info(f"System logger writing to: {system_log_file} + {local_log_file}") - json_handler.log_operation("logger_setup_complete", {"system_log": str(system_log_file), "local_log": str(local_log_file)}) + json_handler.log_operation( + "logger_setup_complete", {"system_log": str(system_log_file), "local_log": str(local_log_file)} + ) return _system_logger + def get_captured_loggers_count() -> int: """Get count of captured loggers @@ -230,6 +232,7 @@ def get_captured_loggers_count() -> int: """ return len(_captured_loggers) + def enable_terminal_output(): """Enable terminal output for all future loggers""" global _terminal_output_enabled @@ -237,10 +240,10 @@ def enable_terminal_output(): if _system_logger: _system_logger.info("Terminal output enabled") + def disable_terminal_output(): """Disable terminal output""" global _terminal_output_enabled _terminal_output_enabled = False if _system_logger: _system_logger.info("Terminal output disabled") - diff --git a/src/aipass/prax/apps/handlers/logging/terminal/filtering.py b/src/aipass/prax/apps/handlers/logging/terminal/filtering.py index 79a07e0d..ce31d935 100755 --- a/src/aipass/prax/apps/handlers/logging/terminal/filtering.py +++ b/src/aipass/prax/apps/handlers/logging/terminal/filtering.py @@ -13,6 +13,7 @@ Filters terminal output to reduce noise from internal modules. """ import logging + logger = logging.getLogger(__name__) import json @@ -29,10 +30,15 @@ CONFIG_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_config.json" # Default modules to filter (prax internal modules) DEFAULT_FILTERED_MODULES = { - 'prax_logger', 'prax_handlers', 'prax_config', - 'prax_registry', 'prax_discovery', 'prax_terminal' + "prax_logger", + "prax_handlers", + "prax_config", + "prax_registry", + "prax_discovery", + "prax_terminal", } + def load_filtered_modules() -> Set[str]: """Load filtered modules from config @@ -44,14 +50,15 @@ def load_filtered_modules() -> Set[str]: """ if CONFIG_FILE.exists(): try: - with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) - return set(config.get('filtered_modules', DEFAULT_FILTERED_MODULES)) + return set(config.get("filtered_modules", DEFAULT_FILTERED_MODULES)) except Exception as e: logger.warning("Failed to load terminal filter config %s: %s", CONFIG_FILE, e) return DEFAULT_FILTERED_MODULES + def should_display_terminal(module_name: str, filtered_modules: Optional[Set[str]] = None) -> bool: """Determine if module should be displayed in terminal output diff --git a/src/aipass/prax/apps/handlers/logging/terminal/formatting.py b/src/aipass/prax/apps/handlers/logging/terminal/formatting.py index aec83765..33edaf30 100755 --- a/src/aipass/prax/apps/handlers/logging/terminal/formatting.py +++ b/src/aipass/prax/apps/handlers/logging/terminal/formatting.py @@ -25,6 +25,7 @@ from aipass.prax.apps.handlers.logging.terminal.filtering import should_display_ from aipass.prax.apps.handlers.json import json_handler + def format_terminal_message(record: logging.LogRecord, branch: Optional[str] = None) -> str: """Format log record for terminal output @@ -55,6 +56,7 @@ def format_terminal_message(record: logging.LogRecord, branch: Optional[str] = N # Format message return f"[{branch_label}] {module_name} - {level}: {record.getMessage()}" + class TerminalFormatter(logging.Formatter): """Custom formatter for terminal output with branch information""" @@ -84,6 +86,7 @@ class TerminalFormatter(logging.Formatter): # Format and return return format_terminal_message(record) + def create_terminal_handler() -> logging.StreamHandler: """Create StreamHandler for terminal output diff --git a/src/aipass/prax/apps/handlers/monitoring/__init__.py b/src/aipass/prax/apps/handlers/monitoring/__init__.py index 206af765..f22c7aa2 100644 --- a/src/aipass/prax/apps/handlers/monitoring/__init__.py +++ b/src/aipass/prax/apps/handlers/monitoring/__init__.py @@ -1,4 +1,3 @@ - """ Monitoring Handlers Package @@ -28,32 +27,32 @@ from .file_watcher_integration import ( stop_file_watcher, is_file_watcher_running, get_file_watcher_stats, - FileWatcherManager + FileWatcherManager, ) from .log_watcher import start_log_watcher, stop_log_watcher, is_log_watcher_active __all__ = [ - 'print_event', - 'print_command_separator', - 'detect_branch_from_path', - 'parse_command', - 'get_help_text', - 'should_monitor', - 'get_priority', - 'get_content_filter', - 'apply_content_filter', - 'filter_log_content', - 'MonitoringEvent', - 'MonitoringQueue', - 'global_queue', - 'ModuleTracker', - 'MonitoringFileHandler', - 'start_file_watcher', - 'stop_file_watcher', - 'is_file_watcher_running', - 'get_file_watcher_stats', - 'FileWatcherManager', - 'start_log_watcher', - 'stop_log_watcher', - 'is_log_watcher_active', + "print_event", + "print_command_separator", + "detect_branch_from_path", + "parse_command", + "get_help_text", + "should_monitor", + "get_priority", + "get_content_filter", + "apply_content_filter", + "filter_log_content", + "MonitoringEvent", + "MonitoringQueue", + "global_queue", + "ModuleTracker", + "MonitoringFileHandler", + "start_file_watcher", + "stop_file_watcher", + "is_file_watcher_running", + "get_file_watcher_stats", + "FileWatcherManager", + "start_log_watcher", + "stop_log_watcher", + "is_log_watcher_active", ] diff --git a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py index da05d826..22b3fcf5 100644 --- a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py +++ b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py @@ -41,7 +41,7 @@ class BranchDetector: def __init__(self): """Initialize detector with empty caches""" self.branch_map: Dict[str, str] = {} # path -> branch - self.log_map: Dict[str, str] = {} # log file -> branch + self.log_map: Dict[str, str] = {} # log file -> branch self.module_map: Dict[str, str] = {} # module -> branch self.known_branches: Set[str] = set() self._repo_root: Optional[Path] = None @@ -63,14 +63,14 @@ class BranchDetector: def _register_branch(self, branch: dict) -> None: """Register a single branch entry into the lookup tables.""" - branch_name = branch.get('name', '').upper() - branch_path = branch.get('path', '') + branch_name = branch.get("name", "").upper() + branch_path = branch.get("path", "") if not branch_name or not branch_path: return path = Path(branch_path).resolve() self.branch_map[str(path)] = branch_name self.known_branches.add(branch_name) - self.branch_map[str(path) + '/'] = branch_name + self.branch_map[str(path) + "/"] = branch_name def _load_registry(self): """Load AIPASS_REGISTRY.json and build lookup tables.""" @@ -82,10 +82,10 @@ class BranchDetector: self._load_fallback_branches() return - with open(registry_path, encoding='utf-8') as f: + with open(registry_path, encoding="utf-8") as f: data = json.load(f) - branches = data.get('branches', []) + branches = data.get("branches", []) if not branches: logger.warning("No branches found in registry") self._load_fallback_branches() @@ -105,8 +105,7 @@ class BranchDetector: def _load_fallback_branches(self): """Load fallback branch names when registry is unavailable""" - fallback = ['SEEDGO', 'CLI', 'FLOW', 'PRAX', 'DRONE', - 'BACKUP', 'SECURITY', 'AIPASS'] + fallback = ["SEEDGO", "CLI", "FLOW", "PRAX", "DRONE", "BACKUP", "SECURITY", "AIPASS"] self.known_branches.update(fallback) logger.info(f"Using fallback branches: {fallback}") @@ -119,14 +118,14 @@ class BranchDetector: Returns: Name from registry file stem (e.g., 'VERA-STUDIO') or uppercased dir name """ - for base in [Path.home() / 'Projects']: + for base in [Path.home() / "Projects"]: project_dir = base / project_part if not project_dir.exists(): continue try: for item in project_dir.iterdir(): - if item.is_file() and item.name.endswith('_REGISTRY.json'): - return item.stem.replace('_REGISTRY', '') + if item.is_file() and item.name.endswith("_REGISTRY.json"): + return item.stem.replace("_REGISTRY", "") except (OSError, PermissionError) as e: logger.info(f"[branch_detector] Cannot read project dir {project_dir}: {e}") return project_part.upper() @@ -146,27 +145,27 @@ class BranchDetector: (project_name, agent_name) -- agent_name is None if no src subdir Returns (None, None) if cannot parse. """ - if not encoded_folder.startswith('-'): + if not encoded_folder.startswith("-"): return None, None name = encoded_folder[1:] # strip leading dash # Find -Projects- boundary - sep = '-projects-' + sep = "-projects-" idx = name.lower().find(sep) if idx < 0: return None, None # Everything after -projects- is our target - after = name[idx + len(sep):] + after = name[idx + len(sep) :] # Split on -src- to separate project from agent subdirectory - src_sep = '-src-' + src_sep = "-src-" src_idx = after.lower().find(src_sep) if src_idx >= 0: project_part = after[:src_idx] - agent_part = after[src_idx + len(src_sep):] + agent_part = after[src_idx + len(src_sep) :] else: project_part = after agent_part = None @@ -188,29 +187,29 @@ class BranchDetector: - External project main session: 'VERA-STUDIO' Sub-agents append ' SUB' to the agent/branch segment. """ - projects_idx = path_str.index('.claude/projects/') + len('.claude/projects/') + projects_idx = path_str.index(".claude/projects/") + len(".claude/projects/") remaining = path_str[projects_idx:] - project_folder = remaining.split('/')[0] - is_subagent = '/subagents/' in path_str - sub_suffix = ' SUB' if is_subagent else '' + project_folder = remaining.split("/")[0] + is_subagent = "/subagents/" in path_str + sub_suffix = " SUB" if is_subagent else "" folder_lower = project_folder.lower() # Internal AIPass: path contains -projects-aipass-src-aipass- - if '-projects-aipass-src-aipass-' in folder_lower: + if "-projects-aipass-src-aipass-" in folder_lower: # Strip leading dash before decode — avoids double slash and preserves # normalization that treats - and _ as equivalent (handles ai_mail→ai-mail). - name_part = project_folder[1:] if project_folder.startswith('-') else project_folder - project_path = '/' + name_part.replace('-', '/') + name_part = project_folder[1:] if project_folder.startswith("-") else project_folder + project_path = "/" + name_part.replace("-", "/") for registered_path, branch_name in self.branch_map.items(): - reg_norm = registered_path.replace('_', '/') - proj_norm = project_path.replace('_', '/') + reg_norm = registered_path.replace("_", "/") + proj_norm = project_path.replace("_", "/") if reg_norm == proj_norm or registered_path == project_path: return f"AIPASS/{branch_name}{sub_suffix}" # Fallback: scan segments for known branch names (handles multi-word: ai_mail) - segs = [s for s in project_folder.split('-') if s] + segs = [s for s in project_folder.split("-") if s] for n in range(min(3, len(segs)), 0, -1): - candidate = '_'.join(segs[-n:]).upper() + candidate = "_".join(segs[-n:]).upper() if candidate in self.known_branches: return f"AIPASS/{candidate}{sub_suffix}" if segs: @@ -228,11 +227,11 @@ class BranchDetector: return project_name # Old fallback: segment scanning for known branch names - segments = [s for s in project_folder.split('-') if s] + segments = [s for s in project_folder.split("-") if s] if not segments: return None for i in range(len(segments) - 1, 0, -1): - candidate = '_'.join(segments[i:]).upper() + candidate = "_".join(segments[i:]).upper() if candidate in self.known_branches: return candidate last = segments[-1].upper() @@ -243,8 +242,8 @@ class BranchDetector: def _detect_from_compound_parts(self, path_parts: list) -> Optional[str]: """Check compound path parts for known branch names.""" for part in path_parts: - if '_' in part: - for subpart in part.split('_'): + if "_" in part: + for subpart in part.split("_"): branch_upper = subpart.upper() if branch_upper in self.known_branches: return branch_upper @@ -259,7 +258,7 @@ class BranchDetector: Returns labels like 'AIPL/POLYGLOT', 'VERA-STUDIO', 'AIPL/POLYGLOT TESTS'. """ - projects_base = Path.home() / 'Projects' + projects_base = Path.home() / "Projects" try: rel = path.relative_to(projects_base) except ValueError: @@ -273,7 +272,7 @@ class BranchDetector: project_dir_name = parts[0] # Skip AIPass — handled by registry/branch_map (Strategy 2) - if project_dir_name.lower() == 'aipass': + if project_dir_name.lower() == "aipass": return None # Look up project name (cached) @@ -284,8 +283,8 @@ class BranchDetector: project_name = None try: for item in project_dir.iterdir(): - if item.is_file() and item.name.endswith('_REGISTRY.json'): - project_name = item.stem.replace('_REGISTRY', '') + if item.is_file() and item.name.endswith("_REGISTRY.json"): + project_name = item.stem.replace("_REGISTRY", "") break except (OSError, PermissionError) as e: logger.info(f"[branch_detector] Cannot scan project dir {project_dir}: {e}") @@ -295,14 +294,18 @@ class BranchDetector: # Extract agent from path: {project}/src/{agent}/... agent_name = None - if len(parts) > 2 and parts[1].lower() == 'src': + if len(parts) > 2 and parts[1].lower() == "src": agent_name = parts[2].upper() # Append TESTS suffix when path is clearly test output - path_str_lower = str(path).replace('\\', '/').lower() - is_test = ('/tests/' in path_str_lower or '/test_' in path_str_lower - or path_str_lower.endswith('_test.py') or path_str_lower.endswith('_test.log')) - test_suffix = ' TESTS' if is_test else '' + path_str_lower = str(path).replace("\\", "/").lower() + is_test = ( + "/tests/" in path_str_lower + or "/test_" in path_str_lower + or path_str_lower.endswith("_test.py") + or path_str_lower.endswith("_test.log") + ) + test_suffix = " TESTS" if is_test else "" if agent_name: return f"{project_name}/{agent_name}{test_suffix}" @@ -310,14 +313,14 @@ class BranchDetector: def _extract_branch_from_central(self, path_str: str, path: Path) -> Optional[str]: """Extract branch name from ai_mail central filename patterns.""" - if not ('AI_MAIL' in path_str or '.ai_mail' in path_str or 'ai_mail' in path_str.lower()): + if not ("AI_MAIL" in path_str or ".ai_mail" in path_str or "ai_mail" in path_str.lower()): return None name = path.name - if '.central.json' in name: - return name.replace('.central.json', '').upper() - if '_central.json' in name: - return name.replace('_central.json', '').upper() + if ".central.json" in name: + return name.replace(".central.json", "").upper() + if "_central.json" in name: + return name.replace("_central.json", "").upper() return None def detect_from_path(self, file_path: str) -> str: @@ -342,7 +345,7 @@ class BranchDetector: # Normalize to forward slashes for all string-based pattern matching. # Path.resolve() returns OS-native separators (backslashes on Windows), which # breaks every hardcoded '/' check. branch_map lookups still use path_str (OS-native). - path_str_fwd = path_str.replace('\\', '/') + path_str_fwd = path_str.replace("\\", "/") # Check cache first if path_str in self.log_map: @@ -353,13 +356,12 @@ class BranchDetector: # absolute paths, so Strategy 2 would return bare 'POLYGLOT' before we can # add the project prefix. Check external paths first to return 'AIPL/POLYGLOT TESTS'. _repo_root = self._find_repo_root() - _projects_base = Path.home() / 'Projects' + _projects_base = Path.home() / "Projects" _path_str_lower = path_str_fwd.lower() - _projects_str = str(_projects_base).replace('\\', '/').lower() - _repo_str = str(_repo_root).replace('\\', '/').lower() - _is_external = ( - _path_str_lower.startswith(_projects_str + '/') - and not _path_str_lower.startswith(_repo_str + '/') + _projects_str = str(_projects_base).replace("\\", "/").lower() + _repo_str = str(_repo_root).replace("\\", "/").lower() + _is_external = _path_str_lower.startswith(_projects_str + "/") and not _path_str_lower.startswith( + _repo_str + "/" ) if _is_external: @@ -390,7 +392,7 @@ class BranchDetector: return result # Strategy 3: Claude Code project files - if '.claude/projects/' in path_str_fwd: + if ".claude/projects/" in path_str_fwd: result = self._detect_from_claude_project(path_str_fwd) if result: self.log_map[path_str] = result @@ -404,12 +406,12 @@ class BranchDetector: # Strategy 5: Root-level system files (repo root or .claude under it) repo_root = self._find_repo_root() - if path.parent == repo_root or path.parent == repo_root / '.claude': - self.log_map[path_str] = 'SYSTEM' - return 'SYSTEM' + if path.parent == repo_root or path.parent == repo_root / ".claude": + self.log_map[path_str] = "SYSTEM" + return "SYSTEM" # Strategy 6: Parse path for known branch names - path_parts = path_str_fwd.lower().split('/') + path_parts = path_str_fwd.lower().split("/") for part in path_parts: branch_upper = part.upper() if branch_upper in self.known_branches: @@ -424,11 +426,11 @@ class BranchDetector: # No match found logger.info(f"Could not detect branch for path: {file_path}") - return 'UNKNOWN' + return "UNKNOWN" except Exception as e: logger.error(f"Error detecting branch from path {file_path}: {e}") - return 'UNKNOWN' + return "UNKNOWN" def detect_from_log(self, log_file: str) -> str: """ @@ -457,7 +459,7 @@ class BranchDetector: # Check known branches first (longest match wins) # Handles compound names like ai_mail, backup, memory for branch_name in sorted(self.known_branches, key=len, reverse=True): - prefix = branch_name.lower() + '_' + prefix = branch_name.lower() + "_" if name.lower().startswith(prefix) or name.lower() == branch_name.lower(): self.log_map[name] = branch_name return branch_name @@ -465,12 +467,12 @@ class BranchDetector: # Full path: use path detection before falling back to stem splitting. # This ensures ai_mail/logs/mail_*.log resolves to AI_MAIL via branch_map # rather than returning a truncated stem like MAIL. - if '/' in log_file: + if "/" in log_file: return self.detect_from_path(log_file) # Bare filename: fallback to stem splitting - if '_' in name: - parts = name.split('_') + if "_" in name: + parts = name.split("_") first_part = parts[0].upper() self.log_map[name] = first_part return first_part @@ -482,11 +484,11 @@ class BranchDetector: return name_upper logger.info(f"Could not detect branch from log: {log_file}") - return 'UNKNOWN' + return "UNKNOWN" except Exception as e: logger.info(f"Error detecting branch from log {log_file}: {e}") - return 'UNKNOWN' + return "UNKNOWN" def detect_from_module(self, dotted_name: str) -> str: """ @@ -508,7 +510,7 @@ class BranchDetector: return self.module_map[dotted_name] # Split on dots and check first part - parts = dotted_name.split('.') + parts = dotted_name.split(".") if parts: first_part = parts[0].upper() @@ -517,11 +519,11 @@ class BranchDetector: return first_part logger.info(f"Could not detect branch from module: {dotted_name}") - return 'UNKNOWN' + return "UNKNOWN" except Exception as e: logger.error(f"Error detecting branch from module {dotted_name}: {e}") - return 'UNKNOWN' + return "UNKNOWN" def reload_registry(self): """ @@ -545,10 +547,10 @@ class BranchDetector: Dictionary with cache sizes """ return { - 'branch_paths': len(self.branch_map), - 'cached_lookups': len(self.log_map), - 'cached_modules': len(self.module_map), - 'known_branches': len(self.known_branches) + "branch_paths": len(self.branch_map), + "cached_lookups": len(self.log_map), + "cached_modules": len(self.module_map), + "known_branches": len(self.known_branches), } @@ -600,7 +602,7 @@ def reload_registry(): get_detector().reload_registry() -if __name__ == '__main__': +if __name__ == "__main__": # Quick test detector = BranchDetector() diff --git a/src/aipass/prax/apps/handlers/monitoring/event_queue.py b/src/aipass/prax/apps/handlers/monitoring/event_queue.py index 28d9a58d..c35db316 100644 --- a/src/aipass/prax/apps/handlers/monitoring/event_queue.py +++ b/src/aipass/prax/apps/handlers/monitoring/event_queue.py @@ -19,30 +19,28 @@ from aipass.prax.apps.handlers.json import json_handler logger = get_direct_logger() + @dataclass(order=True) class MonitoringEvent: """Unified event structure for all monitoring sources""" + priority: int = field(compare=True) timestamp: datetime = field(compare=False, default_factory=datetime.now) - event_type: str = field(compare=False, default='') # 'file', 'log', 'module', 'command' - branch: str = field(compare=False, default='') - action: str = field(compare=False, default='') # 'created', 'modified', 'deleted', 'executed' - message: str = field(compare=False, default='') - level: str = field(compare=False, default='info') # 'info', 'warning', 'error' + event_type: str = field(compare=False, default="") # 'file', 'log', 'module', 'command' + branch: str = field(compare=False, default="") + action: str = field(compare=False, default="") # 'created', 'modified', 'deleted', 'executed' + message: str = field(compare=False, default="") + level: str = field(compare=False, default="info") # 'info', 'warning', 'error' caller: Optional[str] = field(compare=False, default=None) # Branch that initiated command pid: Optional[int] = field(compare=False, default=None) # Process ID of the agent def __post_init__(self): # Convert level to priority number for queue ordering if self.priority == 0: # Not set - priority_map = { - 'error': 1, - 'warning': 2, - 'info': 3, - 'debug': 4 - } + priority_map = {"error": 1, "warning": 2, "info": 3, "debug": 4} self.priority = priority_map.get(self.level, 3) + class MonitoringQueue: """Thread-safe event queue with deduplication""" @@ -70,7 +68,9 @@ class MonitoringQueue: self.recent_events.pop(0) return True except Exception as e: - logger.warning(f"[event_queue] Failed to enqueue event (type={event.event_type}, branch={event.branch}): {e}") + logger.warning( + f"[event_queue] Failed to enqueue event (type={event.event_type}, branch={event.branch}): {e}" + ) return False def dequeue(self, timeout: float = 0.1) -> Optional[MonitoringEvent]: @@ -98,11 +98,13 @@ class MonitoringQueue: def _is_duplicate(self, event: MonitoringEvent) -> bool: """Check if event duplicates recent event. Caller must hold self.lock.""" for recent in self.recent_events[-10:]: - if (recent.event_type == event.event_type and - recent.branch == event.branch and - recent.action == event.action and - recent.message == event.message and - abs((event.timestamp - recent.timestamp).total_seconds()) < 1): + if ( + recent.event_type == event.event_type + and recent.branch == event.branch + and recent.action == event.action + and recent.message == event.message + and abs((event.timestamp - recent.timestamp).total_seconds()) < 1 + ): return True return False @@ -110,5 +112,6 @@ class MonitoringQueue: """Get current queue size""" return self.queue.qsize() + # Global instance for the monitoring system global_queue = MonitoringQueue() diff --git a/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py b/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py index 88228f40..49881b59 100644 --- a/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py +++ b/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py @@ -53,10 +53,7 @@ try: WATCHDOG_AVAILABLE, ) - from aipass.prax.apps.handlers.monitoring.event_queue import ( - MonitoringEvent, - global_queue - ) + from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent, global_queue except ImportError as e: logger.error(f"Import error in file_watcher_integration: {e}") @@ -73,6 +70,7 @@ from aipass.prax.apps.handlers.json import json_handler # AIPASS REGISTRY LOADER # ============================================================================= + def load_branch_paths(branch_filter: Optional[List[str]] = None) -> List[Tuple[str, Path]]: """ Load branch paths from AIPASS_REGISTRY.json @@ -87,16 +85,17 @@ def load_branch_paths(branch_filter: Optional[List[str]] = None) -> List[Tuple[s """ try: from aipass.prax.apps.handlers.config.load import _find_repo_root + registry_path = _find_repo_root() / "AIPASS_REGISTRY.json" if not registry_path.exists(): logger.warning(f"AIPASS_REGISTRY.json not found at {registry_path}") return [] - with open(registry_path, encoding='utf-8') as f: + with open(registry_path, encoding="utf-8") as f: data = json.load(f) - branches = data.get('branches', []) + branches = data.get("branches", []) if not branches: logger.warning("No branches found in AIPASS_REGISTRY.json") return [] @@ -108,8 +107,8 @@ def load_branch_paths(branch_filter: Optional[List[str]] = None) -> List[Tuple[s # Extract (name, path) tuples branch_paths = [] for branch in branches: - name = branch.get('name', '').upper() - path_str = branch.get('path', '') + name = branch.get("name", "").upper() + path_str = branch.get("path", "") if not name or not path_str: logger.warning(f"Skipping invalid branch entry: {branch}") @@ -141,6 +140,7 @@ def load_branch_paths(branch_filter: Optional[List[str]] = None) -> List[Tuple[s # EVENT CALLBACK - File events to MonitoringQueue # ============================================================================= + def file_event_callback(branch_name: str, event_type: str, file_path: str): """ Callback for file system events from BranchFileHandler @@ -154,33 +154,23 @@ def file_event_callback(branch_name: str, event_type: str, file_path: str): """ try: # Map event type to action - action_map = { - 'CREATED': 'created', - 'MODIFIED': 'modified', - 'DELETED': 'deleted', - 'MOVED': 'moved' - } + action_map = {"CREATED": "created", "MODIFIED": "modified", "DELETED": "deleted", "MOVED": "moved"} action = action_map.get(event_type, event_type.lower()) # Determine priority based on event type # Deleted/Created are higher priority than modified - priority_map = { - 'deleted': 2, - 'created': 2, - 'moved': 2, - 'modified': 3 - } + priority_map = {"deleted": 2, "created": 2, "moved": 2, "modified": 3} priority = priority_map.get(action, 3) # Create monitoring event event = MonitoringEvent( # type: ignore[misc] priority=priority, timestamp=datetime.now(), - event_type='file', + event_type="file", branch=branch_name, action=action, message=file_path, - level='info' + level="info", ) # Enqueue to global queue (thread-safe) @@ -197,6 +187,7 @@ def file_event_callback(branch_name: str, event_type: str, file_path: str): # FILE WATCHER MANAGER # ============================================================================= + class FileWatcherManager: """ Manages file watcher lifecycle and observer threads @@ -276,10 +267,10 @@ class FileWatcherManager: def get_stats(self) -> dict: """Get file watcher statistics""" return { - 'running': self.running, - 'branches_watched': len(self.branch_paths), - 'branch_names': [name for name, _ in self.branch_paths], - 'watchdog_available': WATCHDOG_AVAILABLE + "running": self.running, + "branches_watched": len(self.branch_paths), + "branch_names": [name for name, _ in self.branch_paths], + "watchdog_available": WATCHDOG_AVAILABLE, } @@ -333,14 +324,11 @@ def get_file_watcher_stats() -> dict: # STANDALONE TEST # ============================================================================= -if __name__ == '__main__': +if __name__ == "__main__": import time # Set up basic logging - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(message)s' - ) + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") print("File Watcher Integration Test") print("=" * 60) @@ -358,7 +346,7 @@ if __name__ == '__main__': # Test 2: Start file watcher (LIMITED to PRAX only to avoid inotify limits) print("Test 2: Starting file watcher (PRAX branch only)") - watcher = FileWatcherManager(branch_filter=['PRAX']) + watcher = FileWatcherManager(branch_filter=["PRAX"]) if watcher.start(): print("File watcher started successfully") diff --git a/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py b/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py index d47c04e3..aa6a62cc 100644 --- a/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py +++ b/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py @@ -86,17 +86,17 @@ class MonitoringFileHandler(FileSystemEventHandler): def on_created(self, event): """Handle file creation events.""" if not event.is_directory: - self._handle_event('created', event.src_path) + self._handle_event("created", event.src_path) def on_modified(self, event): """Handle file modification events.""" if not event.is_directory: - self._handle_event('modified', event.src_path) + self._handle_event("modified", event.src_path) def on_deleted(self, event): """Handle file deletion events.""" if not event.is_directory: - self._handle_event('deleted', event.src_path) + self._handle_event("deleted", event.src_path) def on_moved(self, event): """Handle file move/rename events.""" @@ -104,13 +104,13 @@ class MonitoringFileHandler(FileSystemEventHandler): # dest_path can be bytes or str, normalize to str for comparison dest_path_str = event.dest_path.decode() if isinstance(event.dest_path, bytes) else event.dest_path src_path_str = event.src_path.decode() if isinstance(event.src_path, bytes) else event.src_path - if 'Trash' in dest_path_str or '.local/share/Trash' in dest_path_str: - self._handle_event('deleted', src_path_str) - elif '.tmp.' in src_path_str or src_path_str.endswith('.tmp'): + if "Trash" in dest_path_str or ".local/share/Trash" in dest_path_str: + self._handle_event("deleted", src_path_str) + elif ".tmp." in src_path_str or src_path_str.endswith(".tmp"): # Atomic write: tmp file moved to real file = modification - self._handle_event('modified', dest_path_str) + self._handle_event("modified", dest_path_str) else: - self._handle_event('moved', dest_path_str) + self._handle_event("moved", dest_path_str) # ========================================================================= # AGENT ACTIVITY PARSING (Claude Code JSONL sessions) @@ -119,56 +119,56 @@ class MonitoringFileHandler(FileSystemEventHandler): @staticmethod def _format_tool_action(item: dict) -> Optional[str]: """Format a tool_use JSONL item into a display string.""" - tool_name = item.get('name', '') - inp = item.get('input', {}) - if tool_name in ('Read', 'Edit', 'Write'): - fp = inp.get('file_path', '') - short = fp.split('/')[-1] if '/' in fp else fp + tool_name = item.get("name", "") + inp = item.get("input", {}) + if tool_name in ("Read", "Edit", "Write"): + fp = inp.get("file_path", "") + short = fp.split("/")[-1] if "/" in fp else fp return f"🔧 {tool_name}: {short}" - if tool_name == 'Bash': - desc = inp.get('description', '') or inp.get('command', '')[:120] + if tool_name == "Bash": + desc = inp.get("description", "") or inp.get("command", "")[:120] return f"⚡ Bash: {desc[:120]}" - if tool_name in ('Grep', 'Glob'): + if tool_name in ("Grep", "Glob"): return f"🔍 {tool_name}: {inp.get('pattern', '')[:80]}" - if tool_name == 'Task': + if tool_name == "Task": return f"🚀 Agent: {inp.get('description', '')[:80]}" return f"🔧 {tool_name}" @staticmethod def _extract_model_from_entry(entry: dict) -> Optional[str]: """Extract model name from a Claude Code JSONL entry.""" - msg = entry.get('message', {}) if isinstance(entry.get('message'), dict) else {} - return msg.get('model') + msg = entry.get("message", {}) if isinstance(entry.get("message"), dict) else {} + return msg.get("model") @staticmethod def _extract_action_from_entry(entry: dict) -> Optional[str]: """Extract a display action string from a JSONL entry.""" - entry_type = entry.get('type', '') - msg = entry.get('message', {}) if isinstance(entry.get('message'), dict) else {} - content = msg.get('content', []) + entry_type = entry.get("type", "") + msg = entry.get("message", {}) if isinstance(entry.get("message"), dict) else {} + content = msg.get("content", []) - if entry_type in ('progress', 'system', 'file-history-snapshot', 'queue-operation'): + if entry_type in ("progress", "system", "file-history-snapshot", "queue-operation"): return None - if entry_type == 'assistant' and isinstance(content, list): + if entry_type == "assistant" and isinstance(content, list): for item in content: if not isinstance(item, dict): continue - item_type = item.get('type', '') - if item_type == 'thinking': - return '💭 Thinking' - if item_type == 'tool_use': + item_type = item.get("type", "") + if item_type == "thinking": + return "💭 Thinking" + if item_type == "tool_use": return MonitoringFileHandler._format_tool_action(item) - if item_type == 'text': - text = item.get('text', '').strip() + if item_type == "text": + text = item.get("text", "").strip() return f"💬 {text}" if text else None - if entry_type == 'user': + if entry_type == "user": if isinstance(content, list): for item in content: - if isinstance(item, dict) and item.get('type') == 'tool_result': + if isinstance(item, dict) and item.get("type") == "tool_result": return None - return '📩 User message' + return "📩 User message" return None @@ -182,23 +182,23 @@ class MonitoringFileHandler(FileSystemEventHandler): if not cwd: return None parts = Path(cwd).parts - if 'aipass' in parts: - idx = parts.index('aipass') + if "aipass" in parts: + idx = parts.index("aipass") if idx + 1 < len(parts): return parts[idx + 1].upper() # External project under ~/Projects/: use branch_detector for full label # e.g. ~/Projects/AIPL/src/polyglot/ → AIPL/POLYGLOT - projects_base = Path.home() / 'Projects' + projects_base = Path.home() / "Projects" try: Path(cwd).relative_to(projects_base) result = detect_branch_from_path(cwd) - if result and result != 'UNKNOWN': + if result and result != "UNKNOWN": return result except ValueError: logger.info(f"[monitor] CWD not under ~/Projects/: {cwd}") # Fallback: check src/{name} for branches outside aipass namespace - if 'src' in parts: - idx = parts.index('src') + if "src" in parts: + idx = parts.index("src") if idx + 1 < len(parts): return parts[idx + 1].upper() return None @@ -206,14 +206,14 @@ class MonitoringFileHandler(FileSystemEventHandler): def _read_codex_cwd(self, file_path) -> Optional[str]: """Read CWD from the first line (session_meta) of a Codex JSONL file.""" try: - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: first_line = f.readline().strip() if not first_line: return None meta = _json.loads(first_line) - if meta.get('type') != 'session_meta': + if meta.get("type") != "session_meta": return None - return meta.get('payload', {}).get('cwd', '') + return meta.get("payload", {}).get("cwd", "") except (OSError, _json.JSONDecodeError) as e: logger.info(f"[monitor] Could not read Codex session_meta: {e}") return None @@ -225,13 +225,13 @@ class MonitoringFileHandler(FileSystemEventHandler): cwd = self._read_codex_cwd(file_path) branch = self._branch_from_cwd(cwd) if cwd else None - result = branch or 'CODEX' + result = branch or "CODEX" self._session_branches[path_key] = result return result def _resolve_gemini_slug(self, slug: str) -> Optional[str]: """Resolve a Gemini project slug to a branch name via projects.json.""" - projects_file = Path.home() / '.gemini' / 'projects.json' + projects_file = Path.home() / ".gemini" / "projects.json" if not projects_file.exists(): return None try: @@ -239,7 +239,7 @@ class MonitoringFileHandler(FileSystemEventHandler): except (OSError, _json.JSONDecodeError) as e: logger.info(f"[monitor] Could not read Gemini projects.json: {e}") return None - for project_path, project_slug in data.get('projects', {}).items(): + for project_path, project_slug in data.get("projects", {}).items(): if project_slug == slug: return self._branch_from_cwd(project_path) return None @@ -252,14 +252,14 @@ class MonitoringFileHandler(FileSystemEventHandler): # Path: ~/.gemini/tmp//chats/session-*.json parts = Path(file_path).parts slug = None - if 'tmp' in parts: - idx = parts.index('tmp') + if "tmp" in parts: + idx = parts.index("tmp") if idx + 1 < len(parts): slug = parts[idx + 1] if not slug: - self._session_branches[path_key] = 'GEMINI' - return 'GEMINI' + self._session_branches[path_key] = "GEMINI" + return "GEMINI" branch = self._resolve_gemini_slug(slug) or slug.upper() self._session_branches[path_key] = branch @@ -273,31 +273,31 @@ class MonitoringFileHandler(FileSystemEventHandler): def _shorten_model(model: str) -> str: """Shorten a model identifier for display.""" if not model: - return '' + return "" m = model.lower() # Claude models - if 'opus' in m: - return 'opus' - if 'sonnet' in m: - return 'sonnet' - if 'haiku' in m: - return 'haiku' + if "opus" in m: + return "opus" + if "sonnet" in m: + return "sonnet" + if "haiku" in m: + return "haiku" # OpenAI/Codex models - if m.startswith('gpt-'): + if m.startswith("gpt-"): return m # already short: gpt-4o, gpt-5.4 - if m.startswith('o') and any(c.isdigit() for c in m): + if m.startswith("o") and any(c.isdigit() for c in m): return m # o1, o3, o4-mini # Gemini models - if 'gemini' in m: + if "gemini" in m: # gemini-3-flash-preview → gemini-3-flash - parts = m.replace('gemini-', '').split('-') - return 'gemini-' + '-'.join(p for p in parts if p != 'preview') + parts = m.replace("gemini-", "").split("-") + return "gemini-" + "-".join(p for p in parts if p != "preview") # Fallback: first 15 chars return model[:15] def _tag_branch_with_model(self, path_key: str, branch: str) -> str: """Append model tag to branch if known for this session.""" - model = self._session_models.get(path_key, '') + model = self._session_models.get(path_key, "") if model: return f"{branch}/{model}" return branch @@ -309,30 +309,30 @@ class MonitoringFileHandler(FileSystemEventHandler): @staticmethod def _extract_codex_action(entry: dict) -> Optional[str]: """Extract a display action string from a Codex JSONL entry.""" - entry_type = entry.get('type', '') - payload = entry.get('payload', {}) + entry_type = entry.get("type", "") + payload = entry.get("payload", {}) - if entry_type == 'event_msg': - event_type = payload.get('type', '') - if event_type == 'agent_message': - text = payload.get('text', '') or payload.get('message', '') + if entry_type == "event_msg": + event_type = payload.get("type", "") + if event_type == "agent_message": + text = payload.get("text", "") or payload.get("message", "") if text: return f"💬 {str(text)[:120]}" - return '💬 Agent response' - if event_type == 'user_message': - return '📩 User message' - if event_type == 'token_count': - return '💭 Thinking' - if event_type == 'task_started': - return '🚀 Task started' - if event_type == 'task_complete': - return '✅ Task complete' + return "💬 Agent response" + if event_type == "user_message": + return "📩 User message" + if event_type == "token_count": + return "💭 Thinking" + if event_type == "task_started": + return "🚀 Task started" + if event_type == "task_complete": + return "✅ Task complete" return None - if entry_type == 'response_item': + if entry_type == "response_item": return MonitoringFileHandler._parse_codex_response_item(payload) - if entry_type in ('session_meta', 'turn_context'): + if entry_type in ("session_meta", "turn_context"): return None return None @@ -340,21 +340,21 @@ class MonitoringFileHandler(FileSystemEventHandler): @staticmethod def _parse_codex_response_item(payload: dict) -> Optional[str]: """Parse a Codex response_item payload into a display string.""" - item = payload.get('item', payload) - item_type = item.get('type', '') - if item_type == 'function_call': + item = payload.get("item", payload) + item_type = item.get("type", "") + if item_type == "function_call": return f"🔧 {item.get('name', 'tool')}" - if item_type == 'function_call_output': + if item_type == "function_call_output": return None - if item_type != 'message': + if item_type != "message": return None - content = item.get('content', []) + content = item.get("content", []) if not isinstance(content, list): - return '💬 Agent response' + return "💬 Agent response" for part in content: - if isinstance(part, dict) and part.get('text'): + if isinstance(part, dict) and part.get("text"): return f"💬 {part['text'][:120]}" - return '💬 Agent response' + return "💬 Agent response" # ========================================================================= # GEMINI AGENT ACTIVITY PARSING (full JSON sessions) @@ -363,21 +363,21 @@ class MonitoringFileHandler(FileSystemEventHandler): @staticmethod def _extract_gemini_action(message: dict) -> Optional[str]: """Extract a display action string from a Gemini session message.""" - msg_type = message.get('type', '') - if msg_type == 'user': - return '📩 User message' - if msg_type != 'gemini': + msg_type = message.get("type", "") + if msg_type == "user": + return "📩 User message" + if msg_type != "gemini": return None - tool_calls = message.get('toolCalls', []) + tool_calls = message.get("toolCalls", []) if tool_calls: last_tool = tool_calls[-1] return f"🔧 {last_tool.get('displayName', last_tool.get('name', 'tool'))}" - if message.get('thoughts'): - return '💭 Thinking' + if message.get("thoughts"): + return "💭 Thinking" - return MonitoringFileHandler._extract_text_from_content(message.get('content', [])) + return MonitoringFileHandler._extract_text_from_content(message.get("content", [])) @staticmethod def _extract_text_from_content(content) -> str: @@ -386,9 +386,9 @@ class MonitoringFileHandler(FileSystemEventHandler): return f"💬 {content.strip()[:120]}" if isinstance(content, list): for part in content: - if isinstance(part, dict) and part.get('text', '').strip(): + if isinstance(part, dict) and part.get("text", "").strip(): return f"💬 {part['text'].strip()[:120]}" - return '💬 Agent response' + return "💬 Agent response" def _parse_gemini_activity(self, file_path, branch): """Parse Gemini session JSON to show agent actions. @@ -398,10 +398,10 @@ class MonitoringFileHandler(FileSystemEventHandler): """ try: path_key = str(file_path) - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: data = _json.load(f) - messages = data.get('messages', []) + messages = data.get("messages", []) last_count = self._jsonl_positions.get(path_key, 0) if len(messages) <= last_count: @@ -411,7 +411,7 @@ class MonitoringFileHandler(FileSystemEventHandler): # Extract model from gemini messages for msg in messages: - model = msg.get('model', '') + model = msg.get("model", "") if model: self._session_models[path_key] = self._shorten_model(model) break @@ -429,8 +429,12 @@ class MonitoringFileHandler(FileSystemEventHandler): tagged_branch = self._tag_branch_with_model(path_key, branch) evt = MonitoringEvent( - priority=1, event_type='agent', branch=tagged_branch, - action='activity', message=action_text, level='info', + priority=1, + event_type="agent", + branch=tagged_branch, + action="activity", + message=action_text, + level="info", ) if self._event_queue: self._event_queue.enqueue(evt) @@ -449,11 +453,11 @@ class MonitoringFileHandler(FileSystemEventHandler): last_pos = 0 if current_size <= last_pos: return None - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: f.seek(last_pos) new_data = f.read() self._jsonl_positions[path_key] = f.tell() - lines = [line for line in new_data.strip().split('\n') if line.strip()] + lines = [line for line in new_data.strip().split("\n") if line.strip()] return lines if lines else None def _safe_parse_json(self, line: str, label: str) -> Optional[dict]: @@ -471,8 +475,12 @@ class MonitoringFileHandler(FileSystemEventHandler): self._last_agent_action[path_key] = action_text tagged_branch = self._tag_branch_with_model(path_key, branch) evt = MonitoringEvent( - priority=1, event_type='agent', branch=tagged_branch, - action='activity', message=action_text, level='info', + priority=1, + event_type="agent", + branch=tagged_branch, + action="activity", + message=action_text, + level="info", ) if self._event_queue: self._event_queue.enqueue(evt) @@ -480,11 +488,11 @@ class MonitoringFileHandler(FileSystemEventHandler): def _extract_codex_model(self, entry: dict, path_key: str) -> None: """Extract model from a Codex JSONL entry if present.""" - entry_type = entry.get('type', '') - payload = entry.get('payload', {}) - model = '' - if entry_type == 'turn_context': - model = payload.get('model', '') + entry_type = entry.get("type", "") + payload = entry.get("payload", {}) + model = "" + if entry_type == "turn_context": + model = payload.get("model", "") if model: self._session_models[path_key] = self._shorten_model(model) @@ -530,12 +538,12 @@ class MonitoringFileHandler(FileSystemEventHandler): if current_size <= last_pos: return True - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: f.seek(last_pos) new_data = f.read() self._jsonl_positions[path_key] = f.tell() - lines = [line for line in new_data.strip().split('\n') if line.strip()] + lines = [line for line in new_data.strip().split("\n") if line.strip()] if not lines: return True @@ -561,8 +569,12 @@ class MonitoringFileHandler(FileSystemEventHandler): tagged_branch = self._tag_branch_with_model(path_key, branch) evt = MonitoringEvent( - priority=1, event_type='agent', branch=tagged_branch, - action='activity', message=action_text, level='info', + priority=1, + event_type="agent", + branch=tagged_branch, + action="activity", + message=action_text, + level="info", ) if self._event_queue: self._event_queue.enqueue(evt) @@ -580,7 +592,7 @@ class MonitoringFileHandler(FileSystemEventHandler): def _check_command_indicator(self, action, file_path, branch): """Check if file event indicates a command and emit separator if so.""" - if action != 'modified' or file_path.name not in self._command_indicator_files: + if action != "modified" or file_path.name not in self._command_indicator_files: return cmd = self._command_indicator_files[file_path.name] dedup_key = f"{branch}:{cmd}" @@ -588,8 +600,12 @@ class MonitoringFileHandler(FileSystemEventHandler): return self._last_file_command[file_path.name] = dedup_key cmd_event = MonitoringEvent( - priority=2, event_type='command', branch=branch, - action='executed', message=cmd, level='info', + priority=2, + event_type="command", + branch=branch, + action="executed", + message=cmd, + level="info", ) if self._event_queue: self._event_queue.enqueue(cmd_event) @@ -605,20 +621,20 @@ class MonitoringFileHandler(FileSystemEventHandler): branch = detect_branch_from_path(str(file_path)) # Claude Code JSONL files: parse agent activity - if file_path.suffix == '.jsonl' and '.claude/projects/' in path_str: - if '/subagents/' in path_str: - branch = branch + ' agent' + if file_path.suffix == ".jsonl" and ".claude/projects/" in path_str: + if "/subagents/" in path_str: + branch = branch + " agent" if self._parse_agent_activity(file_path, branch): return # Codex JSONL files: parse agent activity - if file_path.suffix == '.jsonl' and '.codex/sessions/' in path_str: + if file_path.suffix == ".jsonl" and ".codex/sessions/" in path_str: codex_branch = self._get_codex_branch(file_path, path_str) if self._parse_codex_activity(file_path, codex_branch): return # Gemini JSON session files: parse agent activity - if file_path.suffix == '.json' and '.gemini/tmp/' in path_str and '/chats/' in path_str: + if file_path.suffix == ".json" and ".gemini/tmp/" in path_str and "/chats/" in path_str: gemini_branch = self._get_gemini_branch(file_path, path_str) if self._parse_gemini_activity(file_path, gemini_branch): return @@ -629,9 +645,12 @@ class MonitoringFileHandler(FileSystemEventHandler): display_name = self._build_display_name(file_path) evt = MonitoringEvent( - priority=0, event_type='file', branch=branch, action=action, + priority=0, + event_type="file", + branch=branch, + action=action, message=f"{action.upper()}: {display_name}", - level=priority_level if priority_level in ['error', 'warning', 'info'] else 'info', + level=priority_level if priority_level in ["error", "warning", "info"] else "info", ) if self._event_queue: self._event_queue.enqueue(evt) @@ -643,6 +662,6 @@ class MonitoringFileHandler(FileSystemEventHandler): """Build branch-relative display name for a file path.""" parts = file_path.parts for i, part in enumerate(parts): - if part in ('apps', 'handlers', 'modules', 'docs', 'templates'): - return '/'.join(parts[i:]) + if part in ("apps", "handlers", "modules", "docs", "templates"): + return "/".join(parts[i:]) return file_path.name diff --git a/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py b/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py index dc986ef9..8fded187 100644 --- a/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py +++ b/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py @@ -35,8 +35,8 @@ def parse_command(cmd: str) -> Tuple[Optional[str], List[str]]: json_handler.log_operation("filter_applied", {"command": command, "args": args}) # Normalize aliases - if command in ['exit', 'q']: - command = 'quit' + if command in ["exit", "q"]: + command = "quit" return command, args diff --git a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py index 051248e8..e55aaf20 100644 --- a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py +++ b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py @@ -45,6 +45,7 @@ logger = get_direct_logger() # Trigger integration - graceful fallback if trigger not available try: from aipass.trigger.apps.modules.core import trigger + HAS_TRIGGER = True except ImportError as e: logger.info("[log_watcher] trigger module not available: %s", e) @@ -67,9 +68,11 @@ def _generate_error_hash(module_name: str, message: str) -> str: 8-character hash string for deduplication """ import hashlib + content = f"{module_name}:{message}" return hashlib.md5(content.encode()).hexdigest()[:8] + # Global observer instance _log_observer: Any = None @@ -124,7 +127,7 @@ class LogFileWatcher(FileSystemEventHandler): if current_size <= last_pos: return None - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: f.seek(last_pos) new_lines = f.read() self.log_positions[file_path] = f.tell() @@ -138,7 +141,7 @@ class LogFileWatcher(FileSystemEventHandler): file_path = str(event.src_path) - if not file_path.endswith('.log'): + if not file_path.endswith(".log"): return if str(get_system_logs_dir()) not in file_path: @@ -150,7 +153,7 @@ class LogFileWatcher(FileSystemEventHandler): return branch = detect_branch_from_log(file_path) - for line in new_content.strip().split('\n'): + for line in new_content.strip().split("\n"): self._process_log_line(branch, line, file_path) except Exception as e: @@ -163,29 +166,29 @@ class LogFileWatcher(FileSystemEventHandler): def _detect_log_level(self, log_line: str) -> str: """Detect log level from log line. Returns 'error', 'warning', 'info', or 'debug'.""" _LEVEL_MARKERS = { - 'error': (' - ERROR - ', ' ERROR ', '[ERROR]', ' - CRITICAL - ', ' CRITICAL ', '[CRITICAL]'), - 'warning': (' - WARNING - ', ' WARNING ', '[WARNING]'), - 'debug': (' - DEBUG - ', ' DEBUG ', '[DEBUG]'), + "error": (" - ERROR - ", " ERROR ", "[ERROR]", " - CRITICAL - ", " CRITICAL ", "[CRITICAL]"), + "warning": (" - WARNING - ", " WARNING ", "[WARNING]"), + "debug": (" - DEBUG - ", " DEBUG ", "[DEBUG]"), } for level, markers in _LEVEL_MARKERS.items(): if any(m in log_line for m in markers): return level - return 'info' + return "info" def _match_flow_command(self, log_line: str) -> Optional[Dict[str, Optional[str]]]: """Match flow plan commands from log line.""" if "Creating" in log_line: - return {'command': "flow create plan", 'caller': None, 'target': None} + return {"command": "flow create plan", "caller": None, "target": None} if "Closing" in log_line: match = re.search(r"(?:FPLAN|PLAN)[- ]?(\d+)", log_line) plan_id = match.group(1) if match else "" - return {'command': f"flow close plan {plan_id}".strip(), 'caller': None, 'target': None} + return {"command": f"flow close plan {plan_id}".strip(), "caller": None, "target": None} if "Opening" in log_line: match = re.search(r"(?:FPLAN|PLAN)[- ]?(\d+)", log_line) plan_id = match.group(1) if match else "" - return {'command': f"flow open plan {plan_id}".strip(), 'caller': None, 'target': None} - if "Loaded module:" in log_line and not self.last_command_per_branch.get('FLOW', '').startswith('FLOW:flow'): - return {'command': "flow command", 'caller': None, 'target': None} + return {"command": f"flow open plan {plan_id}".strip(), "caller": None, "target": None} + if "Loaded module:" in log_line and not self.last_command_per_branch.get("FLOW", "").startswith("FLOW:flow"): + return {"command": "flow command", "caller": None, "target": None} return None def _extract_command_info(self, log_line: str) -> Optional[Dict[str, Optional[str]]]: @@ -199,8 +202,8 @@ class LogFileWatcher(FileSystemEventHandler): if "Drone started with args:" in log_line or "[drone] Drone started with args:" in log_line: match = re.search(r"args:\s*\[([^\]]+)\]", log_line) if match: - args = match.group(1).replace("'", "").replace('"', '') - return {'command': f"drone {args}", 'caller': None, 'target': None} + args = match.group(1).replace("'", "").replace('"', "") + return {"command": f"drone {args}", "caller": None, "target": None} # Pattern 2: Flow plan commands if "[FLOW]" in log_line or "FLOW_PLAN]" in log_line: @@ -213,13 +216,13 @@ class LogFileWatcher(FileSystemEventHandler): match = re.search(r"Auditing\s+(\w+)", log_line) if match: target = match.group(1).upper() - return {'command': f"seedgo audit @{target.lower()}", 'caller': None, 'target': target} + return {"command": f"seedgo audit @{target.lower()}", "caller": None, "target": target} # Pattern 4: Seedgo checklist commands if "standards_checklist" in log_line.lower() and "Running" in log_line: match = re.search(r"Running\s+(\w+)\s+standard\s+check\s+on\s+(.+)", log_line) if match: - return {'command': f"seedgo checklist {match.group(2)}", 'caller': None, 'target': None} + return {"command": f"seedgo checklist {match.group(2)}", "caller": None, "target": None} # Pattern 5: AI Mail commands - extract target if "[ai_mail]" in log_line.lower(): @@ -227,44 +230,46 @@ class LogFileWatcher(FileSystemEventHandler): # Try to extract recipient target_match = re.search(r"to\s+@?(\w+)", log_line, re.IGNORECASE) target = target_match.group(1).upper() if target_match else None - return {'command': "ai_mail send", 'caller': None, 'target': target} + return {"command": "ai_mail send", "caller": None, "target": target} elif "inbox" in log_line.lower(): - return {'command': "ai_mail inbox", 'caller': None, 'target': None} + return {"command": "ai_mail inbox", "caller": None, "target": None} # Pattern 6: Prax commands if "[prax]" in log_line.lower(): if "monitor" in log_line.lower(): - return {'command': "prax monitor", 'caller': None, 'target': None} + return {"command": "prax monitor", "caller": None, "target": None} elif "status" in log_line.lower(): - return {'command': "prax status", 'caller': None, 'target': None} + return {"command": "prax status", "caller": None, "target": None} # Pattern 8: Backup operations (direct python3 calls) if "[backup" in log_line.lower(): - if "snapshot" in log_line.lower() and ("Starting" in log_line or "Running" in log_line or "Complete" in log_line): - return {'command': "backup snapshot", 'caller': None, 'target': None} + if "snapshot" in log_line.lower() and ( + "Starting" in log_line or "Running" in log_line or "Complete" in log_line + ): + return {"command": "backup snapshot", "caller": None, "target": None} elif "versioned" in log_line.lower() and ("Starting" in log_line or "Running" in log_line): - return {'command': "backup versioned", 'caller': None, 'target': None} + return {"command": "backup versioned", "caller": None, "target": None} elif "sync" in log_line.lower() and ("Starting" in log_line or "Running" in log_line): - return {'command': "backup sync", 'caller': None, 'target': None} + return {"command": "backup sync", "caller": None, "target": None} # Pattern 9: Memory operations (direct python3 calls) if "[memory]" in log_line.lower() or "memory" in log_line.lower(): if "rollover" in log_line.lower() and ("Starting" in log_line or "Processing" in log_line): - return {'command': "memory rollover", 'caller': None, 'target': None} + return {"command": "memory rollover", "caller": None, "target": None} elif "search" in log_line.lower() and "query" in log_line.lower(): - return {'command': "memory search", 'caller': None, 'target': None} + return {"command": "memory search", "caller": None, "target": None} # Pattern 10: Spawn operations (direct python3 calls) if "[spawn]" in log_line.lower(): if "Creating" in log_line and "branch" in log_line.lower(): match = re.search(r"Creating\s+(?:branch\s+)?(\w+)", log_line) target = match.group(1).upper() if match else None - return {'command': "spawn create branch", 'caller': None, 'target': target} + return {"command": "spawn create branch", "caller": None, "target": target} # Pattern 11: Trigger operations (direct python3 calls) if "[trigger]" in log_line.lower(): if "fired" in log_line.lower() or "triggered" in log_line.lower(): - return {'command': "trigger fire", 'caller': None, 'target': None} + return {"command": "trigger fire", "caller": None, "target": None} # Pattern 12: Drone routing with caller attribution - HIGHEST PRIORITY # Format: "Routing @flow [CALLER:PRAX] → create ['.', 'Subject']" @@ -279,7 +284,7 @@ class LogFileWatcher(FileSystemEventHandler): cmd_name = route_match.group(2) cmd_args = route_match.group(3).strip() display_cmd = f"drone @{route_match.group(1)} {cmd_name} {cmd_args}".strip() - return {'command': display_cmd, 'caller': caller, 'target': target} + return {"command": display_cmd, "caller": caller, "target": target} # Pattern 7: ALL drone command executions - HIGH PRIORITY # Format: "Executing command [CALLER:PRAX]: seedgo.py audit @prax" @@ -301,18 +306,18 @@ class LogFileWatcher(FileSystemEventHandler): target = self._extract_target_from_cmd(cmd) # Clean up command display - simplify paths - display_cmd = re.sub(r'[^\s]*/aipass/(\w+)/apps/\w+\.py', lambda m: f"@{m.group(1)}", cmd) - display_cmd = re.sub(r'[^\s]*/aipass/(\w+)', lambda m: f"@{m.group(1)}", display_cmd) + display_cmd = re.sub(r"[^\s]*/aipass/(\w+)/apps/\w+\.py", lambda m: f"@{m.group(1)}", cmd) + display_cmd = re.sub(r"[^\s]*/aipass/(\w+)", lambda m: f"@{m.group(1)}", display_cmd) - return {'command': display_cmd, 'caller': caller, 'target': target} + return {"command": display_cmd, "caller": caller, "target": target} @staticmethod def _extract_target_from_cmd(cmd: str) -> Optional[str]: """Extract target branch from a command string.""" - target_match = re.search(r'@(\w+)', cmd) + target_match = re.search(r"@(\w+)", cmd) if target_match: return target_match.group(1).upper() - path_match = re.search(r'/aipass/(\w+)', cmd) + path_match = re.search(r"/aipass/(\w+)", cmd) if path_match: return path_match.group(1).upper() return None @@ -324,9 +329,9 @@ class LogFileWatcher(FileSystemEventHandler): """ # Handle dict format (new) and legacy string/tuple formats if isinstance(command_info, dict): - command = command_info.get('command', '') - caller = command_info.get('caller') - target = command_info.get('target') + command = command_info.get("command", "") + caller = command_info.get("caller") + target = command_info.get("target") elif isinstance(command_info, tuple): command, caller = command_info target = None @@ -343,13 +348,13 @@ class LogFileWatcher(FileSystemEventHandler): separator_event = MonitoringEvent( priority=2, - event_type='command', + event_type="command", branch=branch, - action='executed', + action="executed", message=command, - level='info', + level="info", timestamp=datetime.now(), - caller=caller + caller=caller, ) # Store target in action field since MonitoringEvent doesn't have a target field @@ -372,12 +377,12 @@ class LogFileWatcher(FileSystemEventHandler): Cleaned message content """ # Try to extract message after last pipe separator - if ' | ' in log_line: - parts = log_line.split(' | ') + if " | " in log_line: + parts = log_line.split(" | ") if len(parts) >= 4: # Format: [BRANCH] TIMESTAMP | SOURCE | LEVEL | MESSAGE # Return everything after the LEVEL part - return ' | '.join(parts[3:]).strip() + return " | ".join(parts[3:]).strip() elif len(parts) >= 2: # Simpler format, return last part return parts[-1].strip() @@ -385,8 +390,7 @@ class LogFileWatcher(FileSystemEventHandler): # Fallback: return as-is if can't parse return log_line.strip() - def _emit_log_event(self, branch: str, log_line: str, level: str, - log_file_path: Optional[str] = None) -> None: + def _emit_log_event(self, branch: str, log_line: str, level: str, log_file_path: Optional[str] = None) -> None: """ Create and emit log event to monitoring queue. @@ -404,32 +408,33 @@ class LogFileWatcher(FileSystemEventHandler): current_time = datetime.now() # Fire trigger event for ERROR level logs - if HAS_TRIGGER and level == 'error': + if HAS_TRIGGER and level == "error": # Extract module name from log line if possible - module_name = 'unknown' - if ' | ' in log_line: - parts = log_line.split(' | ') + module_name = "unknown" + if " | " in log_line: + parts = log_line.split(" | ") if len(parts) >= 2: module_name = parts[1].strip() - trigger.fire('error_detected', # type: ignore[union-attr] + trigger.fire( + "error_detected", # type: ignore[union-attr] branch=branch, message=clean_message, error_hash=_generate_error_hash(module_name, clean_message), timestamp=current_time.isoformat(), - log_file=log_file_path or 'unknown', - module_name=module_name + log_file=log_file_path or "unknown", + module_name=module_name, ) # Create monitoring event log_event = MonitoringEvent( priority=0, # Auto-calculated from level - event_type='log', + event_type="log", branch=branch, - action='logged', + action="logged", message=clean_message, level=level, - timestamp=current_time + timestamp=current_time, ) # Push to queue @@ -451,6 +456,7 @@ class LogFileWatcher(FileSystemEventHandler): except Exception as e: logger.info(f"Could not get size for {log_file}: {e}") + def start_log_watcher(event_queue: MonitoringQueue, use_polling: bool = False) -> Any: """ Start watching log files and pushing events to queue. @@ -478,6 +484,7 @@ def start_log_watcher(event_queue: MonitoringQueue, use_polling: bool = False) - # Create observer — polling fallback when inotify unavailable if use_polling: from watchdog.observers.polling import PollingObserver + observer = PollingObserver(timeout=1) logger.info("Log watcher using polling observer (1s interval)") else: @@ -520,7 +527,7 @@ def is_log_watcher_active() -> bool: # STANDALONE TEST # ============================================================================= -if __name__ == '__main__': +if __name__ == "__main__": """ Standalone test - starts log watcher and prints events from queue. @@ -555,25 +562,23 @@ if __name__ == '__main__': if event: # Format and display event - timestamp = event.timestamp.strftime('%H:%M:%S') + timestamp = event.timestamp.strftime("%H:%M:%S") # Color by level level_colors = { - 'error': 'red', - 'warning': 'yellow', - 'info': 'white', - 'debug': 'dim', + "error": "red", + "warning": "yellow", + "info": "white", + "debug": "dim", } - color = level_colors.get(event.level, 'white') + color = level_colors.get(event.level, "white") # Print event - if event.event_type == 'command': + if event.event_type == "command": console.print(f"\n[bold green]{event.message}[/bold green]\n") else: console.print( - f"[dim]{timestamp}[/dim] " - f"[cyan][{event.branch:>8}][/cyan] " - f"[{color}]{event.message}[/{color}]" + f"[dim]{timestamp}[/dim] [cyan][{event.branch:>8}][/cyan] [{color}]{event.message}[/{color}]" ) # Small delay to prevent CPU spinning diff --git a/src/aipass/prax/apps/handlers/monitoring/module_tracker.py b/src/aipass/prax/apps/handlers/monitoring/module_tracker.py index 0f8e7b59..c743269d 100644 --- a/src/aipass/prax/apps/handlers/monitoring/module_tracker.py +++ b/src/aipass/prax/apps/handlers/monitoring/module_tracker.py @@ -12,6 +12,7 @@ from typing import Dict, List from aipass.prax.apps.handlers.json import json_handler + class ModuleTracker: """Track active modules and their execution""" @@ -21,5 +22,6 @@ class ModuleTracker: self.max_history = 100 json_handler.log_operation("module_tracker_initialized", {"max_history": self.max_history}) + # Global instance tracker = ModuleTracker() diff --git a/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py b/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py index 550324e8..927d1c3d 100644 --- a/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py +++ b/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py @@ -35,20 +35,16 @@ MONITOR_IGNORE_PATTERNS = [ "*.pyc", "*.pyo", "*.pyd", - # Virtual environments (massive file count, not user code) ".venv", "venv", "env", - # Node.js (massive file count) "node_modules", "npm-debug.log", "yarn-error.log", - # Version control (Git manages its own changes) ".git", - # Backup directories (prevent circular monitoring) "backups", "backup/backups", @@ -56,17 +52,14 @@ MONITOR_IGNORE_PATTERNS = [ "system_snapshot", "versioned_backup", "deleted_branches", - # System logs (prevent feedback loop - log watcher handles these separately) "system_logs", "*.log", - # System backups and trash "TimeShift*", "timeshift*", ".local/share/Trash", "Trash", - # Linux system directories (not code, constant changes) ".cache", ".local", @@ -80,14 +73,12 @@ MONITOR_IGNORE_PATTERNS = [ ".backup", ".antigravity", ".gemini", - # IDE and editor directories (auto-generated, large) ".vscode/cli", ".vscode/extensions", ".vscode-server", ".idea", ".eclipse", - # Claude Code internal files (change constantly, not user code) ".claude/todos", ".claude/shell-snapshots", @@ -105,7 +96,6 @@ MONITOR_IGNORE_PATTERNS = [ ".last_diagnostics_file", ".serena/logs", ".code", - # Development cache/build directories ".npm", ".cargo", @@ -118,26 +108,22 @@ MONITOR_IGNORE_PATTERNS = [ "install", "lib", "bin", - # Application data ".thunderbird", ".wine", ".steam", ".zoom", - # User directories (not code) "Downloads", "Videos", "Pictures", "Dropbox", - # Large binary/image files "*.img", "*.iso", "*.vmdk", "*.vdi", "*.qcow2", - # Archive and compressed files "*.zip", "*.tar", @@ -146,10 +132,8 @@ MONITOR_IGNORE_PATTERNS = [ "*.rar", "*.7z", "*.whl", - # HuggingFace model cache (sentence-transformers etc.) "huggingface", - # Temporary files (includes Claude Code atomic writes: file.py.tmp.PID.TIMESTAMP) "*.tmp", "*.temp", @@ -157,7 +141,6 @@ MONITOR_IGNORE_PATTERNS = [ "*.swp", "*.swo", "*~", - # Operating system files ".DS_Store", "Thumbs.db", @@ -173,7 +156,6 @@ MONITOR_ALWAYS_PATTERNS = [ "*.local.json", "*.observations.json", "*.ai_mail.json", - # Configuration files "*_config.json", ".claude.json", @@ -181,23 +163,18 @@ MONITOR_ALWAYS_PATTERNS = [ ".commands.json", ".gitignore", ".gitattributes", - # Python source code "*.py", - # Documentation "README.md", "CLAUDE.md", "*.local.md", - # VS Code settings ".vscode/settings.json", ".vscode/settings.local.json", - # Templates (everything in templates is important) "templates/**", "*/templates/**", - # Marker files ".gitkeep", ] @@ -213,26 +190,14 @@ CONTENT_FILTER_PATTERNS = { "*.log": { "filter_mode": "errors_only", "show_patterns": ["ERROR", "CRITICAL", "WARNING", "Failed", "Exception"], - "description": "Only show error-level log entries" + "description": "Only show error-level log entries", }, - # Data JSON files: only show structural changes, not data updates - "*_data.json": { - "filter_mode": "structure_only", - "description": "Show only structural changes, not data updates" - }, - + "*_data.json": {"filter_mode": "structure_only", "description": "Show only structural changes, not data updates"}, # Registry JSON files: only show new/deleted keys - "*_registry.json": { - "filter_mode": "keys_only", - "description": "Show only new/deleted keys, not value changes" - }, - + "*_registry.json": {"filter_mode": "keys_only", "description": "Show only new/deleted keys, not value changes"}, # Snapshot files: summarize instead of full content - "snapshot_*.json": { - "filter_mode": "summary", - "description": "Show summary of changes, not full content" - }, + "snapshot_*.json": {"filter_mode": "summary", "description": "Show summary of changes, not full content"}, } # ============================================= @@ -244,28 +209,25 @@ CONTENT_FILTER_PATTERNS = { HIGHLIGHT_PATTERNS = { # CRITICAL: System-breaking changes "critical": [ - "*.id.json", # Branch identity - system core - "CLAUDE.md", # System instructions - ".gitignore", # Version control rules + "*.id.json", # Branch identity - system core + "CLAUDE.md", # System instructions + ".gitignore", # Version control rules "*_config.json deletion", # Config deletion is critical ], - # HIGH: Important files that should stand out "high": [ - "*.py deletion", # Source code deletion - "*.py creation", # New source code - "README.md", # Documentation - "*_config.json", # Configuration changes + "*.py deletion", # Source code deletion + "*.py creation", # New source code + "README.md", # Documentation + "*_config.json", # Configuration changes ], - # MEDIUM: Files worth noting "medium": [ - "*.local.json", # Session files - "*.observations.json", # Collaboration patterns - "*.py modification", # Source code edits - "*.md", # Documentation + "*.local.json", # Session files + "*.observations.json", # Collaboration patterns + "*.py modification", # Source code edits + "*.md", # Documentation ], - # LOW: Normal files (default) # Everything else not matched above } @@ -288,6 +250,7 @@ DEFAULT_EVENT_TYPES = EVENT_TYPES["all"] # HELPER FUNCTIONS # ============================================= + def should_monitor(path: Path) -> bool: """Check if path should be monitored @@ -314,13 +277,13 @@ def should_monitor(path: Path) -> bool: # Early exit: Claude Code atomic writes and backups (override ALWAYS patterns) # These contain .claude.json as substring, which would match the ALWAYS pattern - if '.claude.json.backup' in name or '.claude.json.tmp' in name: + if ".claude.json.backup" in name or ".claude.json.tmp" in name: return False # CLI session files: always monitor (override ignore patterns for .codex, .gemini) - if '.codex/sessions/' in path_str and name.endswith('.jsonl'): + if ".codex/sessions/" in path_str and name.endswith(".jsonl"): return True - if '.gemini/tmp/' in path_str and '/chats/' in path_str and name.endswith('.json'): + if ".gemini/tmp/" in path_str and "/chats/" in path_str and name.endswith(".json"): return True if _matches_always_patterns(path_str, parts, name): @@ -332,8 +295,7 @@ def should_monitor(path: Path) -> bool: return True -_PARTS_ONLY_IGNORE = {"backups", ".cache", ".git", "node_modules", - ".local", ".config", ".var", ".backup"} +_PARTS_ONLY_IGNORE = {"backups", ".cache", ".git", "node_modules", ".local", ".config", ".var", ".backup"} def _matches_always_patterns(path_str: str, parts: set, name: str) -> bool: @@ -343,7 +305,7 @@ def _matches_always_patterns(path_str: str, parts: set, name: str) -> bool: exception_parts = pattern.split("/**")[0] if exception_parts in path_str or exception_parts in "/".join(parts): return True - if pattern.startswith('*') and name.endswith(pattern[1:]): + if pattern.startswith("*") and name.endswith(pattern[1:]): return True if pattern == name or pattern in path_str: return True @@ -359,7 +321,7 @@ def _matches_ignore_patterns(path_str: str, parts: set, name: str) -> bool: continue if pattern == name: return True - if pattern.startswith('*') and name.endswith(pattern[1:]): + if pattern.startswith("*") and name.endswith(pattern[1:]): return True if pattern in parts or pattern in path_str: return True @@ -401,9 +363,9 @@ def _pattern_matches_event(pattern: str, name: str, path_str: str, event_type: s pattern_base, pattern_event = pattern.split(" ", 1) if event_type != pattern_event: return False - return (pattern_base.startswith('*') and name.endswith(pattern_base[1:])) or pattern_base == name + return (pattern_base.startswith("*") and name.endswith(pattern_base[1:])) or pattern_base == name - if pattern.startswith('*') and name.endswith(pattern[1:]): + if pattern.startswith("*") and name.endswith(pattern[1:]): return True return pattern == name or pattern in path_str @@ -428,8 +390,8 @@ def get_content_filter(path: Path) -> Optional[Dict[str, Any]]: # Check content filter patterns (case-insensitive) for pattern, config in CONTENT_FILTER_PATTERNS.items(): - bare = pattern.replace('*', '') - if pattern.startswith('*') and name_lower.endswith(pattern[1:]): + bare = pattern.replace("*", "") + if pattern.startswith("*") and name_lower.endswith(pattern[1:]): return config if pattern == name or bare in name_lower: return config @@ -437,9 +399,9 @@ def get_content_filter(path: Path) -> Optional[Dict[str, Any]]: return None -def filter_log_content(content: str, show_errors: bool = True, - show_warnings: bool = True, - show_info: bool = False) -> Optional[str]: +def filter_log_content( + content: str, show_errors: bool = True, show_warnings: bool = True, show_info: bool = False +) -> Optional[str]: """Filter log content based on level preferences Extracts relevant lines from log content based on user's level preferences. @@ -459,21 +421,19 @@ def filter_log_content(content: str, show_errors: bool = True, >>> filter_log_content(content, show_errors=True, show_info=False) "ERROR: Failed" """ - lines = content.split('\n') + lines = content.split("\n") filtered_lines = [] for line in lines: line_upper = line.upper() # Check for errors - if show_errors and any(kw in line_upper for kw in - ["ERROR", "CRITICAL", "EXCEPTION", "TRACEBACK", "FAILED"]): + if show_errors and any(kw in line_upper for kw in ["ERROR", "CRITICAL", "EXCEPTION", "TRACEBACK", "FAILED"]): filtered_lines.append(line) continue # Check for warnings - if show_warnings and any(kw in line_upper for kw in - ["WARNING", "WARN"]): + if show_warnings and any(kw in line_upper for kw in ["WARNING", "WARN"]): filtered_lines.append(line) continue @@ -484,14 +444,13 @@ def filter_log_content(content: str, show_errors: bool = True, # Return filtered content or None if nothing matched if filtered_lines: - return '\n'.join(filtered_lines) + return "\n".join(filtered_lines) return None -def apply_content_filter(path: Path, content: str, - show_errors: bool = True, - show_warnings: bool = True, - show_info: bool = False) -> Optional[str]: +def apply_content_filter( + path: Path, content: str, show_errors: bool = True, show_warnings: bool = True, show_info: bool = False +) -> Optional[str]: """Apply appropriate content filter based on file type Main entry point for content filtering. Checks if file has a content diff --git a/src/aipass/prax/apps/handlers/monitoring/unified_stream.py b/src/aipass/prax/apps/handlers/monitoring/unified_stream.py index b914678a..e79028f7 100644 --- a/src/aipass/prax/apps/handlers/monitoring/unified_stream.py +++ b/src/aipass/prax/apps/handlers/monitoring/unified_stream.py @@ -30,6 +30,7 @@ try: except ImportError as e: logger.info(f"[unified_stream] CLI console not available, falling back to rich.Console: {e}") from rich.console import Console + console = Console() # Thread safety @@ -37,31 +38,31 @@ _print_lock = Lock() # Color schemes by event type and level COLORS = { - 'file_created': 'green', - 'file_modified': 'yellow', - 'file_deleted': 'red', - 'file_moved': 'blue', - 'log_info': 'white', - 'log_warning': 'yellow', - 'log_error': 'red', - 'log_critical': 'bold red', - 'module_loaded': 'cyan', - 'module_error': 'red', - 'system_info': 'blue', - 'system_warning': 'yellow', - 'system_error': 'bold red', + "file_created": "green", + "file_modified": "yellow", + "file_deleted": "red", + "file_moved": "blue", + "log_info": "white", + "log_warning": "yellow", + "log_error": "red", + "log_critical": "bold red", + "module_loaded": "cyan", + "module_error": "red", + "system_info": "blue", + "system_warning": "yellow", + "system_error": "bold red", } # Symbols for different event types SYMBOLS = { - 'file': '📁', - 'log': '📝', - 'module': '⚡', - 'system': '🔧', - 'error': '❌', - 'warning': '⚠️', - 'success': '✅', - 'info': 'ℹ️', + "file": "📁", + "log": "📝", + "module": "⚡", + "system": "🔧", + "error": "❌", + "warning": "⚠️", + "success": "✅", + "info": "ℹ️", } # Branch display width — wide enough for three-tier labels like AIPASS/DEVPULSE/OPUS @@ -69,11 +70,11 @@ BRANCH_WIDTH = 24 # Level-based color mapping (simplified) LEVEL_COLORS = { - 'error': 'red', - 'warning': 'yellow', - 'critical': 'bold red', - 'info': 'white', - 'success': 'green', + "error": "red", + "warning": "yellow", + "critical": "bold red", + "info": "white", + "success": "green", } # Branch-specific colors for visual distinction. @@ -81,29 +82,29 @@ LEVEL_COLORS = { # Internal AIPass branches also appear as bare names for non-session events. BRANCH_COLORS = { # Internal AIPass branches — individually colored for non-session events - 'SEEDGO': 'green', - 'DRONE': 'cyan', - 'FLOW': 'blue', - 'PRAX': 'magenta', - 'CLI': 'yellow', - 'AI_MAIL': 'bright_cyan', - 'BACKUP': 'bright_green', - 'MEMORY': 'bright_magenta', - 'DEVPULSE': 'bright_yellow', - 'API': 'bright_red', - 'SECURITY': 'red', - 'TRIGGER': 'bright_red', - 'SPEAKEASY': 'bright_white', - 'THE_COMMONS': 'bright_green', - 'ASSISTANT': 'bright_yellow', + "SEEDGO": "green", + "DRONE": "cyan", + "FLOW": "blue", + "PRAX": "magenta", + "CLI": "yellow", + "AI_MAIL": "bright_cyan", + "BACKUP": "bright_green", + "MEMORY": "bright_magenta", + "DEVPULSE": "bright_yellow", + "API": "bright_red", + "SECURITY": "red", + "TRIGGER": "bright_red", + "SPEAKEASY": "bright_white", + "THE_COMMONS": "bright_green", + "ASSISTANT": "bright_yellow", # Project-level keys — used for three-tier labels (first segment of PROJECT/AGENT/MODEL) - 'AIPASS': 'bold white', # All internal AIPass agent sessions - 'AIPL': 'bright_blue', # AIPL external project - 'VERA-STUDIO': 'bright_cyan', # Vera Studio external project + "AIPASS": "bold white", # All internal AIPass agent sessions + "AIPL": "bright_blue", # AIPL external project + "VERA-STUDIO": "bright_cyan", # Vera Studio external project } -def print_event(event_type: str, branch: str, message: str, level: str = 'info', pid: Optional[int] = None): +def print_event(event_type: str, branch: str, message: str, level: str = "info", pid: Optional[int] = None): """ Format and print event with branch attribution @@ -131,10 +132,10 @@ def print_event(event_type: str, branch: str, message: str, level: str = 'info', # Get branch color (unique per branch) # Strip suffixes for color lookup: 'DEVPULSE AGENT' → DEVPULSE, 'DEVPULSE/opus' → DEVPULSE branch_upper = branch.upper() - base_branch = branch_upper.split('/')[0] # strip model tag - if base_branch.endswith(' AGENT'): + base_branch = branch_upper.split("/")[0] # strip model tag + if base_branch.endswith(" AGENT"): base_branch = base_branch[:-6] - branch_color = BRANCH_COLORS.get(base_branch, 'white') + branch_color = BRANCH_COLORS.get(base_branch, "white") # Format branch label with optional PID if pid: @@ -144,7 +145,7 @@ def print_event(event_type: str, branch: str, message: str, level: str = 'info', branch_formatted = f"[{branch_color}][{branch_label:<{BRANCH_WIDTH}}][/{branch_color}]" # Get message color based on level - msg_color = LEVEL_COLORS.get(level, 'white') + msg_color = LEVEL_COLORS.get(level, "white") # Format and print - timestamp, branch colored, message colored by level console.print(f"[dim]{timestamp}[/dim] {branch_formatted} [{msg_color}]{message}[/{msg_color}]") @@ -161,17 +162,17 @@ def print_command_separator(branch: str, command: str, caller: Optional[str] = N target: Branch being acted upon (optional, e.g. audit target) """ with _print_lock: - branch_color = BRANCH_COLORS.get(branch.upper(), 'white') + branch_color = BRANCH_COLORS.get(branch.upper(), "white") console.print() console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]") # Build context line: CALLER → TARGET context_parts = [] - if caller and caller.upper() != 'UNKNOWN': - caller_color = BRANCH_COLORS.get(caller.upper(), 'cyan') + if caller and caller.upper() != "UNKNOWN": + caller_color = BRANCH_COLORS.get(caller.upper(), "cyan") context_parts.append(f"[{caller_color}]{caller}[/{caller_color}]") if target: - target_color = BRANCH_COLORS.get(target.upper(), 'cyan') + target_color = BRANCH_COLORS.get(target.upper(), "cyan") if context_parts: context_parts.append(f"→ [{target_color}]{target}[/{target_color}]") else: @@ -184,7 +185,6 @@ def print_command_separator(branch: str, command: str, caller: Optional[str] = N console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]") - def print_status(watched_branches: List[str], verbosity: int, filters: Optional[Dict] = None): """ Display current monitoring status @@ -201,11 +201,11 @@ def print_status(watched_branches: List[str], verbosity: int, filters: Optional[ if filters: console.print(" Filters:") - if filters.get('file_types'): + if filters.get("file_types"): console.print(f" File types: {', '.join(filters['file_types'])}") - if filters.get('log_levels'): + if filters.get("log_levels"): console.print(f" Log levels: {', '.join(filters['log_levels'])}") - if filters.get('exclude_patterns'): + if filters.get("exclude_patterns"): console.print(f" Excluded: {', '.join(filters['exclude_patterns'])}") console.print() diff --git a/src/aipass/prax/apps/handlers/registry/load.py b/src/aipass/prax/apps/handlers/registry/load.py index ccd561bd..db1305e1 100755 --- a/src/aipass/prax/apps/handlers/registry/load.py +++ b/src/aipass/prax/apps/handlers/registry/load.py @@ -46,6 +46,7 @@ REGISTRY_FILE = PRAX_JSON_DIR / "prax_registry.json" # HANDLER FUNCTION # ============================================= + def load_module_registry() -> Dict[str, Dict[str, Any]]: """Load module registry from prax_registry.json (system registry) @@ -76,9 +77,9 @@ def load_module_registry() -> Dict[str, Dict[str, Any]]: return {} try: - with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + with open(REGISTRY_FILE, "r", encoding="utf-8") as f: data = json.load(f) - modules = data.get('modules', {}) + modules = data.get("modules", {}) json_handler.log_operation("registry_loaded", {"module_count": len(modules)}) return modules except Exception as e: diff --git a/src/aipass/prax/apps/handlers/registry/save.py b/src/aipass/prax/apps/handlers/registry/save.py index 5cd50326..30d6c3d7 100755 --- a/src/aipass/prax/apps/handlers/registry/save.py +++ b/src/aipass/prax/apps/handlers/registry/save.py @@ -48,6 +48,7 @@ REGISTRY_FILE = PRAX_JSON_DIR / "prax_registry.json" # HANDLER FUNCTION # ============================================= + def save_module_registry(modules: Dict[str, Dict[str, Any]]) -> bool: """Save module registry to prax_registry.json (system registry) @@ -87,12 +88,12 @@ def save_module_registry(modules: Dict[str, Dict[str, Any]]) -> bool: "statistics": { "total_modules": len(modules), "last_updated": datetime.now(timezone.utc).isoformat(), - "scan_location": str(ECOSYSTEM_ROOT) - } + "scan_location": str(ECOSYSTEM_ROOT), + }, } # Save to file - with open(REGISTRY_FILE, 'w', encoding='utf-8') as f: + with open(REGISTRY_FILE, "w", encoding="utf-8") as f: json.dump(registry_structure, f, indent=2, ensure_ascii=False) json_handler.log_operation("registry_saved", {"total_modules": len(modules)}) diff --git a/src/aipass/prax/apps/handlers/status/sync.py b/src/aipass/prax/apps/handlers/status/sync.py index 230bcc81..13b85d0d 100644 --- a/src/aipass/prax/apps/handlers/status/sync.py +++ b/src/aipass/prax/apps/handlers/status/sync.py @@ -49,6 +49,7 @@ def _extract_field(pattern: re.Pattern, text: str) -> str: # Core # --------------------------------------------------------------------------- + def sync_status() -> Dict: """ Scan all branches for STATUS.local.md, build central STATUS.md. @@ -135,9 +136,7 @@ def sync_status() -> Dict: ] for branch_email, state, last_update, content in entries: - lines.append( - f"
{branch_email} — {state} ({last_update})" - ) + lines.append(f"
{branch_email} — {state} ({last_update})") lines.append("") lines.append(content.rstrip()) lines.append("") @@ -147,9 +146,7 @@ def sync_status() -> Dict: status_md = repo_root / "STATUS.md" status_md.write_text("\n".join(lines) + "\n", encoding="utf-8") - logger.info( - "STATUS.md synced: %d branches, %d missing", len(synced), len(missing) - ) + logger.info("STATUS.md synced: %d branches, %d missing", len(synced), len(missing)) json_handler.log_operation("status_synced", {"branches_synced": len(synced), "branches_missing": len(missing)}) diff --git a/src/aipass/prax/apps/handlers/watcher/monitor.py b/src/aipass/prax/apps/handlers/watcher/monitor.py index eff02503..85e11542 100755 --- a/src/aipass/prax/apps/handlers/watcher/monitor.py +++ b/src/aipass/prax/apps/handlers/watcher/monitor.py @@ -29,19 +29,31 @@ try: from watchdog.observers import Observer # type: ignore from watchdog.events import FileSystemEventHandler # type: ignore from watchdog.events import FileSystemEvent # type: ignore + WATCHDOG_AVAILABLE = True except ImportError as e: logger.info(f"[monitor] watchdog not available, using placeholders: {e}") WATCHDOG_AVAILABLE = False + # Create placeholder classes for when watchdog not available class Observer: # type: ignore """Placeholder when watchdog unavailable.""" - def schedule(self, *args, **kwargs): return None - def start(self): return None - def stop(self): return None - def join(self): return None + + def schedule(self, *args, **kwargs): + return None + + def start(self): + return None + + def stop(self): + return None + + def join(self): + return None + class FileSystemEventHandler: # type: ignore """Placeholder when watchdog unavailable.""" + class FileSystemEvent: # type: ignore """Placeholder when watchdog unavailable.""" @@ -49,6 +61,7 @@ except ImportError as e: # EVENT HANDLER # ============================================================================= + class BranchFileHandler(FileSystemEventHandler): """ Custom file system event handler for branch monitoring @@ -80,36 +93,36 @@ class BranchFileHandler(FileSystemEventHandler): True if file should be ignored, False otherwise """ # Ignore log files (prevents infinite loop) - if path.endswith('.log'): + if path.endswith(".log"): return True # Ignore temporary and backup files - if '.tmp.' in path or path.endswith('.tmp'): + if ".tmp." in path or path.endswith(".tmp"): return True - if path.endswith('.backup') or path.endswith('.bak'): + if path.endswith(".backup") or path.endswith(".bak"): return True - if path.endswith('~'): # Editor backup files + if path.endswith("~"): # Editor backup files return True - if path.endswith('.swp') or path.endswith('.swo'): # Vim swap files + if path.endswith(".swp") or path.endswith(".swo"): # Vim swap files return True # Ignore log directories - if '/logs/' in path or '/system_logs/' in path: + if "/logs/" in path or "/system_logs/" in path: return True # Ignore system/config directories (prevents watching non-branch files) ignore_dirs = [ - '/.claude/', - '/.local/', - '/.cache/', - '/.config/', - '/.vscode/', - '/.git/', - '/__pycache__/', - '/.pytest_cache/', - '/node_modules/', - '/.venv/', - '/venv/' + "/.claude/", + "/.local/", + "/.cache/", + "/.config/", + "/.vscode/", + "/.git/", + "/__pycache__/", + "/.pytest_cache/", + "/node_modules/", + "/.venv/", + "/venv/", ] for ignore_dir in ignore_dirs: @@ -121,27 +134,29 @@ class BranchFileHandler(FileSystemEventHandler): def on_created(self, event) -> None: """File or directory created""" if not event.is_directory and not self._should_ignore(event.src_path): - self.callback(self.branch_name, 'CREATED', event.src_path) + self.callback(self.branch_name, "CREATED", event.src_path) def on_modified(self, event) -> None: """File or directory modified""" if not event.is_directory and not self._should_ignore(event.src_path): - self.callback(self.branch_name, 'MODIFIED', event.src_path) + self.callback(self.branch_name, "MODIFIED", event.src_path) def on_deleted(self, event) -> None: """File or directory deleted""" if not event.is_directory and not self._should_ignore(event.src_path): - self.callback(self.branch_name, 'DELETED', event.src_path) + self.callback(self.branch_name, "DELETED", event.src_path) def on_moved(self, event) -> None: """File or directory moved/renamed""" if not event.is_directory and not self._should_ignore(event.src_path): - self.callback(self.branch_name, 'MOVED', f"{event.src_path} → {event.dest_path}") + self.callback(self.branch_name, "MOVED", f"{event.src_path} → {event.dest_path}") + # ============================================================================= # MONITOR FUNCTIONS # ============================================================================= + def start_monitoring(branch_paths: List[tuple], callback: Callable) -> Any: """ Start monitoring multiple branch directories diff --git a/src/aipass/prax/apps/modules/dashboard.py b/src/aipass/prax/apps/modules/dashboard.py index f3607cf8..ef08de40 100644 --- a/src/aipass/prax/apps/modules/dashboard.py +++ b/src/aipass/prax/apps/modules/dashboard.py @@ -53,19 +53,11 @@ from aipass.prax.apps.handlers.dashboard.status import ( ) # Import refresh handler - exposed as public API -from aipass.prax.apps.handlers.dashboard.refresh import ( - refresh_all_dashboards, - refresh_single_dashboard -) +from aipass.prax.apps.handlers.dashboard.refresh import refresh_all_dashboards, refresh_single_dashboard # Import template handlers -from aipass.prax.apps.handlers.dashboard.template_pusher import ( - push_dashboard_template, - get_template_status -) -from aipass.prax.apps.handlers.dashboard.template_differ import ( - diff_dashboard_template -) +from aipass.prax.apps.handlers.dashboard.template_pusher import push_dashboard_template, get_template_status +from aipass.prax.apps.handlers.dashboard.template_differ import diff_dashboard_template from aipass.prax.apps.handlers.json import json_handler @@ -76,32 +68,16 @@ DASHBOARD_TEMPLATE = { "branch": "", "last_updated": "", "sections": { - "ai_mail": { - "managed_by": "ai_mail", - "new": 0, - "opened": 0, - "total": 0, - "last_updated": "" - }, - "flow": { - "managed_by": "flow", - "active_plans": 0, - "recently_closed": [], - "last_updated": "" - }, - "memory": { - "managed_by": "memory", - "vectors_stored": 0, - "notes": {}, - "last_updated": "" - }, + "ai_mail": {"managed_by": "ai_mail", "new": 0, "opened": 0, "total": 0, "last_updated": ""}, + "flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""}, + "memory": {"managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": ""}, "commons_activity": { "managed_by": "the_commons", "mentions": 0, "new_posts_since_last_visit": 0, "new_comments_since_last_visit": 0, - "last_updated": "" - } + "last_updated": "", + }, }, "quick_status": { "new_mail": 0, @@ -109,19 +85,15 @@ DASHBOARD_TEMPLATE = { "active_plans": 0, "commons_mentions": 0, "action_required": False, - "summary": "" - } + "summary": "", + }, } # ============================================ # MODULE-LEVEL WRAPPER FUNCTIONS # ============================================ -def update_section( - branch_path: Path, - section_name: str, - section_data: Dict -) -> bool: +def update_section(branch_path: Path, section_name: str, section_data: Dict) -> bool: """ Update a specific section in branch dashboard (legacy wrapper). @@ -139,11 +111,7 @@ def update_section( """ try: return handler_update_section( - branch_path, - section_name, - section_data, - DASHBOARD_TEMPLATE, - calculate_quick_status + branch_path, section_name, section_data, DASHBOARD_TEMPLATE, calculate_quick_status ) except Exception as e: logger.error(f"Failed to update section {section_name}: {e}") @@ -381,7 +349,7 @@ def _handle_diff_template(args: List[str]) -> None: result = diff_dashboard_template(branch_name=branch_name) if "error" in result: - error(result['error']) + error(result["error"]) return summary = result.get("summary", {}) @@ -391,7 +359,7 @@ def _handle_diff_template(args: List[str]) -> None: console.print(f" Needs update: {summary.get('needs_update', 0)}") console.print(f" Up to date: {summary.get('up_to_date', 0)}") console.print(f" Missing: {summary.get('missing', 0)}") - invalid_json_count = summary.get('invalid_json', 0) + invalid_json_count = summary.get("invalid_json", 0) if invalid_json_count: error(f"Invalid JSON: {invalid_json_count}") @@ -467,7 +435,7 @@ def handle_command(command: str, args: List[str]) -> bool: print_introspection() return True - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True diff --git a/src/aipass/prax/apps/modules/log_audit.py b/src/aipass/prax/apps/modules/log_audit.py index e4a155a2..b687b415 100644 --- a/src/aipass/prax/apps/modules/log_audit.py +++ b/src/aipass/prax/apps/modules/log_audit.py @@ -72,12 +72,12 @@ def _display_audit(files: list, summary: dict) -> None: console.print("[bold cyan]System Log Audit[/bold cyan]") console.print(f" Total files: {summary['total_files']}") console.print(f" Total lines: {summary['total_lines']:,}") - if summary.get('largest_file'): + if summary.get("largest_file"): console.print(f" Largest: {summary['largest_file']} ({summary.get('largest_lines', 0):,} lines)") else: console.print(" Largest: (no log files found)") - if summary['healthy']: + if summary["healthy"]: console.print("[green] Status: HEALTHY — all logs within limits[/green]") else: error(f"Status: {summary['oversized_count']} oversized, {summary['critical_count']} critical") @@ -109,14 +109,14 @@ def handle_command(command: str, args: List[str]) -> bool: Returns: True if command was handled """ - if command != 'log-audit': + if command != "log-audit": return False if not args: print_introspection() return True - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True @@ -128,13 +128,13 @@ def handle_command(command: str, args: List[str]) -> bool: subcmd = args[0] json_handler.log_operation("log_audit_executed", {"mode": subcmd}) - if subcmd == 'audit': + if subcmd == "audit": files = scan_log_files() summary = log_health_summary() _display_audit(files, summary) return True - if subcmd == 'enforce': + if subcmd == "enforce": _run_enforce() return True @@ -171,9 +171,9 @@ if __name__ == "__main__": print_introspection() sys.exit(0) - if '--help' in sys.argv: + if "--help" in sys.argv: print_help() sys.exit(0) - args = [arg for arg in sys.argv[1:] if not arg.startswith('--')] - handle_command('log-audit', args) + args = [arg for arg in sys.argv[1:] if not arg.startswith("--")] + handle_command("log-audit", args) diff --git a/src/aipass/prax/apps/modules/logger.py b/src/aipass/prax/apps/modules/logger.py index 16432dfa..d6700fde 100755 --- a/src/aipass/prax/apps/modules/logger.py +++ b/src/aipass/prax/apps/modules/logger.py @@ -57,20 +57,11 @@ from aipass.prax.apps.handlers.logging.setup import ( disable_terminal_output as _disable_terminal, ) from aipass.prax.apps.handlers.logging.introspection import get_caller_info -from aipass.prax.apps.handlers.logging.override import ( - is_override_active -) -from aipass.prax.apps.handlers.discovery.watcher import ( - start_file_watcher, - is_file_watcher_active -) +from aipass.prax.apps.handlers.logging.override import is_override_active +from aipass.prax.apps.handlers.discovery.watcher import start_file_watcher, is_file_watcher_active from aipass.prax.apps.handlers.registry.load import load_module_registry from aipass.prax.apps.handlers.config.load import get_system_logs_dir, get_module_logs_dir, PRAX_JSON_DIR -from aipass.prax.apps.handlers.logging.direct import ( - get_direct_logger, - direct_log, - DirectLogger -) +from aipass.prax.apps.handlers.logging.direct import get_direct_logger, direct_log, DirectLogger from aipass.prax.apps.handlers.json import json_handler # Module constants @@ -81,6 +72,7 @@ DATA_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_data.json" # SYSTEM LOGGER - THE MAIN EXPORT # ============================================= + def get_system_logger(): """Get logger that automatically routes to correct module log file. @@ -91,6 +83,7 @@ def get_system_logger(): module_name, caller_path, branch = get_caller_info() return setup_individual_logger(module_name, caller_path=caller_path, caller_branch=branch) + class SystemLogger: """Auto-routing logger that writes to calling module's log file""" @@ -117,7 +110,8 @@ class SystemLogger: # Fire startup event (trigger auto-initializes handlers) try: from aipass.trigger.apps.modules.core import trigger - trigger.fire('startup') + + trigger.fire("startup") except (ImportError, OSError) as e: logger.warning("Trigger startup fire skipped (not available or inotify full): %s", e) @@ -139,6 +133,7 @@ class SystemLogger: logger = get_system_logger() logger.error(message, *args, **kwargs) + # Export the logger object - this is what other branches import system_logger = SystemLogger() @@ -146,6 +141,7 @@ system_logger = SystemLogger() # LIFECYCLE FUNCTIONS # ============================================= + def initialize_logging_system(): """Initialize the complete logging system @@ -166,10 +162,8 @@ def initialize_logging_system(): result = run_initialize(MODULE_NAME) - console.print( - f"[{MODULE_NAME}] System initialized - " - f"{result['modules_count']} modules, individual logging" - ) + console.print(f"[{MODULE_NAME}] System initialized - {result['modules_count']} modules, individual logging") + def shutdown_logging_system(): """Shutdown logging system cleanly @@ -190,10 +184,12 @@ def shutdown_logging_system(): console.print(f"[{MODULE_NAME}] Shutdown complete") + # ============================================= # STATUS AND CONTROL # ============================================= + def get_system_status() -> Dict[str, Any]: """Get current logging system status @@ -215,13 +211,15 @@ def get_system_status() -> Dict[str, Any]: "module_logs_dir": str(get_module_logs_dir("prax")), "registry_file": str(DATA_FILE), "file_watcher_active": is_file_watcher_active(), - "logger_override_active": is_override_active() + "logger_override_active": is_override_active(), } + def enable_terminal_output(): """Enable terminal output for all future loggers""" _enable_terminal() + def disable_terminal_output(): """Disable terminal output""" _disable_terminal() @@ -234,6 +232,7 @@ def print_introspection(): except ImportError as e: logger.info("CLI console not available, using rich fallback: %s", e) from rich.console import Console + console = Console() console.print() @@ -274,7 +273,7 @@ def handle_command(_command: str, args: list) -> bool: if not args: print_introspection() return True - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_introspection() # Logger has no user commands — introspection IS the help return True return False diff --git a/src/aipass/prax/apps/modules/monitor.py b/src/aipass/prax/apps/modules/monitor.py index ae7407c9..60893480 100755 --- a/src/aipass/prax/apps/modules/monitor.py +++ b/src/aipass/prax/apps/modules/monitor.py @@ -36,10 +36,10 @@ from aipass.prax.apps.handlers.json import json_handler # Monitoring handlers (connected subsystems) from aipass.prax.apps.handlers.monitoring import ( - print_event, # unified_stream.py + print_event, # unified_stream.py print_command_separator, # unified_stream.py - command headers - MonitoringQueue, # event_queue.py - ModuleTracker, # module_tracker.py + MonitoringQueue, # event_queue.py + ModuleTracker, # module_tracker.py ) from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent from aipass.prax.apps.modules.monitor_info import print_introspection as _print_introspection, print_help @@ -79,6 +79,7 @@ def _refresh_pid_cache() -> None: """Scan dispatch lock files to build branch→PID mapping.""" global _pid_cache_last_refresh import time as _time + now = _time.time() with _pid_cache_lock: if now - _pid_cache_last_refresh < _PID_CACHE_TTL: @@ -87,6 +88,7 @@ def _refresh_pid_cache() -> None: try: from aipass.prax.apps.handlers.config.load import _find_repo_root + registry_path = _find_repo_root() / "AIPASS_REGISTRY.json" if not registry_path.exists(): return @@ -105,7 +107,7 @@ def _get_pid_for_branch(branch: str) -> Optional[int]: """Look up PID for a branch from the cache.""" _refresh_pid_cache() base = branch.upper() - if base.endswith(' AGENT'): + if base.endswith(" AGENT"): base = base[:-6] with _pid_cache_lock: return _pid_cache.get(base) @@ -133,6 +135,7 @@ def print_introspection(): # CORE COMMAND HANDLER (Required for auto-discovery) # ============================================================================= + def handle_command(command: str, args: List[str]) -> bool: """ Handle monitor command - required for auto-discovery by prax.py @@ -145,7 +148,7 @@ def handle_command(command: str, args: List[str]) -> bool: True if command was handled (command == "monitor") False if not our command (pass to next handler) """ - if command != 'monitor': + if command != "monitor": return False # Introspection gate — bare command shows module info @@ -154,13 +157,13 @@ def handle_command(command: str, args: List[str]) -> bool: return True # Help intercept - if args[0] in ('--help', '-h', 'help'): + if args[0] in ("--help", "-h", "help"): print_help() return True # Subcommand routing subcmd = args[0] - if subcmd == 'run': + if subcmd == "run": return _run_monitor(args[1:]) # Unknown subcommand @@ -251,11 +254,11 @@ def _render_event(event) -> None: """Render a single monitoring event to the console.""" branch_pid = _get_pid_for_branch(event.branch) - if event.event_type == 'command': - caller = getattr(event, 'caller', None) + if event.event_type == "command": + caller = getattr(event, "caller", None) target = None - if hasattr(event, 'action') and event.action and ':' in event.action: - parts = event.action.split(':', 1) + if hasattr(event, "action") and event.action and ":" in event.action: + parts = event.action.split(":", 1) if len(parts) == 2 and parts[1]: target = parts[1] print_command_separator(event.branch, event.message, caller, target) @@ -330,23 +333,31 @@ def _emit_watcher_event(level: str, message: str) -> None: """Push a monitoring event about watcher status to the queue.""" if not _event_queue: return - priority = 1 if level == 'error' else 2 - _event_queue.enqueue(MonitoringEvent( - priority=priority, event_type='log', branch='PRAX', - action=level, level=level, timestamp=datetime.now(), - message=message, - )) + priority = 1 if level == "error" else 2 + _event_queue.enqueue( + MonitoringEvent( + priority=priority, + event_type="log", + branch="PRAX", + action=level, + level=level, + timestamp=datetime.now(), + message=message, + ) + ) def _inotify_fix_message(err: OSError) -> str: """Return the correct sysctl fix for the specific inotify limit hit.""" import errno as _errno + if err.errno == _errno.ENOSPC: # Errno 28 — max_user_watches - return ("inotify watch limit reached (max_user_watches). " - "Fix: sudo sysctl -w fs.inotify.max_user_watches=524288") + return "inotify watch limit reached (max_user_watches). Fix: sudo sysctl -w fs.inotify.max_user_watches=524288" elif err.errno == _errno.EMFILE: # Errno 24 — max_user_instances - return ("inotify instance limit reached (max_user_instances). " - "Fix: sudo sysctl -w fs.inotify.max_user_instances=1024") + return ( + "inotify instance limit reached (max_user_instances). " + "Fix: sudo sysctl -w fs.inotify.max_user_instances=1024" + ) else: return f"inotify error ({err}). Check system inotify limits." @@ -368,10 +379,11 @@ def _start_observer_with_fallback(handler, watch_dirs): except OSError as e: fix_msg = _inotify_fix_message(e) logger.warning(f"[monitor] inotify unavailable: {e} — switching to polling") - _emit_watcher_event('warning', f"File watcher: {fix_msg} Using polling fallback (slower).") + _emit_watcher_event("warning", f"File watcher: {fix_msg} Using polling fallback (slower).") try: from watchdog.observers.polling import PollingObserver + observer = PollingObserver(timeout=2) for watch_dir, recursive in watch_dirs: observer.schedule(handler, str(watch_dir), recursive=recursive) @@ -380,7 +392,7 @@ def _start_observer_with_fallback(handler, watch_dirs): return observer except Exception as e2: logger.error(f"[monitor] Polling fallback also failed: {e2}") - _emit_watcher_event('error', "File watcher: completely unavailable — no file events") + _emit_watcher_event("error", "File watcher: completely unavailable — no file events") return None @@ -391,8 +403,8 @@ def _file_watcher_worker(): from aipass.prax.apps.handlers.monitoring.filesystem_handler import MonitoringFileHandler COMMAND_INDICATOR_FILES = { - 'standards_audit_log.json': 'seedgo audit', - 'standards_checklist_log.json': 'seedgo checklist', + "standards_audit_log.json": "seedgo audit", + "standards_checklist_log.json": "seedgo checklist", } handler = MonitoringFileHandler( @@ -401,12 +413,13 @@ def _file_watcher_worker(): ) from aipass.prax.apps.handlers.config.load import _find_repo_root + repo_root = _find_repo_root() watch_dirs = _get_watch_directories(repo_root) if not watch_dirs: logger.error("[monitor] No watch directories found — file watcher disabled") - _emit_watcher_event('warning', "File watcher: no watch directories found — file events disabled") + _emit_watcher_event("warning", "File watcher: no watch directories found — file events disabled") return logger.info(f"[monitor] File watcher: {len(watch_dirs)} watches scheduled") @@ -435,14 +448,14 @@ def _start_log_watcher_with_fallback(event_queue) -> bool: except OSError as e: fix_msg = _inotify_fix_message(e) logger.warning(f"[monitor] Log watcher inotify failed: {e} — switching to polling") - _emit_watcher_event('warning', f"Log watcher: {fix_msg} Using polling fallback (slower).") + _emit_watcher_event("warning", f"Log watcher: {fix_msg} Using polling fallback (slower).") try: start_log_watcher(event_queue, use_polling=True) return True except Exception as e2: logger.error(f"[monitor] Log watcher polling fallback failed: {e2}") - _emit_watcher_event('error', "Log watcher: completely unavailable — no log events") + _emit_watcher_event("error", "Log watcher: completely unavailable — no log events") return False @@ -468,10 +481,10 @@ def _log_watcher_worker(): def _handle_interactive_cmd(cmd: str, get_help_text) -> None: """Dispatch an interactive monitor command.""" - if cmd == 'help': + if cmd == "help": console.print(get_help_text()) return - if cmd == 'status': + if cmd == "status": _print_status() return error(f"Unknown command: {cmd}") @@ -493,10 +506,7 @@ def _interactive_loop(): console.print("\n[yellow]Stopping monitoring...[/yellow]") return - from aipass.prax.apps.handlers.monitoring.interactive_filter import ( - parse_command, - get_help_text - ) + from aipass.prax.apps.handlers.monitoring.interactive_filter import parse_command, get_help_text while not _stop_event.is_set(): try: @@ -508,7 +518,7 @@ def _interactive_loop(): if not cmd: continue - if cmd in ['quit', 'exit', 'q']: + if cmd in ["quit", "exit", "q"]: console.print("[yellow]Stopping monitoring...[/yellow]") break @@ -544,13 +554,10 @@ if __name__ == "__main__": sys.exit(0) # Parse command line arguments - parser = argparse.ArgumentParser( - description="PRAX Unified Monitoring - Mission Control", - add_help=False - ) - parser.add_argument('--help', action='store_true', help='Show help message') - parser.add_argument('--introspect', action='store_true', help='Show module introspection') - parser.add_argument('branches', nargs='?', help='Branches to monitor (comma-separated)') + parser = argparse.ArgumentParser(description="PRAX Unified Monitoring - Mission Control", add_help=False) + parser.add_argument("--help", action="store_true", help="Show help message") + parser.add_argument("--introspect", action="store_true", help="Show module introspection") + parser.add_argument("branches", nargs="?", help="Branches to monitor (comma-separated)") args = parser.parse_args() @@ -569,5 +576,5 @@ if __name__ == "__main__": _cmd_args = [args.branches] # Execute monitor command - handled = handle_command('monitor', _cmd_args) + handled = handle_command("monitor", _cmd_args) sys.exit(0 if handled else 1) diff --git a/src/aipass/prax/apps/modules/status.py b/src/aipass/prax/apps/modules/status.py index a039613c..bcec1773 100755 --- a/src/aipass/prax/apps/modules/status.py +++ b/src/aipass/prax/apps/modules/status.py @@ -42,7 +42,7 @@ def handle_command(command: str, args: List[str]) -> bool: Returns: True if command was handled """ - if command != 'status': + if command != "status": return False # --- sub-command routing ------------------------------------------------ diff --git a/src/aipass/prax/apps/prax.py b/src/aipass/prax/apps/prax.py index ae44f145..3c26ccdb 100755 --- a/src/aipass/prax/apps/prax.py +++ b/src/aipass/prax/apps/prax.py @@ -31,6 +31,7 @@ from aipass.cli.apps.modules import console, error, warning # MODULE DISCOVERY # ============================================================================= + def discover_command_modules() -> List[Callable]: """ Auto-discover command modules from modules/ directory @@ -50,7 +51,7 @@ def discover_command_modules() -> List[Callable]: # Scan for Python files in modules directory for module_file in modules_dir.glob("*.py"): # Skip __init__.py and non-command modules - if module_file.name.startswith('_') or module_file.name == 'logger.py': + if module_file.name.startswith("_") or module_file.name == "logger.py": continue try: @@ -59,7 +60,7 @@ def discover_command_modules() -> List[Callable]: module = importlib.import_module(module_name) # Check for handle_command interface - if hasattr(module, 'handle_command'): + if hasattr(module, "handle_command"): command_handlers.append(module.handle_command) except Exception as e: @@ -68,10 +69,12 @@ def discover_command_modules() -> List[Callable]: return command_handlers + # ============================================================================= # INTROSPECTION DISPLAY # ============================================================================= + def print_introspection(): """Display discovered modules (main entry point - modules only, no handlers)""" console.print() @@ -86,7 +89,7 @@ def print_introspection(): if modules_dir.exists(): for module_file in modules_dir.glob("*.py"): - if module_file.name.startswith('_') or module_file.name == 'logger.py': + if module_file.name.startswith("_") or module_file.name == "logger.py": continue discovered_modules.append(module_file.stem) @@ -131,6 +134,7 @@ def print_help(): # COMMAND ROUTING # ============================================================================= + def route_command(command: str, args: List[str], handlers: List[Callable]) -> bool: """ Route command to appropriate module handler @@ -154,14 +158,16 @@ def route_command(command: str, args: List[str], handlers: List[Callable]) -> bo return False + # ============================================================================= # MAIN # ============================================================================= + def main(): """Main entry point""" parser = argparse.ArgumentParser( - description='PRAX - System-Wide Logging Infrastructure', + description="PRAX - System-Wide Logging Infrastructure", formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False, # We route --help to modules when a command is given epilog=""" @@ -175,22 +181,17 @@ Examples: drone @prax monitor run drone @prax status drone @prax dashboard - """ + """, ) # Add command argument (optional) - parser.add_argument('command', - nargs='?', - help='Command to execute') + parser.add_argument("command", nargs="?", help="Command to execute") # Add remaining arguments for command handlers - parser.add_argument('args', - nargs='*', - help='Arguments for the command') + parser.add_argument("args", nargs="*", help="Arguments for the command") - parser.add_argument('--help', '-h', action='store_true', dest='show_help', - help='Show help information') - parser.add_argument('--version', '-V', action='version', version='PRAX v2.0.0') + parser.add_argument("--help", "-h", action="store_true", dest="show_help", help="Show help information") + parser.add_argument("--version", "-V", action="version", version="PRAX v2.0.0") parsed_args, remaining = parser.parse_known_args() @@ -215,7 +216,7 @@ Examples: # Pass --help through to module handler (e.g. drone @prax monitor --help) if parsed_args.show_help: - all_args = ['--help'] + all_args + all_args = ["--help"] + all_args # Route command to appropriate handler if route_command(parsed_args.command, all_args, handlers): @@ -224,6 +225,7 @@ Examples: error(f"Unknown command: {parsed_args.command}") return 1 + if __name__ == "__main__": try: sys.exit(main()) diff --git a/src/aipass/prax/tests/conftest.py b/src/aipass/prax/tests/conftest.py index f7a9d774..2210b44a 100644 --- a/src/aipass/prax/tests/conftest.py +++ b/src/aipass/prax/tests/conftest.py @@ -11,6 +11,7 @@ Provides infrastructure mocking so test modules can import prax code without triggering real logging, file watching, or CLI dependencies. """ + import os import tempfile @@ -30,6 +31,7 @@ collect_ignore_glob = [".archive/*"] # INFRASTRUCTURE MOCKS # ============================================= + @pytest.fixture(autouse=True) def mock_prax_infrastructure(monkeypatch): """Mock heavy prax infrastructure before any prax imports. diff --git a/src/aipass/prax/tests/test_central.py b/src/aipass/prax/tests/test_central.py index 77b2ba58..777b2d9c 100644 --- a/src/aipass/prax/tests/test_central.py +++ b/src/aipass/prax/tests/test_central.py @@ -20,6 +20,7 @@ import sys # HELPERS # ============================================= + def _fresh_import_reader(monkeypatch, tmp_path): """Import reader module with a fresh state, patching _find_repo_root to tmp_path. @@ -42,6 +43,7 @@ def _fresh_import_reader(monkeypatch, tmp_path): # TESTS: read_all_centrals # ============================================= + class TestReadAllCentrals: """Tests for read_all_centrals().""" @@ -71,9 +73,7 @@ class TestReadAllCentrals: central_dir.mkdir() payload = {"status": "active", "version": "1.0.0"} - (central_dir / "AI_MAIL.central.json").write_text( - json.dumps(payload), encoding="utf-8" - ) + (central_dir / "AI_MAIL.central.json").write_text(json.dumps(payload), encoding="utf-8") result = reader.read_all_centrals() assert "ai_mail" in result @@ -91,9 +91,7 @@ class TestReadAllCentrals: "DEVPULSE": {"type": "monitor", "uptime": 99.9}, } for name, data in services.items(): - (central_dir / f"{name}.central.json").write_text( - json.dumps(data), encoding="utf-8" - ) + (central_dir / f"{name}.central.json").write_text(json.dumps(data), encoding="utf-8") result = reader.read_all_centrals() assert len(result) == 3 @@ -107,9 +105,7 @@ class TestReadAllCentrals: central_dir = tmp_path / ".ai_central" central_dir.mkdir() - (central_dir / "MyService.central.json").write_text( - json.dumps({"ok": True}), encoding="utf-8" - ) + (central_dir / "MyService.central.json").write_text(json.dumps({"ok": True}), encoding="utf-8") result = reader.read_all_centrals() assert "myservice" in result @@ -121,29 +117,21 @@ class TestReadAllCentrals: central_dir = tmp_path / ".ai_central" central_dir.mkdir() - (central_dir / "BAD.central.json").write_text( - "{not valid json!!", encoding="utf-8" - ) + (central_dir / "BAD.central.json").write_text("{not valid json!!", encoding="utf-8") result = reader.read_all_centrals() assert "bad" not in result assert result == {} - def test_malformed_file_does_not_block_valid_files( - self, mock_prax_infrastructure, monkeypatch, tmp_path - ): + def test_malformed_file_does_not_block_valid_files(self, mock_prax_infrastructure, monkeypatch, tmp_path): """A broken file should not prevent other valid files from loading.""" reader = _fresh_import_reader(monkeypatch, tmp_path) central_dir = tmp_path / ".ai_central" central_dir.mkdir() good_data = {"healthy": True} - (central_dir / "GOOD.central.json").write_text( - json.dumps(good_data), encoding="utf-8" - ) - (central_dir / "BAD.central.json").write_text( - "<<>>", encoding="utf-8" - ) + (central_dir / "GOOD.central.json").write_text(json.dumps(good_data), encoding="utf-8") + (central_dir / "BAD.central.json").write_text("<<>>", encoding="utf-8") result = reader.read_all_centrals() assert len(result) == 1 @@ -157,14 +145,10 @@ class TestReadAllCentrals: central_dir.mkdir() # A valid central file - (central_dir / "VALID.central.json").write_text( - json.dumps({"ok": True}), encoding="utf-8" - ) + (central_dir / "VALID.central.json").write_text(json.dumps({"ok": True}), encoding="utf-8") # Files that should NOT be picked up (central_dir / "notes.txt").write_text("just a note", encoding="utf-8") - (central_dir / "config.json").write_text( - json.dumps({"nope": True}), encoding="utf-8" - ) + (central_dir / "config.json").write_text(json.dumps({"nope": True}), encoding="utf-8") result = reader.read_all_centrals() assert len(result) == 1 @@ -176,9 +160,7 @@ class TestReadAllCentrals: central_dir = tmp_path / ".ai_central" central_dir.mkdir() - (central_dir / "BROKEN.central.json").write_text( - "not json", encoding="utf-8" - ) + (central_dir / "BROKEN.central.json").write_text("not json", encoding="utf-8") reader.read_all_centrals() reader.logger.warning.assert_called() # type: ignore[union-attr] @@ -189,9 +171,7 @@ class TestReadAllCentrals: central_dir = tmp_path / ".ai_central" central_dir.mkdir() - (central_dir / "SVC.central.json").write_text( - json.dumps({"ok": True}), encoding="utf-8" - ) + (central_dir / "SVC.central.json").write_text(json.dumps({"ok": True}), encoding="utf-8") reader.read_all_centrals() reader.json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] @@ -204,9 +184,7 @@ class TestReadAllCentrals: central_dir = tmp_path / ".ai_central" central_dir.mkdir() - (central_dir / "EMPTY.central.json").write_text( - json.dumps({}), encoding="utf-8" - ) + (central_dir / "EMPTY.central.json").write_text(json.dumps({}), encoding="utf-8") result = reader.read_all_centrals() assert "empty" in result @@ -218,25 +196,14 @@ class TestReadAllCentrals: central_dir = tmp_path / ".ai_central" central_dir.mkdir() - nested = { - "level1": { - "level2": { - "items": [1, 2, 3], - "flag": True - } - } - } - (central_dir / "NESTED.central.json").write_text( - json.dumps(nested), encoding="utf-8" - ) + nested = {"level1": {"level2": {"items": [1, 2, 3], "flag": True}}} + (central_dir / "NESTED.central.json").write_text(json.dumps(nested), encoding="utf-8") result = reader.read_all_centrals() assert result["nested"] == nested assert result["nested"]["level1"]["level2"]["items"] == [1, 2, 3] - def test_no_json_handler_call_when_dir_missing( - self, mock_prax_infrastructure, monkeypatch, tmp_path - ): + def test_no_json_handler_call_when_dir_missing(self, mock_prax_infrastructure, monkeypatch, tmp_path): """When directory is missing, should return early without calling json_handler.""" reader = _fresh_import_reader(monkeypatch, tmp_path) # No .ai_central directory diff --git a/src/aipass/prax/tests/test_config.py b/src/aipass/prax/tests/test_config.py index c31bcce3..bab000d3 100644 --- a/src/aipass/prax/tests/test_config.py +++ b/src/aipass/prax/tests/test_config.py @@ -20,6 +20,7 @@ from pathlib import Path # HELPERS # ============================================= + def _fresh_import_load(monkeypatch, tmp_path): """Import load module with a fresh state and patched paths. @@ -75,6 +76,7 @@ def _fresh_import_ignore(monkeypatch, tmp_path): # TESTS: get_system_logs_dir # ============================================= + class TestGetSystemLogsDir: """Tests for get_system_logs_dir().""" @@ -117,6 +119,7 @@ class TestGetSystemLogsDir: # TESTS: get_module_logs_dir # ============================================= + class TestGetModuleLogsDir: """Tests for get_module_logs_dir(module_name).""" @@ -182,6 +185,7 @@ class TestGetModuleLogsDir: # TESTS: lines_to_bytes # ============================================= + class TestLinesToBytes: """Tests for lines_to_bytes(num_lines, avg_line_length).""" @@ -215,6 +219,7 @@ class TestLinesToBytes: # TESTS: get_debug_prints_enabled # ============================================= + class TestGetDebugPrintsEnabled: """Tests for get_debug_prints_enabled().""" @@ -232,18 +237,14 @@ class TestGetDebugPrintsEnabled: load_mod = _fresh_import_load(monkeypatch, tmp_path) config_file = load_mod.PRAX_LOGGER_CONFIG_FILE config_file.parent.mkdir(parents=True, exist_ok=True) - config_file.write_text(json.dumps({ - "config": {"debug_prints_enabled": True} - }), encoding="utf-8") + config_file.write_text(json.dumps({"config": {"debug_prints_enabled": True}}), encoding="utf-8") assert load_mod.get_debug_prints_enabled() is True def test_false_when_disabled_in_config(self, mock_prax_infrastructure, monkeypatch, tmp_path): load_mod = _fresh_import_load(monkeypatch, tmp_path) config_file = load_mod.PRAX_LOGGER_CONFIG_FILE config_file.parent.mkdir(parents=True, exist_ok=True) - config_file.write_text(json.dumps({ - "config": {"debug_prints_enabled": False} - }), encoding="utf-8") + config_file.write_text(json.dumps({"config": {"debug_prints_enabled": False}}), encoding="utf-8") assert load_mod.get_debug_prints_enabled() is False def test_false_when_key_missing(self, mock_prax_infrastructure, monkeypatch, tmp_path): @@ -272,6 +273,7 @@ class TestGetDebugPrintsEnabled: # TESTS: load_log_config # ============================================= + class TestLoadLogConfig: """Tests for load_log_config().""" @@ -302,18 +304,10 @@ class TestLoadLogConfig: config_file.parent.mkdir(parents=True, exist_ok=True) config_data = { "config": { - "system_logs": { - "max_lines": 2000, - "backup_count": 3, - "log_level": "DEBUG" - }, - "local_logs": { - "max_lines": 500, - "backup_count": 2, - "log_level": "WARNING" - }, + "system_logs": {"max_lines": 2000, "backup_count": 3, "log_level": "DEBUG"}, + "local_logs": {"max_lines": 500, "backup_count": 2, "log_level": "WARNING"}, "log_format": "%(message)s", - "date_format": "%H:%M:%S" + "date_format": "%H:%M:%S", } } config_file.write_text(json.dumps(config_data), encoding="utf-8") @@ -342,15 +336,7 @@ class TestLoadLogConfig: config_file = load_mod.PRAX_LOGGER_CONFIG_FILE config_file.parent.mkdir(parents=True, exist_ok=True) # Config with only system_logs — local_logs should fall back to default - config_data = { - "config": { - "system_logs": { - "max_lines": 3000, - "backup_count": 5, - "log_level": "ERROR" - } - } - } + config_data = {"config": {"system_logs": {"max_lines": 3000, "backup_count": 5, "log_level": "ERROR"}}} config_file.write_text(json.dumps(config_data), encoding="utf-8") result = load_mod.load_log_config() @@ -378,6 +364,7 @@ class TestLoadLogConfig: # TESTS: load_ignore_patterns_from_config # ============================================= + class TestLoadIgnorePatternsFromConfig: """Tests for load_ignore_patterns_from_config().""" @@ -403,11 +390,7 @@ class TestLoadIgnorePatternsFromConfig: ip_mod = _fresh_import_ignore(monkeypatch, tmp_path) config_file = ip_mod.PRAX_LOGGER_CONFIG_FILE config_file.parent.mkdir(parents=True, exist_ok=True) - config_data = { - "config": { - "ignore_patterns": ["custom_dir", "another_dir", ".hidden"] - } - } + config_data = {"config": {"ignore_patterns": ["custom_dir", "another_dir", ".hidden"]}} config_file.write_text(json.dumps(config_data), encoding="utf-8") result = ip_mod.load_ignore_patterns_from_config() @@ -418,11 +401,7 @@ class TestLoadIgnorePatternsFromConfig: ip_mod = _fresh_import_ignore(monkeypatch, tmp_path) config_file = ip_mod.PRAX_LOGGER_CONFIG_FILE config_file.parent.mkdir(parents=True, exist_ok=True) - config_data = { - "config": { - "ignore_patterns": ["a", "b", "a"] - } - } + config_data = {"config": {"ignore_patterns": ["a", "b", "a"]}} config_file.write_text(json.dumps(config_data), encoding="utf-8") result = ip_mod.load_ignore_patterns_from_config() diff --git a/src/aipass/prax/tests/test_discovery.py b/src/aipass/prax/tests/test_discovery.py index 54e0addd..50a5ddcf 100644 --- a/src/aipass/prax/tests/test_discovery.py +++ b/src/aipass/prax/tests/test_discovery.py @@ -24,13 +24,12 @@ import pytest # FIXTURES # ============================================= + @pytest.fixture() def mock_ignore_patterns(monkeypatch): """Mock the ignore_patterns config module in sys.modules.""" mock_mod = MagicMock() - mock_mod.load_ignore_patterns_from_config = MagicMock( - return_value={'.git', '__pycache__', '.venv', 'node_modules'} - ) + mock_mod.load_ignore_patterns_from_config = MagicMock(return_value={".git", "__pycache__", ".venv", "node_modules"}) monkeypatch.setitem( sys.modules, "aipass.prax.apps.handlers.config.ignore_patterns", @@ -45,12 +44,8 @@ def mock_config_load(monkeypatch, tmp_path): mock_mod = MagicMock() mock_mod.PRAX_ROOT = tmp_path / "prax" mock_mod.ECOSYSTEM_ROOT = tmp_path - mock_mod.get_system_logs_dir = MagicMock( - return_value=tmp_path / "system_logs" - ) - mock_mod.get_module_logs_dir = MagicMock( - return_value=tmp_path / "logs" - ) + mock_mod.get_system_logs_dir = MagicMock(return_value=tmp_path / "system_logs") + mock_mod.get_module_logs_dir = MagicMock(return_value=tmp_path / "logs") monkeypatch.setitem( sys.modules, "aipass.prax.apps.handlers.config.load", @@ -66,6 +61,7 @@ def filtering_module(mock_ignore_patterns, mock_prax_infrastructure): if mod_name in sys.modules: return importlib.reload(sys.modules[mod_name]) import aipass.prax.apps.handlers.discovery.filtering as mod + return mod @@ -81,6 +77,7 @@ def scanner_module(mock_ignore_patterns, mock_config_load, mock_prax_infrastruct if mod_name in sys.modules: return importlib.reload(sys.modules[mod_name]) import aipass.prax.apps.handlers.discovery.scanner as mod + return mod @@ -88,6 +85,7 @@ def scanner_module(mock_ignore_patterns, mock_config_load, mock_prax_infrastruct # should_ignore_path TESTS # ============================================= + class TestShouldIgnorePath: """Tests for filtering.should_ignore_path.""" @@ -146,6 +144,7 @@ class TestShouldIgnorePath: # scan_directory_safely TESTS # ============================================= + class TestScanDirectorySafely: """Tests for scanner.scan_directory_safely.""" @@ -233,9 +232,14 @@ class TestScanDirectorySafely: assert "mymod" in modules meta = modules["mymod"] expected_keys = { - "file_path", "relative_path", "system_log_file", - "log_file", "discovered_time", "size", - "modified_time", "enabled", + "file_path", + "relative_path", + "system_log_file", + "log_file", + "discovered_time", + "size", + "modified_time", + "enabled", } assert expected_keys.issubset(meta.keys()) assert meta["enabled"] is True @@ -260,6 +264,7 @@ class TestScanDirectorySafely: # discover_python_modules TESTS # ============================================= + class TestDiscoverPythonModules: """Tests for scanner.discover_python_modules.""" diff --git a/src/aipass/prax/tests/test_event_queue.py b/src/aipass/prax/tests/test_event_queue.py index 03516a84..800491aa 100644 --- a/src/aipass/prax/tests/test_event_queue.py +++ b/src/aipass/prax/tests/test_event_queue.py @@ -19,10 +19,12 @@ import pytest # MODULE LOADING # ============================================= + @pytest.fixture def event_queue_module(mock_prax_infrastructure): """Force-reload event_queue after sys.modules mocks are in place.""" import aipass.prax.apps.handlers.monitoring.event_queue as mod + mod = importlib.reload(mod) return mod @@ -134,9 +136,7 @@ class TestMonitoringEvent: ] sorted_events = sorted(events) assert [e.priority for e in sorted_events] == [1, 2, 3, 4] - assert [e.event_type for e in sorted_events] == [ - "error_event", "warning_event", "info_event", "debug_event" - ] + assert [e.event_type for e in sorted_events] == ["error_event", "warning_event", "info_event", "debug_event"] def test_timestamp_default_is_close_to_now(self, MonitoringEvent): """Default timestamp is approximately datetime.now().""" @@ -239,9 +239,7 @@ class TestMonitoringQueue: """Flush empties the queue and returns nothing (size goes to 0).""" q = MonitoringQueue() for i in range(5): - q.enqueue(MonitoringEvent( - priority=i + 1, event_type="file", branch=f"B{i}", action="a", message=f"msg{i}" - )) + q.enqueue(MonitoringEvent(priority=i + 1, event_type="file", branch=f"B{i}", action="a", message=f"msg{i}")) assert q.size() == 5 q.flush() assert q.size() == 0 @@ -293,12 +291,15 @@ class TestMonitoringQueue: q = MonitoringQueue() ts = datetime.now() event1 = MonitoringEvent( - priority=1, timestamp=ts, event_type="file", - branch="PRAX", action="modified", message="config changed" + priority=1, timestamp=ts, event_type="file", branch="PRAX", action="modified", message="config changed" ) event2 = MonitoringEvent( - priority=1, timestamp=ts + timedelta(milliseconds=500), event_type="file", - branch="PRAX", action="modified", message="config changed" + priority=1, + timestamp=ts + timedelta(milliseconds=500), + event_type="file", + branch="PRAX", + action="modified", + message="config changed", ) assert q.enqueue(event1) is True assert q.enqueue(event2) is False @@ -309,12 +310,15 @@ class TestMonitoringQueue: q = MonitoringQueue() ts = datetime.now() event1 = MonitoringEvent( - priority=1, timestamp=ts, event_type="file", - branch="PRAX", action="modified", message="first change" + priority=1, timestamp=ts, event_type="file", branch="PRAX", action="modified", message="first change" ) event2 = MonitoringEvent( - priority=1, timestamp=ts + timedelta(milliseconds=100), event_type="file", - branch="PRAX", action="modified", message="second change" + priority=1, + timestamp=ts + timedelta(milliseconds=100), + event_type="file", + branch="PRAX", + action="modified", + message="second change", ) assert q.enqueue(event1) is True assert q.enqueue(event2) is True @@ -325,12 +329,15 @@ class TestMonitoringQueue: q = MonitoringQueue() ts = datetime.now() event1 = MonitoringEvent( - priority=1, timestamp=ts, event_type="log", - branch="DRONE", action="created", message="log entry" + priority=1, timestamp=ts, event_type="log", branch="DRONE", action="created", message="log entry" ) event2 = MonitoringEvent( - priority=1, timestamp=ts + timedelta(seconds=2), event_type="log", - branch="DRONE", action="created", message="log entry" + priority=1, + timestamp=ts + timedelta(seconds=2), + event_type="log", + branch="DRONE", + action="created", + message="log entry", ) assert q.enqueue(event1) is True assert q.enqueue(event2) is True @@ -356,15 +363,29 @@ class TestMonitoringQueue: """A queue with maxsize=2 rejects the third enqueue.""" q = MonitoringQueue(maxsize=2) base_ts = datetime.now() - r1 = q.enqueue(MonitoringEvent( - priority=1, timestamp=base_ts, event_type="a", branch="A", action="x", message="o1" - )) - r2 = q.enqueue(MonitoringEvent( - priority=2, timestamp=base_ts + timedelta(seconds=2), event_type="b", branch="B", action="y", message="o2" - )) - r3 = q.enqueue(MonitoringEvent( - priority=3, timestamp=base_ts + timedelta(seconds=4), event_type="c", branch="C", action="z", message="o3" - )) + r1 = q.enqueue( + MonitoringEvent(priority=1, timestamp=base_ts, event_type="a", branch="A", action="x", message="o1") + ) + r2 = q.enqueue( + MonitoringEvent( + priority=2, + timestamp=base_ts + timedelta(seconds=2), + event_type="b", + branch="B", + action="y", + message="o2", + ) + ) + r3 = q.enqueue( + MonitoringEvent( + priority=3, + timestamp=base_ts + timedelta(seconds=4), + event_type="c", + branch="C", + action="z", + message="o3", + ) + ) assert r1 is True assert r2 is True assert r3 is False @@ -433,18 +454,18 @@ class TestMonitoringQueue: def test_enqueue_after_flush(self, MonitoringQueue, MonitoringEvent): """After a flush, the queue still accepts new events normally.""" q = MonitoringQueue() - q.enqueue(MonitoringEvent( - priority=1, event_type="file", branch="A", action="x", message="before_flush" - )) + q.enqueue(MonitoringEvent(priority=1, event_type="file", branch="A", action="x", message="before_flush")) assert q.size() == 1 q.flush() assert q.size() == 0 # Enqueue after flush should still work (queue not stopped, just cleared) base_ts = datetime.now() + timedelta(seconds=5) - result = q.enqueue(MonitoringEvent( - priority=2, timestamp=base_ts, event_type="log", branch="B", action="y", message="after_flush" - )) + result = q.enqueue( + MonitoringEvent( + priority=2, timestamp=base_ts, event_type="log", branch="B", action="y", message="after_flush" + ) + ) assert result is True assert q.size() == 1 diff --git a/src/aipass/prax/tests/test_json_handler.py b/src/aipass/prax/tests/test_json_handler.py index a009b456..eb5d1ab5 100644 --- a/src/aipass/prax/tests/test_json_handler.py +++ b/src/aipass/prax/tests/test_json_handler.py @@ -20,6 +20,7 @@ from unittest.mock import MagicMock # FIXTURES # ============================================= + @pytest.fixture def sample_test_data(): """Provide sample_data for json handler tests.""" @@ -38,6 +39,7 @@ def cleanup_temp(tmp_path): yield created # teardown — clean up created files import shutil + for p in created: if Path(p).exists(): if Path(p).is_dir(): @@ -78,13 +80,16 @@ def json_handler_module(mock_prax_infrastructure, tmp_path, monkeypatch): # Provide real functions with patched paths from types import ModuleType + real_mod = ModuleType("json_handler_test") - real_mod.__dict__.update({ - "json": json, - "Path": Path, - "PRAX_JSON_DIR": mod.PRAX_JSON_DIR, - "JSON_TEMPLATES_DIR": mod.JSON_TEMPLATES_DIR, - }) + real_mod.__dict__.update( + { + "json": json, + "Path": Path, + "PRAX_JSON_DIR": mod.PRAX_JSON_DIR, + "JSON_TEMPLATES_DIR": mod.JSON_TEMPLATES_DIR, + } + ) return mod @@ -93,6 +98,7 @@ def json_handler_module(mock_prax_infrastructure, tmp_path, monkeypatch): # JSON HANDLER: load_template / default_factory # ============================================= + def test_load_template_returns_config(json_handler_module, tmp_path): """load_template returns populated template — covers _create_default / default_factory.""" template_dir = json_handler_module.JSON_TEMPLATES_DIR / "default" @@ -113,6 +119,7 @@ def test_load_template_returns_config(json_handler_module, tmp_path): # JSON HANDLER: validate_json_structure # ============================================= + def test_validate_json_structure_config(sample_test_data): """validate_json_structure accepts valid config with module_name.""" data = sample_test_data @@ -131,6 +138,7 @@ def test_validate_json_structure_rejects_non_dict(): # JSON HANDLER: get_json_path # ============================================= + def test_get_json_path_returns_path(json_handler_module): """get_json_path returns a Path object.""" prax_json_dir = json_handler_module.PRAX_JSON_DIR @@ -146,6 +154,7 @@ def test_get_json_path_returns_path(json_handler_module): # JSON HANDLER: ensure_json_exists # ============================================= + def test_ensure_json_exists_creates_file(json_handler_module): """ensure_json_exists creates missing config file from template.""" prax_dir = json_handler_module.PRAX_JSON_DIR @@ -183,6 +192,7 @@ def test_ensure_json_no_overwrite(json_handler_module): # JSON HANDLER: load_json # ============================================= + def test_load_json_returns_dict(json_handler_module): """load_json returns dict type — isinstance(result, dict) check.""" prax_dir = json_handler_module.PRAX_JSON_DIR @@ -213,6 +223,7 @@ def test_load_json_missing_file_returns_none(json_handler_module): # JSON HANDLER: save_json # ============================================= + def test_save_json_writes_valid_data(json_handler_module): """save_json writes valid config data to file.""" prax_dir = json_handler_module.PRAX_JSON_DIR @@ -236,6 +247,7 @@ def test_save_json_invalid_raises(json_handler_module): # JSON HANDLER: ensure_module_jsons # ============================================= + def test_ensure_module_jsons_creates_all(json_handler_module): """ensure_module_jsons creates config, data, and log files.""" prax_dir = json_handler_module.PRAX_JSON_DIR @@ -256,6 +268,7 @@ def test_ensure_module_jsons_creates_all(json_handler_module): # EXCEPTION CONTRACTS # ============================================= + def test_create_default_raises_on_invalid_type(): """_create_default raises ValueError on invalid json_type.""" with pytest.raises(ValueError): @@ -278,6 +291,7 @@ def test_invalid_mode_raises_on_bad_input(): # CLI ROUTING: unknown_command + output_capture # ============================================= + def test_unknown_command_returns_false(mock_prax_infrastructure): """handle_command returns False for unknown_command.""" # Simulate command routing for unrecognized command @@ -298,6 +312,7 @@ def test_output_capture_with_capsys(capsys, mock_prax_infrastructure): # RETURN TYPE CONTRACTS # ============================================= + def test_command_returns_bool_type(mock_prax_infrastructure): """handle_command returns_bool — isinstance(result, bool) check.""" # Simulate command routing @@ -311,6 +326,7 @@ def test_command_returns_bool_type(mock_prax_infrastructure): # DATA STRUCTURE CONTRACTS # ============================================= + def test_config_has_required_keys(sample_test_data): """Config JSON contains module_name and config_keys.""" data = sample_test_data @@ -322,6 +338,7 @@ def test_config_has_required_keys(sample_test_data): # INIT PROVISIONING # ============================================= + def test_auto_creates_directory(tmp_path): """Provisioning auto-creates directories with mkdir.""" target = tmp_path / "new_dir" / "sub" diff --git a/src/aipass/prax/tests/test_log_audit.py b/src/aipass/prax/tests/test_log_audit.py index c8e486fe..c1c6bd03 100644 --- a/src/aipass/prax/tests/test_log_audit.py +++ b/src/aipass/prax/tests/test_log_audit.py @@ -21,25 +21,32 @@ from unittest.mock import MagicMock # HELPERS # ============================================= + def _ensure_watchdog_mock(monkeypatch): """Inject a mock for the log_watchdog handler.""" mock_watchdog = MagicMock() - mock_watchdog.scan_log_files = MagicMock(return_value=[ - {"name": "system.log", "lines": 500, "size_kb": 45, "status": "ok"}, - {"name": "error.log", "lines": 2500, "size_kb": 200, "status": "oversized"}, - ]) - mock_watchdog.log_health_summary = MagicMock(return_value={ - "total_files": 2, - "total_lines": 3000, - "largest_file": "error.log", - "largest_lines": 2500, - "healthy": False, - "oversized_count": 1, - "critical_count": 0, - }) - mock_watchdog.enforce_log_limits = MagicMock(return_value=[ - {"name": "error.log", "truncated": True, "original_lines": 2500, "new_lines": 1000}, - ]) + mock_watchdog.scan_log_files = MagicMock( + return_value=[ + {"name": "system.log", "lines": 500, "size_kb": 45, "status": "ok"}, + {"name": "error.log", "lines": 2500, "size_kb": 200, "status": "oversized"}, + ] + ) + mock_watchdog.log_health_summary = MagicMock( + return_value={ + "total_files": 2, + "total_lines": 3000, + "largest_file": "error.log", + "largest_lines": 2500, + "healthy": False, + "oversized_count": 1, + "critical_count": 0, + } + ) + mock_watchdog.enforce_log_limits = MagicMock( + return_value=[ + {"name": "error.log", "truncated": True, "original_lines": 2500, "new_lines": 1000}, + ] + ) monkeypatch.setitem( sys.modules, "aipass.prax.apps.handlers.logging.log_watchdog", @@ -58,6 +65,7 @@ def _fresh_import(): print_introspection, _display_audit, ) + return handle_command, print_help, print_introspection, _display_audit @@ -65,6 +73,7 @@ def _fresh_import(): # TESTS # ============================================= + def test_handle_command_help(mock_prax_infrastructure, monkeypatch): """--help flag returns True and displays help text.""" handle_command, _, _, _ = _fresh_import() diff --git a/src/aipass/prax/tests/test_log_watcher.py b/src/aipass/prax/tests/test_log_watcher.py index e72f2bdd..b5802e1c 100644 --- a/src/aipass/prax/tests/test_log_watcher.py +++ b/src/aipass/prax/tests/test_log_watcher.py @@ -22,13 +22,14 @@ from types import ModuleType from unittest.mock import MagicMock, patch - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + class _RealFSHandler: """Stub base so LogFileWatcher subclass methods are not swallowed by MagicMock.""" + pass @@ -55,19 +56,23 @@ def _import_log_watcher() -> ModuleType: mock_trigger_mod = MagicMock() - with patch.dict(sys.modules, { - "watchdog": MagicMock(), - "watchdog.observers": mock_watchdog_observer, - "watchdog.events": mock_watchdog_events, - "aipass.prax.apps.handlers.config.load": mock_config, - "aipass.prax.apps.handlers.monitoring.event_queue": mock_event_queue_mod, - "aipass.prax.apps.handlers.monitoring.branch_detector": mock_branch_detector, - "aipass.trigger": MagicMock(), - "aipass.trigger.apps": MagicMock(), - "aipass.trigger.apps.modules": MagicMock(), - "aipass.trigger.apps.modules.core": mock_trigger_mod, - }): + with patch.dict( + sys.modules, + { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.monitoring.event_queue": mock_event_queue_mod, + "aipass.prax.apps.handlers.monitoring.branch_detector": mock_branch_detector, + "aipass.trigger": MagicMock(), + "aipass.trigger.apps": MagicMock(), + "aipass.trigger.apps.modules": MagicMock(), + "aipass.trigger.apps.modules.core": mock_trigger_mod, + }, + ): import importlib + if "aipass.prax.apps.handlers.monitoring.log_watcher" in sys.modules: mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.monitoring.log_watcher"]) else: @@ -87,6 +92,7 @@ def _make_watcher(mod: ModuleType): # _detect_log_level tests # ============================================================================ + class TestDetectLogLevel: """Test log level detection from raw log lines.""" @@ -138,6 +144,7 @@ class TestDetectLogLevel: # _extract_command_info tests # ============================================================================ + class TestExtractCommandInfo: """Test command pattern extraction from log lines.""" @@ -280,6 +287,7 @@ class TestExtractCommandInfo: # _parse_log_message tests # ============================================================================ + class TestParseLogMessage: """Test pipe-delimited log line parsing.""" @@ -323,6 +331,7 @@ class TestParseLogMessage: # start/stop/is_active lifecycle tests # ============================================================================ + class TestLogWatcherLifecycle: """Test start_log_watcher, stop_log_watcher, is_log_watcher_active.""" @@ -349,9 +358,12 @@ class TestLogWatcherLifecycle: setattr(mod, "_log_observer", None) - with patch.dict(sys.modules, { - "watchdog.observers.polling": MagicMock(PollingObserver=mock_polling_cls), - }): + with patch.dict( + sys.modules, + { + "watchdog.observers.polling": MagicMock(PollingObserver=mock_polling_cls), + }, + ): with patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs")): result = mod.start_log_watcher(mock_queue, use_polling=True) @@ -400,6 +412,7 @@ class TestLogWatcherLifecycle: # initialize_positions tests # ============================================================================ + class TestInitializePositions: """Test seek-to-end initialization.""" diff --git a/src/aipass/prax/tests/test_logger_module.py b/src/aipass/prax/tests/test_logger_module.py index e7491b32..26cf64e9 100644 --- a/src/aipass/prax/tests/test_logger_module.py +++ b/src/aipass/prax/tests/test_logger_module.py @@ -94,9 +94,13 @@ def _build_handler_mocks(): # registry/load.py exports registry = mocks["aipass.prax.apps.handlers.registry.load"] - registry.load_module_registry = MagicMock(return_value=[ - {"name": "mod_a"}, {"name": "mod_b"}, {"name": "mod_c"}, - ]) + registry.load_module_registry = MagicMock( + return_value=[ + {"name": "mod_a"}, + {"name": "mod_b"}, + {"name": "mod_c"}, + ] + ) # config/load.py exports config = mocks["aipass.prax.apps.handlers.config.load"] @@ -142,6 +146,7 @@ def _inject_and_import(monkeypatch): # Now import — the module-level imports will resolve to our mocks import aipass.prax.apps.modules.logger as logger_mod import importlib + importlib.reload(logger_mod) return logger_mod, mocks @@ -151,6 +156,7 @@ def _inject_and_import(monkeypatch): # get_system_logger # ============================================= + class TestGetSystemLogger: """Tests for get_system_logger() — returns a logger with standard methods.""" @@ -210,6 +216,7 @@ class TestGetSystemLogger: # get_system_status # ============================================= + class TestGetSystemStatus: """Tests for get_system_status() — returns a dict with system info.""" @@ -296,6 +303,7 @@ class TestGetSystemStatus: # handle_command # ============================================= + class TestHandleCommand: """Tests for handle_command() — introspection gate and routing.""" @@ -346,9 +354,7 @@ class TestHandleCommand: mod.handle_command("logger", ["--help"]) json_handler = mocks["aipass.prax.apps.handlers.json"].json_handler - json_handler.log_operation.assert_called_once_with( - "logger_handle_command", {"args": ["--help"]} - ) + json_handler.log_operation.assert_called_once_with("logger_handle_command", {"args": ["--help"]}) def test_no_args_logs_operation(self, monkeypatch): """handle_command with no args still logs the operation.""" @@ -357,15 +363,14 @@ class TestHandleCommand: mod.handle_command("logger", []) json_handler = mocks["aipass.prax.apps.handlers.json"].json_handler - json_handler.log_operation.assert_called_once_with( - "logger_handle_command", {"args": []} - ) + json_handler.log_operation.assert_called_once_with("logger_handle_command", {"args": []}) # ============================================= # initialize_logging_system # ============================================= + class TestInitializeLoggingSystem: """Tests for initialize_logging_system() — delegates to lifecycle handler.""" @@ -376,9 +381,11 @@ class TestInitializeLoggingSystem: # The lifecycle handler is lazy-imported inside the function, # so we need to mock it in sys.modules mock_lifecycle = MagicMock() - mock_lifecycle.run_initialize = MagicMock(return_value={ - "modules_count": 42, - }) + mock_lifecycle.run_initialize = MagicMock( + return_value={ + "modules_count": 42, + } + ) monkeypatch.setitem( sys.modules, "aipass.prax.apps.handlers.logging.lifecycle", @@ -394,9 +401,11 @@ class TestInitializeLoggingSystem: mod, mocks = _inject_and_import(monkeypatch) mock_lifecycle = MagicMock() - mock_lifecycle.run_initialize = MagicMock(return_value={ - "modules_count": 10, - }) + mock_lifecycle.run_initialize = MagicMock( + return_value={ + "modules_count": 10, + } + ) monkeypatch.setitem( sys.modules, "aipass.prax.apps.handlers.logging.lifecycle", @@ -415,6 +424,7 @@ class TestInitializeLoggingSystem: # shutdown_logging_system # ============================================= + class TestShutdownLoggingSystem: """Tests for shutdown_logging_system() — delegates to lifecycle handler.""" @@ -458,6 +468,7 @@ class TestShutdownLoggingSystem: # enable_terminal_output / disable_terminal_output # ============================================= + class TestTerminalOutputControl: """Tests for enable/disable terminal output pass-through functions.""" @@ -484,6 +495,7 @@ class TestTerminalOutputControl: # SystemLogger class # ============================================= + class TestSystemLogger: """Tests for the SystemLogger class — auto-routing logger proxy.""" @@ -551,6 +563,7 @@ class TestSystemLogger: # Module constants # ============================================= + class TestModuleConstants: """Tests for module-level constants.""" @@ -577,6 +590,7 @@ class TestModuleConstants: # print_introspection # ============================================= + class TestPrintIntrospection: """Tests for print_introspection() — displays module info.""" @@ -620,6 +634,7 @@ class TestPrintIntrospection: sys.modules.pop(MODULE_NAME, None) import importlib import aipass.prax.apps.modules.logger as fresh_mod + importlib.reload(fresh_mod) fresh_mod.print_introspection() diff --git a/src/aipass/prax/tests/test_logging.py b/src/aipass/prax/tests/test_logging.py index a133f90c..815deef4 100644 --- a/src/aipass/prax/tests/test_logging.py +++ b/src/aipass/prax/tests/test_logging.py @@ -18,47 +18,56 @@ from unittest.mock import MagicMock, patch # _is_prax_internal # ============================================= + class TestIsPraxInternal: """Tests for _is_prax_internal() — checks prax internal markers.""" def test_prax_logger_path(self, mock_prax_infrastructure): """Logger module path is detected as prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/home/user/src/aipass/prax/apps/modules/logger.py") is True def test_prax_handlers_path(self, mock_prax_infrastructure): """Handler directory path is detected as prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/home/user/src/aipass/prax/apps/handlers/logging/setup.py") is True def test_prax_logger_filename(self, mock_prax_infrastructure): """prax_logger.py filename is detected as prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/some/path/prax_logger.py") is True def test_prax_handlers_filename(self, mock_prax_infrastructure): """prax_handlers.py filename is detected as prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/some/path/prax_handlers.py") is True def test_external_cli_path(self, mock_prax_infrastructure): """CLI module path is not prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/home/user/src/aipass/cli/apps/cli.py") is False def test_external_flow_path(self, mock_prax_infrastructure): """Flow module path is not prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/home/user/src/aipass/flow/apps/flow.py") is False def test_random_script_path(self, mock_prax_infrastructure): """Random script path is not prax internal.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("/tmp/random_script.py") is False def test_empty_string(self, mock_prax_infrastructure): """Empty string returns False.""" from aipass.prax.apps.handlers.logging.introspection import _is_prax_internal + assert _is_prax_internal("") is False @@ -66,6 +75,7 @@ class TestIsPraxInternal: # detect_branch_from_path # ============================================= + class TestDetectBranchFromPath: """Tests for detect_branch_from_path() — extracts branch name from file paths.""" @@ -75,6 +85,7 @@ class TestDetectBranchFromPath: detect_branch_from_path, _AIPASS_PKG_ROOT, ) + cli_path = str(_AIPASS_PKG_ROOT / "cli" / "apps" / "cli.py") assert detect_branch_from_path(cli_path) == "cli" @@ -84,6 +95,7 @@ class TestDetectBranchFromPath: detect_branch_from_path, _AIPASS_PKG_ROOT, ) + flow_path = str(_AIPASS_PKG_ROOT / "flow" / "apps" / "flow.py") assert detect_branch_from_path(flow_path) == "flow" @@ -93,6 +105,7 @@ class TestDetectBranchFromPath: detect_branch_from_path, _AIPASS_PKG_ROOT, ) + prax_path = str(_AIPASS_PKG_ROOT / "prax" / "apps" / "module.py") assert detect_branch_from_path(prax_path) == "prax" @@ -102,17 +115,20 @@ class TestDetectBranchFromPath: detect_branch_from_path, _AIPASS_PKG_ROOT, ) + drone_path = str(_AIPASS_PKG_ROOT / "drone" / "apps" / "branch.py") assert detect_branch_from_path(drone_path) == "drone" def test_random_path_returns_none(self, mock_prax_infrastructure): """Random path outside the project returns None.""" from aipass.prax.apps.handlers.logging.introspection import detect_branch_from_path + assert detect_branch_from_path("/tmp/random_script.py") is None def test_empty_string_returns_none(self, mock_prax_infrastructure): """Empty string returns None.""" from aipass.prax.apps.handlers.logging.introspection import detect_branch_from_path + assert detect_branch_from_path("") is None def test_nested_module_still_resolves(self, mock_prax_infrastructure): @@ -121,9 +137,8 @@ class TestDetectBranchFromPath: detect_branch_from_path, _AIPASS_PKG_ROOT, ) - deep_path = str( - _AIPASS_PKG_ROOT / "flow" / "apps" / "handlers" / "deep" / "module.py" - ) + + deep_path = str(_AIPASS_PKG_ROOT / "flow" / "apps" / "handlers" / "deep" / "module.py") assert detect_branch_from_path(deep_path) == "flow" @@ -131,12 +146,14 @@ class TestDetectBranchFromPath: # get_caller_info # ============================================= + class TestGetCallerInfo: """Tests for get_caller_info() — returns (module_name, path, branch) tuple.""" def test_returns_tuple_of_three(self, mock_prax_infrastructure): """get_caller_info always returns a 3-tuple.""" from aipass.prax.apps.handlers.logging.introspection import get_caller_info + result = get_caller_info() assert isinstance(result, tuple) assert len(result) == 3 @@ -144,12 +161,14 @@ class TestGetCallerInfo: def test_module_name_is_string(self, mock_prax_infrastructure): """First element (module_name) is always a string.""" from aipass.prax.apps.handlers.logging.introspection import get_caller_info + module_name, _path, _branch = get_caller_info() assert isinstance(module_name, str) def test_called_from_test_file(self, mock_prax_infrastructure): """When called from a test file, path should reference this file.""" from aipass.prax.apps.handlers.logging.introspection import get_caller_info + module_name, caller_path, branch = get_caller_info() # Called from this test file, so module_name should be "test_logging" # or the stack walk may land on pytest internals; either way it is a string @@ -169,6 +188,7 @@ class TestGetCallerInfo: # lines_to_bytes # ============================================= + class TestLinesToBytes: """Tests for lines_to_bytes() — converts line counts to byte estimates.""" @@ -183,9 +203,12 @@ class TestLinesToBytes: mock_config.load_log_config = MagicMock() mock_config.get_debug_prints_enabled = MagicMock(return_value=False) - with patch.dict(sys.modules, { - "aipass.prax.apps.handlers.config.load": mock_config, - }): + with patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.config.load": mock_config, + }, + ): # Direct computation test — mirrors the function logic result = 1000 * 200 assert result == 200_000 @@ -215,9 +238,12 @@ class TestLinesToBytes: mock_config_mod.lines_to_bytes = real_lines_to_bytes - with patch.dict(sys.modules, { - "aipass.prax.apps.handlers.config.load": mock_config_mod, - }): + with patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.config.load": mock_config_mod, + }, + ): fn = sys.modules["aipass.prax.apps.handlers.config.load"].lines_to_bytes assert fn(1000) == 200_000 assert fn(1000, 100) == 100_000 @@ -229,6 +255,7 @@ class TestLinesToBytes: # _replace_placeholders # ============================================= + class TestReplacePlaceholders: """Tests for _replace_placeholders() — template placeholder substitution.""" @@ -240,6 +267,7 @@ class TestReplacePlaceholders: which has dependencies that need extensive mocking. The logic under test is the recursive walk + placeholder replacement algorithm. """ + def _replace_placeholders(template: dict, branch_name: str) -> dict: def _walk(val): if isinstance(val, str): @@ -249,9 +277,11 @@ class TestReplacePlaceholders: elif isinstance(val, dict): return {k: _walk(v) for k, v in val.items()} return val + result = _walk(copy.deepcopy(template)) assert isinstance(result, dict) return result + return _replace_placeholders def test_simple_string_replacement(self, mock_prax_infrastructure): diff --git a/src/aipass/prax/tests/test_monitor_module.py b/src/aipass/prax/tests/test_monitor_module.py index 7b5b16d0..98826643 100644 --- a/src/aipass/prax/tests/test_monitor_module.py +++ b/src/aipass/prax/tests/test_monitor_module.py @@ -18,30 +18,34 @@ import sys from unittest.mock import MagicMock, patch - # --------------------------------------------------------------------------- # handle_command tests # --------------------------------------------------------------------------- + class TestHandleCommand: """Test the top-level command router.""" def _import_monitor(self): """Import monitor module fresh (after conftest mocks are in place).""" # Additional mocks for monitoring handler imports - with patch.dict(sys.modules, { - "aipass.prax.apps.handlers.monitoring": MagicMock(), - "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), - "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), - "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), - "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), - "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), - "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), - "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), - "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), - "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), - }): + with patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring": MagicMock(), + "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), + "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), + "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), + "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), + "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), + "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), + "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), + "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), + "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), + }, + ): import importlib + if "aipass.prax.apps.modules.monitor" in sys.modules: mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) else: @@ -100,23 +104,28 @@ class TestHandleCommand: # _get_watch_directories tests # --------------------------------------------------------------------------- + class TestGetWatchDirectories: """Test directory enumeration from registry.""" def _import_monitor(self): - with patch.dict(sys.modules, { - "aipass.prax.apps.handlers.monitoring": MagicMock(), - "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), - "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), - "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), - "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), - "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), - "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), - "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), - "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), - "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), - }): + with patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring": MagicMock(), + "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), + "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), + "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), + "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), + "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), + "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), + "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), + "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), + "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), + }, + ): import importlib + if "aipass.prax.apps.modules.monitor" in sys.modules: mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) else: @@ -140,11 +149,7 @@ class TestGetWatchDirectories: apps_dir.mkdir(parents=True) trinity_dir.mkdir(parents=True) - registry = { - "branches": [ - {"name": "flow", "path": "src/aipass/flow"} - ] - } + registry = {"branches": [{"name": "flow", "path": "src/aipass/flow"}]} registry_file = tmp_path / "AIPASS_REGISTRY.json" registry_file.write_text(json.dumps(registry), encoding="utf-8") @@ -166,11 +171,7 @@ class TestGetWatchDirectories: def test_skips_nonexistent_branch_paths(self, tmp_path): """Branches whose path doesn't exist on disk are skipped.""" mod = self._import_monitor() - registry = { - "branches": [ - {"name": "ghost", "path": "src/aipass/ghost"} - ] - } + registry = {"branches": [{"name": "ghost", "path": "src/aipass/ghost"}]} registry_file = tmp_path / "AIPASS_REGISTRY.json" registry_file.write_text(json.dumps(registry), encoding="utf-8") @@ -203,11 +204,7 @@ class TestGetWatchDirectories: branch_dir.mkdir(parents=True) trinity_dir.mkdir(parents=True) - registry = { - "branches": [ - {"name": "minimal", "path": "src/aipass/minimal"} - ] - } + registry = {"branches": [{"name": "minimal", "path": "src/aipass/minimal"}]} registry_file = tmp_path / "AIPASS_REGISTRY.json" registry_file.write_text(json.dumps(registry), encoding="utf-8") diff --git a/src/aipass/prax/tests/test_monitoring_filters.py b/src/aipass/prax/tests/test_monitoring_filters.py index 6b1e50c3..a122651b 100644 --- a/src/aipass/prax/tests/test_monitoring_filters.py +++ b/src/aipass/prax/tests/test_monitoring_filters.py @@ -15,11 +15,11 @@ filter_log_content, and apply_content_filter. from pathlib import Path - # ============================================= # should_monitor TESTS # ============================================= + class TestShouldMonitor: """Tests for should_monitor(path).""" @@ -136,6 +136,7 @@ class TestShouldMonitor: # get_priority TESTS # ============================================= + class TestGetPriority: """Tests for get_priority(path, event_type).""" @@ -193,6 +194,7 @@ class TestGetPriority: # get_content_filter TESTS # ============================================= + class TestGetContentFilter: """Tests for get_content_filter(path).""" @@ -233,6 +235,7 @@ class TestGetContentFilter: # filter_log_content TESTS # ============================================= + class TestFilterLogContent: """Tests for filter_log_content(content, ...).""" @@ -308,6 +311,7 @@ class TestFilterLogContent: # apply_content_filter TESTS # ============================================= + class TestApplyContentFilter: """Tests for apply_content_filter(path, content, ...).""" diff --git a/src/aipass/prax/tests/test_operations.py b/src/aipass/prax/tests/test_operations.py index 0b369a0f..e55bb286 100644 --- a/src/aipass/prax/tests/test_operations.py +++ b/src/aipass/prax/tests/test_operations.py @@ -26,6 +26,7 @@ def _load_ops(): """Import (or reimport) the operations module under active mocks.""" sys.modules.pop(MODULE_PATH, None) import aipass.prax.apps.handlers.dashboard.operations as mod + importlib.reload(mod) return mod @@ -34,6 +35,7 @@ def _load_ops(): # get_dashboard_path # ============================================= + class TestGetDashboardPath: """Tests for get_dashboard_path — pure path joining.""" @@ -58,6 +60,7 @@ class TestGetDashboardPath: # load_dashboard # ============================================= + class TestLoadDashboard: """Tests for load_dashboard — file I/O with fallback to template.""" @@ -80,9 +83,7 @@ class TestLoadDashboard: "last_updated": "2026-01-01", "sections": {"flow": {"active_plans": 5}}, } - (branch_dir / "DASHBOARD.local.json").write_text( - json.dumps(dashboard_data), encoding="utf-8" - ) + (branch_dir / "DASHBOARD.local.json").write_text(json.dumps(dashboard_data), encoding="utf-8") result = ops.load_dashboard(branch_dir, self._make_template()) assert result["branch"] == "MYBRANCH" @@ -103,9 +104,7 @@ class TestLoadDashboard: ops = _load_ops() branch_dir = tmp_path / "broken" branch_dir.mkdir() - (branch_dir / "DASHBOARD.local.json").write_text( - "{not valid json!!!", encoding="utf-8" - ) + (branch_dir / "DASHBOARD.local.json").write_text("{not valid json!!!", encoding="utf-8") template = self._make_template() result = ops.load_dashboard(branch_dir, template) @@ -127,9 +126,7 @@ class TestLoadDashboard: ops = _load_ops() branch_dir = tmp_path / "arrayfile" branch_dir.mkdir() - (branch_dir / "DASHBOARD.local.json").write_text( - json.dumps([1, 2, 3]), encoding="utf-8" - ) + (branch_dir / "DASHBOARD.local.json").write_text(json.dumps([1, 2, 3]), encoding="utf-8") template = self._make_template() result = ops.load_dashboard(branch_dir, template) @@ -157,6 +154,7 @@ class TestLoadDashboard: # save_dashboard # ============================================= + class TestSaveDashboard: """Tests for save_dashboard — file write with timestamp update.""" @@ -189,9 +187,7 @@ class TestSaveDashboard: data = {"branch": "TIMESTAMP", "sections": {}} ops.save_dashboard(branch_dir, data) - content = json.loads( - (branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8") - ) + content = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8")) assert "last_updated" in content # Should be a non-empty ISO-format string assert len(content["last_updated"]) > 0 @@ -210,6 +206,7 @@ class TestSaveDashboard: # write_section # ============================================= + class TestWriteSection: """Tests for write_section — orchestration of load/update/save.""" @@ -238,14 +235,10 @@ class TestWriteSection: "ai_mail": {"new": 5, "last_updated": "2026-01-01"}, }, } - (branch_dir / "DASHBOARD.local.json").write_text( - json.dumps(existing), encoding="utf-8" - ) + (branch_dir / "DASHBOARD.local.json").write_text(json.dumps(existing), encoding="utf-8") ops.write_section(branch_dir, "flow", {"active_plans": 7}) - data = json.loads( - (branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8") - ) + data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8")) assert data["sections"]["flow"]["active_plans"] == 7 def test_preserves_other_sections(self, tmp_path): @@ -259,14 +252,10 @@ class TestWriteSection: "ai_mail": {"new": 3, "opened": 1, "last_updated": "2026-01-01"}, }, } - (branch_dir / "DASHBOARD.local.json").write_text( - json.dumps(existing), encoding="utf-8" - ) + (branch_dir / "DASHBOARD.local.json").write_text(json.dumps(existing), encoding="utf-8") ops.write_section(branch_dir, "flow", {"active_plans": 4}) - data = json.loads( - (branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8") - ) + data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8")) # ai_mail should still be present and unchanged assert data["sections"]["ai_mail"]["new"] == 3 assert data["sections"]["ai_mail"]["opened"] == 1 @@ -277,9 +266,7 @@ class TestWriteSection: branch_dir.mkdir() ops.write_section(branch_dir, "flow", {"active_plans": 1}) - data = json.loads( - (branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8") - ) + data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8")) assert "last_updated" in data["sections"]["flow"] assert "T" in data["sections"]["flow"]["last_updated"] @@ -296,6 +283,7 @@ class TestWriteSection: # _calculate_quick_status_standalone # ============================================= + class TestCalculateQuickStatusStandalone: """Tests for _calculate_quick_status_standalone — pure calculation.""" diff --git a/src/aipass/prax/tests/test_registry.py b/src/aipass/prax/tests/test_registry.py index 51f087d0..d59a026e 100644 --- a/src/aipass/prax/tests/test_registry.py +++ b/src/aipass/prax/tests/test_registry.py @@ -22,6 +22,7 @@ from unittest.mock import MagicMock # HELPERS # ============================================= + def _fresh_import_registry_load(monkeypatch, tmp_path): """Import registry load module with paths redirected to tmp_path.""" for key in list(sys.modules.keys()): @@ -71,6 +72,7 @@ def _fresh_import_registry_save(monkeypatch, tmp_path): # TESTS: load_module_registry # ============================================= + class TestLoadModuleRegistry: """Tests for load_module_registry().""" @@ -99,9 +101,7 @@ class TestLoadModuleRegistry: "modules": modules, "statistics": {"total_modules": 2}, } - load_mod.REGISTRY_FILE.write_text( - json.dumps(registry), encoding="utf-8" - ) + load_mod.REGISTRY_FILE.write_text(json.dumps(registry), encoding="utf-8") result = load_mod.load_module_registry() assert len(result) == 2 @@ -116,26 +116,18 @@ class TestLoadModuleRegistry: result = load_mod.load_module_registry() assert result == {} - def test_empty_dict_when_modules_key_missing( - self, mock_prax_infrastructure, monkeypatch, tmp_path - ): + def test_empty_dict_when_modules_key_missing(self, mock_prax_infrastructure, monkeypatch, tmp_path): """Registry without 'modules' key should return empty dict.""" load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) - load_mod.REGISTRY_FILE.write_text( - json.dumps({"registry_version": "1.0.0"}), encoding="utf-8" - ) + load_mod.REGISTRY_FILE.write_text(json.dumps({"registry_version": "1.0.0"}), encoding="utf-8") result = load_mod.load_module_registry() assert result == {} - def test_empty_modules_returns_empty_dict( - self, mock_prax_infrastructure, monkeypatch, tmp_path - ): + def test_empty_modules_returns_empty_dict(self, mock_prax_infrastructure, monkeypatch, tmp_path): """Registry with empty modules dict should return empty dict.""" load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) - load_mod.REGISTRY_FILE.write_text( - json.dumps({"modules": {}}), encoding="utf-8" - ) + load_mod.REGISTRY_FILE.write_text(json.dumps({"modules": {}}), encoding="utf-8") result = load_mod.load_module_registry() assert result == {} @@ -147,9 +139,7 @@ class TestLoadModuleRegistry: registry = { "modules": {"mod_a": {"path": "a"}, "mod_b": {"path": "b"}}, } - load_mod.REGISTRY_FILE.write_text( - json.dumps(registry), encoding="utf-8" - ) + load_mod.REGISTRY_FILE.write_text(json.dumps(registry), encoding="utf-8") load_mod.load_module_registry() load_mod.json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] @@ -169,6 +159,7 @@ class TestLoadModuleRegistry: # TESTS: save_module_registry # ============================================= + class TestSaveModuleRegistry: """Tests for save_module_registry().""" @@ -197,9 +188,7 @@ class TestSaveModuleRegistry: data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) assert isinstance(data, dict) - def test_saved_structure_has_required_keys( - self, mock_prax_infrastructure, monkeypatch, tmp_path - ): + def test_saved_structure_has_required_keys(self, mock_prax_infrastructure, monkeypatch, tmp_path): """Saved JSON should contain registry_version, timestamp, modules, statistics.""" save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) modules = {"alpha": {"relative_path": "src/alpha.py"}} @@ -306,6 +295,7 @@ class TestSaveModuleRegistry: # TESTS: round-trip (save then load) # ============================================= + class TestRegistryRoundTrip: """Integration-style tests: save then load.""" @@ -313,10 +303,12 @@ class TestRegistryRoundTrip: """Data saved by save_module_registry should be loadable by load_module_registry.""" # Import both with the same tmp_path save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) - save_mod.save_module_registry({ - "alpha": {"relative_path": "src/alpha.py", "size": 100}, - "beta": {"relative_path": "src/beta.py", "size": 200}, - }) + save_mod.save_module_registry( + { + "alpha": {"relative_path": "src/alpha.py", "size": 100}, + "beta": {"relative_path": "src/beta.py", "size": 200}, + } + ) # Now import load pointing at the same directory load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) diff --git a/src/aipass/prax/tests/test_status.py b/src/aipass/prax/tests/test_status.py index 763c1f93..2c3905d6 100644 --- a/src/aipass/prax/tests/test_status.py +++ b/src/aipass/prax/tests/test_status.py @@ -21,15 +21,18 @@ from unittest.mock import MagicMock # HELPERS # ============================================= + def _ensure_sync_mock(monkeypatch): """Inject a mock for the sync handler before importing status module.""" mock_sync_mod = MagicMock() - mock_sync_mod.sync_status = MagicMock(return_value={ - "status": "ok", - "branches_synced": ["prax", "drone", "flow"], - "branches_missing": [], - "timestamp": "2026-03-24T12:00:00", - }) + mock_sync_mod.sync_status = MagicMock( + return_value={ + "status": "ok", + "branches_synced": ["prax", "drone", "flow"], + "branches_missing": [], + "timestamp": "2026-03-24T12:00:00", + } + ) monkeypatch.setitem( sys.modules, "aipass.prax.apps.handlers.status.sync", @@ -47,6 +50,7 @@ def _fresh_import(): print_help, print_introspection, ) + return handle_command, print_help, print_introspection @@ -54,6 +58,7 @@ def _fresh_import(): # TESTS # ============================================= + def test_handle_command_help(mock_prax_infrastructure, monkeypatch): """--help flag returns True and prints help text.""" _ensure_sync_mock(monkeypatch) diff --git a/src/aipass/prax/tests/test_watcher.py b/src/aipass/prax/tests/test_watcher.py index 849996cc..15c07d10 100644 --- a/src/aipass/prax/tests/test_watcher.py +++ b/src/aipass/prax/tests/test_watcher.py @@ -16,31 +16,37 @@ from pathlib import Path from unittest.mock import MagicMock, patch - # ============================================================================ # WATCHER/MONITOR.PY - BranchFileHandler and start/stop_monitoring # ============================================================================ + class TestBranchFileHandler: """Tests for BranchFileHandler event callbacks and filtering.""" def _make_handler(self): """Create a BranchFileHandler with a mock callback.""" + # Provide a real base class so subclass methods work properly class _RealFSHandler: """Stub base so BranchFileHandler methods are not swallowed.""" + pass mock_watchdog_events = MagicMock() mock_watchdog_events.FileSystemEventHandler = _RealFSHandler mock_watchdog_events.FileSystemEvent = MagicMock() mock_watchdog_observer = MagicMock() - with patch.dict(sys.modules, { - "watchdog": MagicMock(), - "watchdog.observers": mock_watchdog_observer, - "watchdog.events": mock_watchdog_events, - }): + with patch.dict( + sys.modules, + { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + }, + ): import importlib + if "aipass.prax.apps.handlers.watcher.monitor" in sys.modules: mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.watcher.monitor"]) else: @@ -151,6 +157,7 @@ class TestBranchFileHandler: # WATCHER/MONITOR.PY - start_monitoring / stop_monitoring # ============================================================================ + class TestStartStopMonitoring: """Tests for start_monitoring and stop_monitoring functions.""" @@ -164,12 +171,16 @@ class TestStartStopMonitoring: mock_watchdog_events = MagicMock() - with patch.dict(sys.modules, { - "watchdog": MagicMock(), - "watchdog.observers": mock_watchdog_observer, - "watchdog.events": mock_watchdog_events, - }): + with patch.dict( + sys.modules, + { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + }, + ): import importlib + if "aipass.prax.apps.handlers.watcher.monitor" in sys.modules: mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.watcher.monitor"]) else: @@ -225,6 +236,7 @@ class TestStartStopMonitoring: # DISCOVERY/WATCHER.PY - PythonFileWatcher, start/stop/is_active # ============================================================================ + class TestDiscoveryWatcher: """Tests for the discovery watcher that registers new Python modules.""" @@ -253,20 +265,24 @@ class TestDiscoveryWatcher: mock_trigger_mod = MagicMock() - with patch.dict(sys.modules, { - "watchdog": MagicMock(), - "watchdog.observers": mock_watchdog_observer, - "watchdog.events": mock_watchdog_events, - "aipass.prax.apps.handlers.config.load": mock_config, - "aipass.prax.apps.handlers.registry.load": mock_registry_load, - "aipass.prax.apps.handlers.registry.save": mock_registry_save, - "aipass.prax.apps.handlers.discovery.filtering": mock_filtering, - "aipass.trigger": MagicMock(), - "aipass.trigger.apps": MagicMock(), - "aipass.trigger.apps.modules": MagicMock(), - "aipass.trigger.apps.modules.core": mock_trigger_mod, - }): + with patch.dict( + sys.modules, + { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.registry.load": mock_registry_load, + "aipass.prax.apps.handlers.registry.save": mock_registry_save, + "aipass.prax.apps.handlers.discovery.filtering": mock_filtering, + "aipass.trigger": MagicMock(), + "aipass.trigger.apps": MagicMock(), + "aipass.trigger.apps.modules": MagicMock(), + "aipass.trigger.apps.modules.core": mock_trigger_mod, + }, + ): import importlib + if "aipass.prax.apps.handlers.discovery.watcher" in sys.modules: mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.discovery.watcher"]) else: diff --git a/src/aipass/seedgo/apps/handlers/__init__.py b/src/aipass/seedgo/apps/handlers/__init__.py index a8c7226e..13442aea 100644 --- a/src/aipass/seedgo/apps/handlers/__init__.py +++ b/src/aipass/seedgo/apps/handlers/__init__.py @@ -62,8 +62,10 @@ def _guard_branch_access(): # DEBUG: Log guard resolution details when debugging import os + if os.environ.get("AIPASS_DEBUG_GUARD"): import logging + _guard_logger = logging.getLogger("aipass.seedgo.handlers.guard") _guard_logger.info("caller_file = %s", caller_file) _guard_logger.info("import_line = %s", import_line) @@ -94,9 +96,9 @@ def _guard_branch_access(): blocked_import = import_line if import_line else "unknown" raise ImportError( - f"\n{'='*60}\n" + f"\n{'=' * 60}\n" f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" + f"{'=' * 60}\n" f" Caller branch: {caller_branch}\n" f" Caller file: {caller_filename}\n" f" Blocked: {blocked_import}\n" @@ -110,7 +112,7 @@ def _guard_branch_access(): f"\n" f" For full standards guide:\n" f" drone @seedgo handlers\n" - f"{'='*60}" + f"{'=' * 60}" ) diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py b/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py index 9b5b4e84..b81ae026 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py @@ -100,23 +100,27 @@ def scan(pack_dir: Path) -> dict: except SyntaxError as exc: logger.info("Skipped %s: SyntaxError during parse", content_file.name) incorrect.append(content_file.name) - issues.append({ - "file": content_file.name, - "expected": expected_fn, - "issue": f"SyntaxError: {exc}", - }) + issues.append( + { + "file": content_file.name, + "expected": expected_fn, + "issue": f"SyntaxError: {exc}", + } + ) continue if expected_fn in public_fns: correct.append(content_file.name) else: incorrect.append(content_file.name) - issues.append({ - "file": content_file.name, - "expected": expected_fn, - "found_functions": public_fns, - "issue": f"Missing expected function {expected_fn}()", - }) + issues.append( + { + "file": content_file.name, + "expected": expected_fn, + "found_functions": public_fns, + "issue": f"Missing expected function {expected_fn}()", + } + ) total = len(correct) + len(incorrect) passed = len(incorrect) == 0 diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py b/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py index dba2a85d..e06b075d 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py @@ -45,9 +45,7 @@ def _extract_audit_scope(tree: ast.Module) -> str | None: continue for target in node.targets: if isinstance(target, ast.Name) and target.id == "AUDIT_SCOPE": - if isinstance(node.value, ast.Constant) and isinstance( - node.value.value, str - ): + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): return node.value.value return None @@ -207,9 +205,7 @@ def scan(pack_dir: Path) -> dict: issues.append(err) # Final compliance - entry["compliant"] = ( - entry["scope_valid"] and entry["has_function"] and entry["params_ok"] - ) + entry["compliant"] = entry["scope_valid"] and entry["has_function"] and entry["params_ok"] results.append(entry) pass_count = sum(1 for r in results if r["compliant"]) diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/interface_content.py b/src/aipass/seedgo/apps/handlers/aipass_proof/interface_content.py index c370a342..45d76863 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/interface_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/interface_content.py @@ -40,8 +40,8 @@ def get_interface_proof() -> str: ' [green]"branch_level"[/green] \u2014 Checker receives the branch root path', "", "[bold cyan]FUNCTION SIGNATURES:[/bold cyan]", - ' [yellow]all_files / entry_point:[/yellow] [dim]def check_module(file_path, module_name, branch_path)[/dim]', - ' [yellow]branch_level:[/yellow] [dim]def check_branch(branch_path, module_name)[/dim]', + " [yellow]all_files / entry_point:[/yellow] [dim]def check_module(file_path, module_name, branch_path)[/dim]", + " [yellow]branch_level:[/yellow] [dim]def check_branch(branch_path, module_name)[/dim]", "", "[bold cyan]WHY IT MATTERS:[/bold cyan]", " Without AUDIT_SCOPE, the audit engine can't scope the checker.", @@ -53,7 +53,7 @@ def get_interface_proof() -> str: "", "[yellow]Missing AUDIT_SCOPE:[/yellow]", " [red]\u2717[/red] Checker has no AUDIT_SCOPE declaration at all", - " [green]\u2713[/green] [dim]AUDIT_SCOPE = \"all_files\"[/dim]", + ' [green]\u2713[/green] [dim]AUDIT_SCOPE = "all_files"[/dim]', "", "[yellow]Wrong function name:[/yellow]", " [red]\u2717[/red] [dim]def check(file_path, module_name, branch_path)[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/plugin_integrity.py b/src/aipass/seedgo/apps/handlers/aipass_proof/plugin_integrity.py index de836e54..324979bb 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/plugin_integrity.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/plugin_integrity.py @@ -68,6 +68,7 @@ AMBIGUOUS_NAMES: set[str] = { # TARGET MODULE RESOLUTION # ============================================================================= + def _resolve_target_modules( pack_dir: Path, ) -> list[dict[str, Path | str | bool]]: @@ -117,6 +118,7 @@ def _resolve_target_modules( # STANDARD NAME DISCOVERY # ============================================================================= + def _discover_standard_names(pack_dir: Path) -> list[str]: """Discover standard names from *_check.py files in the pack directory. @@ -136,6 +138,7 @@ def _discover_standard_names(pack_dir: Path) -> list[str]: # AST HELPERS # ============================================================================= + def _enclosing_context(node: ast.AST, parents: dict[int, ast.AST]) -> str: """Walk up the parent chain to find the enclosing function/class name.""" parts: list[str] = [] @@ -158,11 +161,7 @@ def _is_docstring(node: ast.Constant, tree: ast.Module) -> bool: if not isinstance(body, list) or not body: continue first = body[0] - if ( - isinstance(first, ast.Expr) - and isinstance(first.value, ast.Constant) - and first.value is node - ): + if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and first.value is node: return True return False @@ -188,11 +187,17 @@ def _is_display_string( if isinstance(parent, ast.Call): func = parent.func if isinstance(func, ast.Attribute) and func.attr in ( - "print", "info", "error", "warning", "debug", + "print", + "info", + "error", + "warning", + "debug", ): return True if isinstance(func, ast.Name) and func.id in ( - "header", "error", "warning", + "header", + "error", + "warning", ): return True @@ -201,11 +206,14 @@ def _is_display_string( if isinstance(grandparent, ast.Call): func = grandparent.func if isinstance(func, ast.Attribute) and func.attr in ( - "print", "log_operation", + "print", + "log_operation", ): return True if isinstance(func, ast.Name) and func.id in ( - "header", "error", "warning", + "header", + "error", + "warning", ): return True @@ -242,6 +250,7 @@ def _is_dict_key_access( # AST SCANNER # ============================================================================= + def _scan_file_ast( file_path: Path, standard_names: list[str], @@ -288,13 +297,15 @@ def _scan_file_ast( continue context = _enclosing_context(node, parents) - findings.append({ - "line": getattr(node, "lineno", 0), - "name": value, - "context": context, - "value": value, - "kind": "ast_string_literal", - }) + findings.append( + { + "line": getattr(node, "lineno", 0), + "name": value, + "context": context, + "value": value, + "kind": "ast_string_literal", + } + ) return findings @@ -303,6 +314,7 @@ def _scan_file_ast( # REGEX SCANNER # ============================================================================= + def _scan_file_regex( file_path: Path, standard_names: list[str], @@ -351,35 +363,41 @@ def _scan_file_regex( # Pattern 1: check_( -- direct checker call fn_pat = rf"\bcheck_{re.escape(sn)}\s*\(" if re.search(fn_pat, code_part): - findings.append({ - "line": lineno, - "name": sn, - "context": stripped[:80], - "value": stripped[:80], - "kind": "hardcoded_function_call", - }) + findings.append( + { + "line": lineno, + "name": sn, + "context": stripped[:80], + "value": stripped[:80], + "kind": "hardcoded_function_call", + } + ) # Pattern 2: _violations -- hardcoded violation key viol_pat = rf"\b{re.escape(sn)}_violations\b" if re.search(viol_pat, code_part): - findings.append({ - "line": lineno, - "name": sn, - "context": stripped[:80], - "value": stripped[:80], - "kind": "hardcoded_violation_key", - }) + findings.append( + { + "line": lineno, + "name": sn, + "context": stripped[:80], + "value": stripped[:80], + "kind": "hardcoded_violation_key", + } + ) # Pattern 3: == '' -- hardcoded branching branch_pat = rf"""==\s*['"]{re.escape(sn)}['"]""" if re.search(branch_pat, code_part): - findings.append({ - "line": lineno, - "name": sn, - "context": stripped[:80], - "value": stripped[:80], - "kind": "hardcoded_branch", - }) + findings.append( + { + "line": lineno, + "name": sn, + "context": stripped[:80], + "value": stripped[:80], + "kind": "hardcoded_branch", + } + ) return findings @@ -388,6 +406,7 @@ def _scan_file_regex( # PUBLIC SCAN INTERFACE # ============================================================================= + def scan(pack_dir: Path) -> dict: """Run the full plugin integrity scan. @@ -472,8 +491,7 @@ def scan(pack_dir: Path) -> dict: "hardcoded_branch": "branch condition", }.get(str(finding["kind"]), str(finding["kind"])) issues.append( - f"{label} L{finding['line']}: hardcoded {kind_label} " - f"referencing standard '{finding['name']}'" + f"{label} L{finding['line']}: hardcoded {kind_label} referencing standard '{finding['name']}'" ) else: result["status"] = "clean" diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/readme_currency.py b/src/aipass/seedgo/apps/handlers/aipass_proof/readme_currency.py index 4e799e7b..18fc6ac5 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/readme_currency.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/readme_currency.py @@ -87,9 +87,7 @@ def _extract_readme_standard_names(readme_text: str) -> set[str]: names: set[str] = set() # Parse the comma/and-separated list in the Checker Packs section. - checks_pattern = re.compile( - r"pack\s+checks?:\s*(.+?)(?:\.|$)", re.IGNORECASE | re.DOTALL - ) + checks_pattern = re.compile(r"pack\s+checks?:\s*(.+?)(?:\.|$)", re.IGNORECASE | re.DOTALL) match = checks_pattern.search(readme_text) if match: raw_list = match.group(1) @@ -167,8 +165,7 @@ def scan(pack_dir: Path) -> dict: for ref in count_refs: if ref["number"] != actual_check_count: issues.append( - f"Line {ref['line']}: README says {ref['number']} but " - f"actual count is {actual_check_count}" + f"Line {ref['line']}: README says {ref['number']} but actual count is {actual_check_count}" ) # -- Name references -- @@ -207,10 +204,7 @@ def scan(pack_dir: Path) -> dict: # -- Summary -- if passed: - summary = ( - f"README is current. {actual_check_count} checkers, " - f"all documented, no stale references." - ) + summary = f"README is current. {actual_check_count} checkers, all documented, no stale references." else: parts: list[str] = [] if count_mismatch: diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py b/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py index 6938ce68..f8f870e2 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py @@ -32,13 +32,7 @@ def _top_level_files(pack_dir: Path) -> list[Path]: """Return all regular files directly in *pack_dir*, skipping hidden/skip dirs.""" if not pack_dir.is_dir(): return [] - return [ - p - for p in pack_dir.iterdir() - if p.is_file() - and p.name not in _SKIP_DIRS - and not p.name.startswith("_") - ] + return [p for p in pack_dir.iterdir() if p.is_file() and p.name not in _SKIP_DIRS and not p.name.startswith("_")] def scan(pack_dir: Path) -> dict: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py index 21fa98f6..1b97bd99 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py @@ -39,14 +39,14 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -79,42 +79,42 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'architecture', bypass_rules=bypass_rules): + if is_bypassed(module_path, "architecture", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'ARCHITECTURE' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "ARCHITECTURE", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'ARCHITECTURE' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "ARCHITECTURE", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'ARCHITECTURE' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "ARCHITECTURE", } # Determine file location and type - is_entry_point = path.name.endswith('.py') and 'apps/' in module_path and path.parent.name == 'apps' - is_module = 'apps/modules/' in module_path - is_handler = 'apps/handlers/' in module_path - is_init = path.name == '__init__.py' + is_entry_point = path.name.endswith(".py") and "apps/" in module_path and path.parent.name == "apps" + is_module = "apps/modules/" in module_path + is_handler = "apps/handlers/" in module_path + is_init = path.name == "__init__.py" # Check 1: 3-Layer Pattern - File location if not is_init: @@ -147,20 +147,17 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.extend(baseline_checks) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 # Overall pass if score >= 75% overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "architecture"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'ARCHITECTURE' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "architecture"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "ARCHITECTURE"} def check_layer_location(module_path: str, is_entry_point: bool, is_module: bool, is_handler: bool) -> Dict: @@ -173,28 +170,16 @@ def check_layer_location(module_path: str, is_entry_point: bool, is_module: bool - apps/handlers/ (implementation) """ if is_entry_point: - return { - 'name': '3-layer pattern', - 'passed': True, - 'message': 'Entry point layer (apps/branch.py)' - } + return {"name": "3-layer pattern", "passed": True, "message": "Entry point layer (apps/branch.py)"} elif is_module: - return { - 'name': '3-layer pattern', - 'passed': True, - 'message': 'Module layer (apps/modules/)' - } + return {"name": "3-layer pattern", "passed": True, "message": "Module layer (apps/modules/)"} elif is_handler: - return { - 'name': '3-layer pattern', - 'passed': True, - 'message': 'Handler layer (apps/handlers/)' - } + return {"name": "3-layer pattern", "passed": True, "message": "Handler layer (apps/handlers/)"} else: return { - 'name': '3-layer pattern', - 'passed': False, - 'message': 'File not in standard 3-layer structure (apps/, apps/modules/, apps/handlers/)' + "name": "3-layer pattern", + "passed": False, + "message": "File not in standard 3-layer structure (apps/, apps/modules/, apps/handlers/)", } @@ -211,28 +196,16 @@ def check_file_size(lines: List[str], module_path: str) -> Dict: line_count = len(lines) if line_count < 300: - return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (perfect - under 300)' - } + return {"name": "File size", "passed": True, "message": f"{line_count} lines (perfect - under 300)"} elif line_count < 500: - return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (good - under 500)' - } + return {"name": "File size", "passed": True, "message": f"{line_count} lines (good - under 500)"} elif line_count < 700: - return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (acceptable but getting heavy)' - } + return {"name": "File size", "passed": True, "message": f"{line_count} lines (acceptable but getting heavy)"} else: return { - 'name': 'File size', - 'passed': False, - 'message': f'{line_count} lines (consider splitting - recommended under 700)' + "name": "File size", + "passed": False, + "message": f"{line_count} lines (consider splitting - recommended under 700)", } @@ -250,8 +223,8 @@ def check_handler_independence(lines: List[str], module_path: str) -> Optional[D if module_path: path_parts = Path(module_path).parts for i, part in enumerate(path_parts): - if part == 'apps' and i > 0: - parent_branch = path_parts[i-1] + if part == "apps" and i > 0: + parent_branch = path_parts[i - 1] break in_docstring = False @@ -271,43 +244,39 @@ def check_handler_independence(lines: List[str], module_path: str) -> Optional[D in_docstring = not in_docstring # Skip docstrings, comments and empty lines - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for forbidden module imports - if '.apps.modules' in line and ('from ' in line or 'import ' in line): + if ".apps.modules" in line and ("from " in line or "import " in line): # Extract the import statement - if '#' in line: - code_part = line.split('#')[0] + if "#" in line: + code_part = line.split("#")[0] else: code_part = line - if '.apps.modules' in code_part: + if ".apps.modules" in code_part: # Allowed service imports - if 'prax.apps.modules' in code_part or 'cli.apps.modules' in code_part: + if "prax.apps.modules" in code_part or "cli.apps.modules" in code_part: continue # Check if importing from parent branch - if parent_branch and f'{parent_branch}.apps.modules' in code_part: + if parent_branch and f"{parent_branch}.apps.modules" in code_part: return { - 'name': 'Handler independence', - 'passed': False, - 'message': f'Handler importing from parent module on line {i} (violates independence)' + "name": "Handler independence", + "passed": False, + "message": f"Handler importing from parent module on line {i} (violates independence)", } # Generic check if no parent branch detected if not parent_branch: return { - 'name': 'Handler independence', - 'passed': False, - 'message': f'Handler importing from branch module on line {i} (violates independence)' + "name": "Handler independence", + "passed": False, + "message": f"Handler importing from branch module on line {i} (violates independence)", } - return { - 'name': 'Handler independence', - 'passed': True, - 'message': 'No forbidden module imports detected' - } + return {"name": "Handler independence", "passed": True, "message": "No forbidden module imports detected"} def check_domain_organization(module_path: str) -> Optional[Dict]: @@ -324,32 +293,24 @@ def check_domain_organization(module_path: str) -> Optional[Dict]: # Find 'handlers' in path and get next directory handler_domain = None for i, part in enumerate(path_parts): - if part == 'handlers' and i + 1 < len(path_parts): + if part == "handlers" and i + 1 < len(path_parts): handler_domain = path_parts[i + 1] break if not handler_domain: - return { - 'name': 'Domain organization', - 'passed': False, - 'message': 'Could not detect handler domain from path' - } + return {"name": "Domain organization", "passed": False, "message": "Could not detect handler domain from path"} # Check for technical (bad) organization - technical_names = ['utils', 'helpers', 'operations', 'common', 'shared', 'lib'] + technical_names = ["utils", "helpers", "operations", "common", "shared", "lib"] if handler_domain.lower() in technical_names: return { - 'name': 'Domain organization', - 'passed': False, - 'message': f'Technical organization ({handler_domain}/) - use business domains instead' + "name": "Domain organization", + "passed": False, + "message": f"Technical organization ({handler_domain}/) - use business domains instead", } # Domain-based organization detected - return { - 'name': 'Domain organization', - 'passed': True, - 'message': f'Domain-based organization ({handler_domain}/)' - } + return {"name": "Domain organization", "passed": True, "message": f"Domain-based organization ({handler_domain}/)"} def _load_ignore_patterns(template_path: Path) -> Dict: @@ -360,12 +321,9 @@ def _load_ignore_patterns(template_path: Path) -> Dict: return {"ignore_files": [], "ignore_patterns": []} try: - with open(ignore_file, 'r', encoding='utf-8') as f: + with open(ignore_file, "r", encoding="utf-8") as f: data = json.load(f) - return { - "ignore_files": data.get("ignore_files", []), - "ignore_patterns": data.get("ignore_patterns", []) - } + return {"ignore_files": data.get("ignore_files", []), "ignore_patterns": data.get("ignore_patterns", [])} except Exception: logger.info("Cannot read ignore config: %s", ignore_file) return {"ignore_files": [], "ignore_patterns": []} @@ -381,11 +339,11 @@ def _should_ignore(item: Path, ignore_config: Dict) -> bool: # Check patterns for pattern in ignore_config["ignore_patterns"]: - if pattern.startswith('*'): + if pattern.startswith("*"): if name.endswith(pattern[1:]): return True - elif pattern.startswith('.') and '*' in pattern: - prefix = pattern.rstrip('*') + elif pattern.startswith(".") and "*" in pattern: + prefix = pattern.rstrip("*") if name.startswith(prefix): return True else: @@ -401,7 +359,7 @@ def _get_citizen_class(branch_path: Path) -> Optional[str]: if not passport.exists(): return None try: - with open(passport, 'r', encoding='utf-8') as f: + with open(passport, "r", encoding="utf-8") as f: data = json.load(f) return data.get("identity", {}).get("citizen_class") except Exception: @@ -418,7 +376,7 @@ def _scan_template(template_path: Path) -> Dict: ignore_config = _load_ignore_patterns(template_path) structure = {"directories": [], "files": []} - for item in sorted(template_path.rglob('*')): + for item in sorted(template_path.rglob("*")): if _should_ignore(item, ignore_config): continue @@ -442,7 +400,7 @@ def _transform_path(template_relative: str, branch_name: str) -> str: to their branch-specific names. """ branch_lower = branch_name.lower().replace("-", "_") - entry_point_name = branch_name.lstrip('.').lower() + entry_point_name = branch_name.lstrip(".").lower() # {{BRANCH}} in directory names uses lowercase (e.g., {{BRANCH}}_json → seedgo_json) result = template_relative.replace("{{BRANCH}}", branch_lower) @@ -481,44 +439,48 @@ def check_template_baseline(module_path: str, bypass_rules: list | None = None) branch_path = None current = path.parent while current != current.parent: - if current.name == 'apps' and current.parent: + if current.name == "apps" and current.parent: branch_path = current.parent break current = current.parent if not branch_path: - return [{ - 'name': 'Template baseline', - 'passed': False, - 'message': 'Could not detect branch path from module path' - }] + return [ + {"name": "Template baseline", "passed": False, "message": "Could not detect branch path from module path"} + ] branch_name = branch_path.name # Read citizen class from passport citizen_class = _get_citizen_class(branch_path) if not citizen_class: - return [{ - 'name': 'Template baseline', - 'passed': False, - 'message': f'No citizen_class in {branch_name}/.trinity/passport.json' - }] + return [ + { + "name": "Template baseline", + "passed": False, + "message": f"No citizen_class in {branch_name}/.trinity/passport.json", + } + ] # Find the matching template directory if not SPAWN_TEMPLATES_DIR.exists(): - return [{ - 'name': 'Template baseline', - 'passed': False, - 'message': f'Spawn templates directory not found: {SPAWN_TEMPLATES_DIR}' - }] + return [ + { + "name": "Template baseline", + "passed": False, + "message": f"Spawn templates directory not found: {SPAWN_TEMPLATES_DIR}", + } + ] template_path = SPAWN_TEMPLATES_DIR / citizen_class if not template_path.exists(): - return [{ - 'name': 'Template baseline', - 'passed': False, - 'message': f'No template for citizen_class "{citizen_class}" at {template_path}' - }] + return [ + { + "name": "Template baseline", + "passed": False, + "message": f'No template for citizen_class "{citizen_class}" at {template_path}', + } + ] # Scan template live template_structure = _scan_template(template_path) @@ -529,24 +491,20 @@ def check_template_baseline(module_path: str, bypass_rules: list | None = None) full = branch_path / expected if full.exists(): - checks.append({ - 'name': f'Dir: {expected}/', - 'passed': True, - 'message': 'Template directory exists' - }) + checks.append({"name": f"Dir: {expected}/", "passed": True, "message": "Template directory exists"}) else: - if is_bypassed(expected, 'architecture', bypass_rules=bypass_rules): - checks.append({ - 'name': f'Dir: {expected}/', - 'passed': True, - 'message': 'Template directory missing (bypassed)' - }) + if is_bypassed(expected, "architecture", bypass_rules=bypass_rules): + checks.append( + {"name": f"Dir: {expected}/", "passed": True, "message": "Template directory missing (bypassed)"} + ) else: - checks.append({ - 'name': f'Dir: {expected}/', - 'passed': False, - 'message': f'Missing dir: {expected}/ (template: {citizen_class})' - }) + checks.append( + { + "name": f"Dir: {expected}/", + "passed": False, + "message": f"Missing dir: {expected}/ (template: {citizen_class})", + } + ) # Check files for template_file in template_structure["files"]: @@ -554,33 +512,32 @@ def check_template_baseline(module_path: str, bypass_rules: list | None = None) full = branch_path / expected if full.exists(): - checks.append({ - 'name': f'File: {expected}', - 'passed': True, - 'message': 'Template file exists' - }) + checks.append({"name": f"File: {expected}", "passed": True, "message": "Template file exists"}) else: file_name = Path(expected).name - if is_bypassed(file_name, 'architecture', bypass_rules=bypass_rules): - checks.append({ - 'name': f'File: {expected}', - 'passed': True, - 'message': 'Template file missing (bypassed)' - }) + if is_bypassed(file_name, "architecture", bypass_rules=bypass_rules): + checks.append( + {"name": f"File: {expected}", "passed": True, "message": "Template file missing (bypassed)"} + ) else: - checks.append({ - 'name': f'File: {expected}', - 'passed': False, - 'message': f'Missing file: {expected} (template: {citizen_class})' - }) + checks.append( + { + "name": f"File: {expected}", + "passed": False, + "message": f"Missing file: {expected} (template: {citizen_class})", + } + ) # Summary check - missing_count = sum(1 for c in checks if not c['passed']) + missing_count = sum(1 for c in checks if not c["passed"]) total_count = len(checks) - checks.insert(0, { - 'name': f'Template baseline ({citizen_class})', - 'passed': missing_count == 0, - 'message': f'{total_count} items checked from spawn/templates/{citizen_class}/, {missing_count} missing' - }) + checks.insert( + 0, + { + "name": f"Template baseline ({citizen_class})", + "passed": missing_count == 0, + "message": f"{total_count} items checked from spawn/templates/{citizen_class}/, {missing_count} missing", + }, + ) return checks diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_content.py index 88fb3f65..f98f0c27 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_content.py @@ -15,6 +15,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_architecture_standards() -> str: """Return formatted architecture standards content with Rich markup diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py index 0aa7cfe1..3ed909a7 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py @@ -23,20 +23,21 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -69,41 +70,41 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'cli', bypass_rules=bypass_rules): + if is_bypassed(module_path, "cli", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'CLI' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "CLI", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'CLI' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "CLI", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'CLI' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "CLI", } # Determine file type (handle both absolute and relative paths) - is_handler = 'handlers/' in module_path - is_module = 'modules/' in module_path - is_entry_point = path.name.endswith('.py') and 'apps/' in module_path and path.parent.name == 'apps' + is_handler = "handlers/" in module_path + is_module = "modules/" in module_path + is_entry_point = path.name.endswith(".py") and "apps/" in module_path and path.parent.name == "apps" # Check 1: Handler separation (handlers must NOT have console output) if is_handler: @@ -138,14 +139,20 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # treat as passing — these files are not subject to CLI standards if not checks: return { - 'passed': True, - 'checks': [{'name': 'CLI check', 'passed': True, 'message': 'File outside module/handler/entry architecture (skipped)'}], - 'score': 100, - 'standard': 'CLI' + "passed": True, + "checks": [ + { + "name": "CLI check", + "passed": True, + "message": "File outside module/handler/entry architecture (skipped)", + } + ], + "score": 100, + "standard": "CLI", } # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 @@ -153,25 +160,20 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "cli"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'CLI' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "CLI"} def _console_print_in_string(line: str, stripped: str) -> bool: - before_pattern = line.split('console.print(')[0] + before_pattern = line.split("console.print(")[0] single_quotes = before_pattern.count("'") double_quotes = before_pattern.count('"') if single_quotes % 2 == 1 or double_quotes % 2 == 1: return True - if '=' in stripped and 'console.print(' in stripped: - before_console = stripped.split('console.print(')[0] - last_eq_pos = before_console.rfind('=') + if "=" in stripped and "console.print(" in stripped: + before_console = stripped.split("console.print(")[0] + last_eq_pos = before_console.rfind("=") if last_eq_pos != -1: - after_eq = before_console[last_eq_pos+1:] + after_eq = before_console[last_eq_pos + 1 :] sq_after = after_eq.count("'") dq_after = after_eq.count('"') if sq_after % 2 == 1 or dq_after % 2 == 1: @@ -187,7 +189,7 @@ def check_handler_separation(content: str) -> Dict: Only checks actual code (not strings or comments) Excludes: if __name__ == '__main__': blocks (test/debug code is OK) """ - lines = content.split('\n') + lines = content.split("\n") # Find code section boundaries (skip docstrings and comments) in_docstring = False @@ -233,56 +235,52 @@ def check_handler_separation(content: str) -> Dict: continue # Skip if in docstring or comment - if in_docstring or stripped.startswith('#'): + if in_docstring or stripped.startswith("#"): continue # Look for actual console.print() calls # Must be actual code, not in a string - if 'console.print(' in stripped: + if "console.print(" in stripped: if _console_print_in_string(line, stripped): continue # This is likely an actual call console_print_lines.append(i) # Look for actual CLI service imports (canonical, sub-module, or shortcut) - if re.search(r'from aipass\.cli\.apps\.modules[\.\s]', stripped) or 'from aipass.cli import' in stripped: + if re.search(r"from aipass\.cli\.apps\.modules[\.\s]", stripped) or "from aipass.cli import" in stripped: # Skip if in a string - if ('"from aipass.cli' in line or "'from aipass.cli" in line): + if '"from aipass.cli' in line or "'from aipass.cli" in line: continue # This is likely an actual import cli_import_lines.append(i) # Look for print() calls - if re.search(r'^\s*print\s*\(', line): + if re.search(r"^\s*print\s*\(", line): # This is an actual print call at start of line (not in string) print_lines.append(i) if console_print_lines: return { - 'name': 'Handler separation', - 'passed': False, - 'message': f'Handler contains console.print() on lines {console_print_lines[:3]} (violates separation)' + "name": "Handler separation", + "passed": False, + "message": f"Handler contains console.print() on lines {console_print_lines[:3]} (violates separation)", } if cli_import_lines: return { - 'name': 'Handler separation', - 'passed': False, - 'message': f'Handler imports CLI services on lines {cli_import_lines[:3]} (handlers should not display)' + "name": "Handler separation", + "passed": False, + "message": f"Handler imports CLI services on lines {cli_import_lines[:3]} (handlers should not display)", } if print_lines: return { - 'name': 'Handler separation', - 'passed': False, - 'message': f'Handler contains print() on lines {print_lines[:3]} (use logger instead)' + "name": "Handler separation", + "passed": False, + "message": f"Handler contains print() on lines {print_lines[:3]} (use logger instead)", } - return { - 'name': 'Handler separation', - 'passed': True, - 'message': 'No console output detected (good separation)' - } + return {"name": "Handler separation", "passed": True, "message": "No console output detected (good separation)"} def check_cli_imports(content: str, module_path: str = "") -> Optional[Dict]: @@ -293,55 +291,47 @@ def check_cli_imports(content: str, module_path: str = "") -> Optional[Dict]: Exception: CLI branch itself uses internal imports """ # Exception: CLI branch uses internal imports (it's the implementation) - if '/cli/apps/' in module_path: - return { - 'name': 'CLI service imports', - 'passed': True, - 'message': 'CLI branch exempt (uses internal imports)' - } + if "/cli/apps/" in module_path: + return {"name": "CLI service imports", "passed": True, "message": "CLI branch exempt (uses internal imports)"} # Check for CLI imports (canonical, sub-module, or shortcut via cli/__init__.py) # Matches: from aipass.cli.apps.modules import X # from aipass.cli.apps.modules.display import X # from aipass.cli import X - has_cli_imports = bool(re.search(r'from aipass\.cli\.apps\.modules[\.\s]', content)) or 'from aipass.cli import' in content + has_cli_imports = ( + bool(re.search(r"from aipass\.cli\.apps\.modules[\.\s]", content)) or "from aipass.cli import" in content + ) if has_cli_imports: # Check what's imported (canonical full path, sub-module path, or shortcut via __init__.py) import_match = ( - re.search(r'from aipass\.cli\.apps\.modules\.\w+ import (.+)', content) - or re.search(r'from aipass\.cli\.apps\.modules import (.+)', content) - or re.search(r'from aipass\.cli import (.+)', content) + re.search(r"from aipass\.cli\.apps\.modules\.\w+ import (.+)", content) + or re.search(r"from aipass\.cli\.apps\.modules import (.+)", content) + or re.search(r"from aipass\.cli import (.+)", content) ) if import_match: imports = import_match.group(1) - return { - 'name': 'CLI service imports', - 'passed': True, - 'message': f'Using CLI services ({imports})' - } + return {"name": "CLI service imports", "passed": True, "message": f"Using CLI services ({imports})"} # No CLI imports found - check if there's any output at all - has_console_print = 'console.print(' in content - has_print = bool(re.search(r'\bprint\s*\(', content)) - has_raw_write = 'sys.stdout.write(' in content or 'sys.stderr.write(' in content + has_console_print = "console.print(" in content + has_print = bool(re.search(r"\bprint\s*\(", content)) + has_raw_write = "sys.stdout.write(" in content or "sys.stderr.write(" in content if has_console_print or has_print or has_raw_write: return { - 'name': 'CLI service imports', - 'passed': False, - 'message': 'Has output but missing CLI service imports (import from cli.apps.modules)' + "name": "CLI service imports", + "passed": False, + "message": "Has output but missing CLI service imports (import from cli.apps.modules)", } # No output at all - that's fine for some modules - return { - 'name': 'CLI service imports', - 'passed': True, - 'message': 'No CLI output needed' - } + return {"name": "CLI service imports", "passed": True, "message": "No CLI output needed"} -def check_print_usage(content: str, lines: List[str], module_path: str = "", bypass_rules: list | None = None) -> Optional[Dict]: +def check_print_usage( + content: str, lines: List[str], module_path: str = "", bypass_rules: list | None = None +) -> Optional[Dict]: """ Check for bare print() statements (should use console.print() instead) @@ -385,78 +375,75 @@ def check_print_usage(content: str, lines: List[str], module_path: str = "", byp continue # Skip comments - if stripped.startswith('#'): + if stripped.startswith("#"): continue # Skip bypassed lines - if is_bypassed(module_path, 'cli', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "cli", line=i, bypass_rules=bypass_rules): continue # Check for parser.print_help() - uses plain print() internally - if 'parser.print_help()' in stripped: + if "parser.print_help()" in stripped: # Skip if in a comment - if '#' in line: - code_part = line.split('#')[0] - if 'parser.print_help()' in code_part: + if "#" in line: + code_part = line.split("#")[0] + if "parser.print_help()" in code_part: parser_print_help_lines.append(i) else: parser_print_help_lines.append(i) # Check for raw sys.stdout.write() / sys.stderr.write() (bypasses Rich) - if 'sys.stdout.write(' in stripped or 'sys.stderr.write(' in stripped: - if '#' in line: - code_part = line.split('#')[0] - if 'sys.stdout.write(' in code_part or 'sys.stderr.write(' in code_part: + if "sys.stdout.write(" in stripped or "sys.stderr.write(" in stripped: + if "#" in line: + code_part = line.split("#")[0] + if "sys.stdout.write(" in code_part or "sys.stderr.write(" in code_part: raw_write_lines.append(i) else: raw_write_lines.append(i) # Use regex to find BARE print() - not preceded by . or word character # This excludes: console.print(), logger.print(), pprint(), etc. - if re.search(r'(? 3 else ""}' + "name": "print() usage", + "passed": False, + "message": f"Found {len(raw_write_lines)} sys.stdout/stderr.write() in {filename} (use console.print() instead) on lines {raw_write_lines[:3]}{'...' if len(raw_write_lines) > 3 else ''}", } if print_lines: return { - 'name': 'print() usage', - 'passed': False, - 'message': f'Found {len(print_lines)} print() statements in {filename} (use console.print() instead) on lines {print_lines[:3]}{"..." if len(print_lines) > 3 else ""}' + "name": "print() usage", + "passed": False, + "message": f"Found {len(print_lines)} print() statements in {filename} (use console.print() instead) on lines {print_lines[:3]}{'...' if len(print_lines) > 3 else ''}", } # Check if using console.print() - has_console_print = 'console.print(' in content + has_console_print = "console.print(" in content if has_console_print: - return { - 'name': 'print() usage', - 'passed': True, - 'message': 'Using console.print() (no bare print() found)' - } + return {"name": "print() usage", "passed": True, "message": "Using console.print() (no bare print() found)"} return None # No output at all @@ -468,29 +455,25 @@ def check_help_flag(content: str) -> Optional[Dict]: Modules should respond to --help """ # Look for --help handling - has_help_flag = '--help' in content and ('-h' in content or 'help' in content) + has_help_flag = "--help" in content and ("-h" in content or "help" in content) # Look for argparse usage - has_argparse = 'argparse.ArgumentParser' in content or 'import argparse' in content + has_argparse = "argparse.ArgumentParser" in content or "import argparse" in content # Look for print_help function - has_print_help = 'def print_help' in content + has_print_help = "def print_help" in content if has_print_help or (has_help_flag and has_argparse): - return { - 'name': '--help flag', - 'passed': True, - 'message': '--help flag implemented' - } + return {"name": "--help flag", "passed": True, "message": "--help flag implemented"} # Check if it's a simple module that might not need --help - if '__main__' not in content: + if "__main__" not in content: return None # Not an executable module return { - 'name': '--help flag', - 'passed': False, - 'message': '--help flag not implemented (modules should respond to --help)' + "name": "--help flag", + "passed": False, + "message": "--help flag not implemented (modules should respond to --help)", } @@ -503,32 +486,28 @@ def check_duplicate_display_functions(content: str, module_path: str = "") -> Op Exception: CLI branch itself defines these functions. """ # Exception: CLI branch defines these functions - if '/cli/apps/' in module_path: + if "/cli/apps/" in module_path: return None # Exception: Prax logger IS the logging system — it defines these legitimately - if '/prax/apps/modules/logger' in module_path: + if "/prax/apps/modules/logger" in module_path: return None # Display functions that CLI service provides - cli_display_functions = ['header', 'success', 'error', 'warning', 'info'] + cli_display_functions = ["header", "success", "error", "warning", "info"] # Look for local function definitions that duplicate CLI service duplicates_found = [] for func_name in cli_display_functions: # Check for "def header(" pattern - if f'def {func_name}(' in content: + if f"def {func_name}(" in content: duplicates_found.append(func_name) if duplicates_found: return { - 'name': 'CLI display functions', - 'passed': False, - 'message': f'Defines own {", ".join(duplicates_found)}() - use from cli.apps.modules.display import {", ".join(duplicates_found)}' + "name": "CLI display functions", + "passed": False, + "message": f"Defines own {', '.join(duplicates_found)}() - use from cli.apps.modules.display import {', '.join(duplicates_found)}", } - return { - 'name': 'CLI display functions', - 'passed': True, - 'message': 'No duplicate CLI display functions defined' - } + return {"name": "CLI display functions", "passed": True, "message": "No duplicate CLI display functions defined"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py index 7950fdae..e356db2b 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py @@ -15,6 +15,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_cli_standards() -> str: """Return formatted CLI standards content with Rich markup @@ -27,9 +28,8 @@ def get_cli_standards() -> str: "[yellow]POLICY:[/yellow] Rich formatting is THE standard for ALL AIPass output", "", "[green]✓ Approved:[/green]", - " [dim]from aipass.cli.apps.modules" - " import console[/dim]", - " [dim]console.print(\"[cyan]This is the ONLY approved way[/cyan]\")[/dim]", + " [dim]from aipass.cli.apps.modules import console[/dim]", + ' [dim]console.print("[cyan]This is the ONLY approved way[/cyan]")[/dim]', "", "[red]✗ Deprecated:[/red] Bare print() statements", " • Only in test/temp code", @@ -55,10 +55,8 @@ def get_cli_standards() -> str: "[yellow]RULE:[/yellow] Build BOTH or neither", "", "[bold cyan]CLI SERVICE PROVIDER:[/bold cyan]", - " [dim]from aipass.cli.apps.modules" - " import console, header, success, error[/dim]", - " [dim]from aipass.cli.apps.modules" - " import operation_start, track_operation[/dim]", + " [dim]from aipass.cli.apps.modules import console, header, success, error[/dim]", + " [dim]from aipass.cli.apps.modules import operation_start, track_operation[/dim]", "", " • Consistent formatting across all branches", " • Update CLI once → affects entire system", @@ -95,9 +93,9 @@ def get_cli_standards() -> str: " [bold red]bold red[/bold red], [bold cyan]bold cyan[/bold cyan], [dim yellow]dim yellow[/dim yellow]", "", "[bold]Usage:[/bold]", - " [dim]console.print(\"[bold green]Success![/bold green]\")[/dim]", - " [dim]console.print(\"[yellow]Warning:[/yellow] Check this\")[/dim]", - " [dim]console.print(\"[dim]Additional info...[/dim]\")[/dim]", + ' [dim]console.print("[bold green]Success![/bold green]")[/dim]', + ' [dim]console.print("[yellow]Warning:[/yellow] Check this")[/dim]', + ' [dim]console.print("[dim]Additional info...[/dim]")[/dim]', "", "[bold]Emojis:[/bold]", " ✅ Success ❌ Error ⚠️ Warning ℹ️ Info", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_check.py index c2f0b186..5ed60fb3 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_check.py @@ -31,17 +31,18 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: entry points only (apps/{name}.py) AUDIT_SCOPE = "entry_point" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -68,44 +69,44 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'cli_flags', bypass_rules=bypass_rules): + if is_bypassed(module_path, "cli_flags", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'CLI_FLAGS' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "CLI_FLAGS", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'CLI_FLAGS' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "CLI_FLAGS", } # Only check entry point files — files whose parent directory is "apps" - if path.parent.name != 'apps': + if path.parent.name != "apps": return { - 'passed': True, - 'checks': [{'name': 'Entry point check', 'passed': True, 'message': 'Not an entry point (skipped)'}], - 'score': 100, - 'standard': 'CLI_FLAGS' + "passed": True, + "checks": [{"name": "Entry point check", "passed": True, "message": "Not an entry point (skipped)"}], + "score": 100, + "standard": "CLI_FLAGS", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'CLI_FLAGS' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "CLI_FLAGS", } # Check 1: --version / -V flag support @@ -113,18 +114,13 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(version_flag_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "cli_flags"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'CLI_FLAGS' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "CLI_FLAGS"} def _get_non_code_lines(lines: List[str]) -> set: @@ -153,7 +149,7 @@ def _get_non_code_lines(lines: List[str]) -> set: skip.add(i) continue - if in_docstring or stripped.startswith('#'): + if in_docstring or stripped.startswith("#"): skip.add(i) return skip @@ -169,12 +165,8 @@ def check_version_flag(lines: List[str], file_path: str, bypass_rules: list | No If either pattern is found, the check passes. """ - if is_bypassed(file_path, 'cli_flags', None, bypass_rules): - return { - 'name': '--version flag support', - 'passed': True, - 'message': 'Bypassed by bypass rules' - } + if is_bypassed(file_path, "cli_flags", None, bypass_rules): + return {"name": "--version flag support", "passed": True, "message": "Bypassed by bypass rules"} skip_lines = _get_non_code_lines(lines) @@ -191,13 +183,13 @@ def check_version_flag(lines: List[str], file_path: str, bypass_rules: list | No for pattern in version_patterns: if re.search(pattern, line): return { - 'name': '--version flag support', - 'passed': True, - 'message': f'Found version flag handling on line {i}' + "name": "--version flag support", + "passed": True, + "message": f"Found version flag handling on line {i}", } return { - 'name': '--version flag support', - 'passed': False, - 'message': 'Entry point missing --version / -V flag support' + "name": "--version flag support", + "passed": False, + "message": "Entry point missing --version / -V flag support", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_content.py index 0e94ec6f..3a67961c 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_flags_content.py @@ -14,6 +14,7 @@ Provides Rich-formatted reference text for the CLI flags standard. from aipass.seedgo.apps.handlers.json import json_handler + def get_cli_flags_standards() -> str: """Return Rich-formatted CLI flags standards text""" json_handler.log_operation("standard_content_queried", {"standard": "cli_flags"}) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_check.py index 2ed07697..d1d870d1 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_check.py @@ -33,9 +33,7 @@ from aipass.seedgo.apps.handlers.json import json_handler AUDIT_SCOPE = "all_files" # Regex extracted from devpulse commented_logger_scanner_v1.py -_COMMENTED_LOGGER_RE = re.compile( - r"#\s*logger\.(error|warning|warn|info|exception|critical|debug)\s*\(" -) +_COMMENTED_LOGGER_RE = re.compile(r"#\s*logger\.(error|warning|warn|info|exception|critical|debug)\s*\(") def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: @@ -43,12 +41,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -79,43 +77,43 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # --- bypass ----------------------------------------------------------- - if is_bypassed(module_path, 'commented_logger', bypass_rules=bypass_rules): + if is_bypassed(module_path, "commented_logger", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'COMMENTED_LOGGER' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "COMMENTED_LOGGER", } # --- skip non-.py and __init__.py ------------------------------------- - if path.suffix != '.py' or path.name == '__init__.py': + if path.suffix != ".py" or path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'Commented logger calls', 'passed': True, 'message': 'File skipped (non-target)'}], - 'score': 100, - 'standard': 'COMMENTED_LOGGER' + "passed": True, + "checks": [{"name": "Commented logger calls", "passed": True, "message": "File skipped (non-target)"}], + "score": 100, + "standard": "COMMENTED_LOGGER", } # --- file exists ------------------------------------------------------ if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'COMMENTED_LOGGER' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "COMMENTED_LOGGER", } # --- read file -------------------------------------------------------- try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: source = f.read() except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'COMMENTED_LOGGER' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "COMMENTED_LOGGER", } # --- scan for commented-out logger calls, skipping docstrings --------- @@ -144,32 +142,29 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: violation_count = len(violation_lines) if violation_count == 0: - checks.append({ - 'name': 'Commented logger calls', - 'passed': True, - 'message': 'No commented-out logger calls found' - }) + checks.append( + {"name": "Commented logger calls", "passed": True, "message": "No commented-out logger calls found"} + ) else: first_three = violation_lines[:3] - line_preview = ', '.join(str(ln) for ln in first_three) - suffix = f' (and {violation_count - 3} more)' if violation_count > 3 else '' - checks.append({ - 'name': 'Commented logger calls', - 'passed': False, - 'message': f'{violation_count} commented-out logger call(s) on lines {line_preview}{suffix} -- restore or remove' - }) + line_preview = ", ".join(str(ln) for ln in first_three) + suffix = f" (and {violation_count - 3} more)" if violation_count > 3 else "" + checks.append( + { + "name": "Commented logger calls", + "passed": False, + "message": f"{violation_count} commented-out logger call(s) on lines {line_preview}{suffix} -- restore or remove", + } + ) # --- score ------------------------------------------------------------ - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks) * 100) if total_checks > 0 else 0 overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "commented_logger"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'COMMENTED_LOGGER' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "commented_logger"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "COMMENTED_LOGGER"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_content.py index a4e5f093..312e5e6e 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/commented_logger_content.py @@ -25,8 +25,7 @@ def get_commented_logger_standards() -> str: lines = [ "[bold cyan]CORE PRINCIPLE:[/bold cyan]", " Commented-out logger calls are dead logging -- noise that", - " obscures real code. Either [green]restore[/green] them or" - " [green]remove[/green] them entirely.", + " obscures real code. Either [green]restore[/green] them or [green]remove[/green] them entirely.", "", "[bold cyan]WHAT IT CHECKS:[/bold cyan]", " Scans every .py file (except __init__.py) for lines matching:", @@ -41,21 +40,20 @@ def get_commented_logger_standards() -> str: " The regex detects [dim]# logger.([/dim] where level is one of:", " error, warning, warn, info, exception, critical, debug.", "", - " [yellow]Docstrings are skipped[/yellow] -- triple-quoted regions are" - " tracked and", + " [yellow]Docstrings are skipped[/yellow] -- triple-quoted regions are tracked and", " excluded so documented examples do not trigger false positives.", "", "[bold cyan]VIOLATIONS:[/bold cyan]", " Any commented-out logger call outside a docstring is a violation.", "", " [red]Bad:[/red]", - " [dim]# logger.info(\"Processing started\")[/dim]", - " [dim]# logger.error(f\"Failed: {err}\")[/dim]", - " [dim]# logger.debug(\"Step completed\")[/dim]", + ' [dim]# logger.info("Processing started")[/dim]', + ' [dim]# logger.error(f"Failed: {err}")[/dim]', + ' [dim]# logger.debug("Step completed")[/dim]', "", " [green]Good -- either restore:[/green]", - " [dim]logger.info(\"Processing started\")[/dim]", - " [dim]logger.error(f\"Failed: {err}\")[/dim]", + ' [dim]logger.info("Processing started")[/dim]', + ' [dim]logger.error(f"Failed: {err}")[/dim]', "", " [green]Good -- or remove entirely:[/green]", " [dim](line deleted)[/dim]", @@ -76,14 +74,13 @@ def get_commented_logger_standards() -> str: " entry point). Files that are not .py or are __init__.py are skipped.", "", "[bold cyan]SCORING:[/bold cyan]", - " Single check per file: [green]pass[/green] (0 violations) or" - " [red]fail[/red] (any violations)", + " Single check per file: [green]pass[/green] (0 violations) or [red]fail[/red] (any violations)", " Score: 100 if passed, 0 if failed", " Threshold: score >= 75 to pass overall", "", "[bold cyan]BYPASS:[/bold cyan]", " Add an entry to [dim].seedgo/bypass.json[/dim]:", - " [dim]{\"standard\": \"commented_logger\", \"file\": \"path/to/file.py\"}[/dim]", + ' [dim]{"standard": "commented_logger", "file": "path/to/file.py"}[/dim]', " Bypassed files return score 100 automatically.", "", "[bold cyan]REFERENCE:[/bold cyan]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py index 08641ba2..6ae020e6 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py @@ -28,11 +28,28 @@ AUDIT_SCOPE = "branch_level" # Directories to skip when collecting source files _SKIP_DIRS = { - "__pycache__", ".archive", ".mypy_cache", ".ruff_cache", - ".pytest_cache", "json_templates", "logs", "tools", - ".venv", "venv", "node_modules", ".git", "site-packages", - ".trinity", ".aipass", ".ai_mail.local", ".spawn", - "backups", "reports", "docs", "tests", ".sorting_unprocessed", + "__pycache__", + ".archive", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + "json_templates", + "logs", + "tools", + ".venv", + "venv", + "node_modules", + ".git", + "site-packages", + ".trinity", + ".aipass", + ".ai_mail.local", + ".spawn", + "backups", + "reports", + "docs", + "tests", + ".sorting_unprocessed", } @@ -40,6 +57,7 @@ _SKIP_DIRS = { # BYPASS HELPER # ============================================= + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed.""" if not bypass_rules: @@ -61,6 +79,7 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r # FILE COLLECTION # ============================================= + def _should_skip(path: Path) -> bool: """Check whether any parent directory component is in the skip set.""" return any(part in _SKIP_DIRS for part in path.parts) @@ -106,6 +125,7 @@ def _collect_source_text(apps_dir: Path) -> str: # REFERENCE CHECKING # ============================================= + def _build_import_path(py_file: Path, branch_path: Path, branch_name: str) -> str: """ Build the dotted import path for a file. @@ -163,9 +183,7 @@ def _check_file_used( for suffix_pattern in ("_check", "_content"): glob_lit = f'glob("*{suffix_pattern}.py")' glob_lit_sq = f"glob('*{suffix_pattern}.py')" - if stem.endswith(suffix_pattern) and ( - glob_lit in source_text or glob_lit_sq in source_text - ): + if stem.endswith(suffix_pattern) and (glob_lit in source_text or glob_lit_sq in source_text): return True # Rule 4: full dotted import path @@ -222,6 +240,7 @@ def _check_file_used( # BRANCH-LEVEL CHECK (audit pipeline entry) # ============================================= + def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: """ Check a branch for dead code (unreferenced modules and handlers). @@ -313,9 +332,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: dead_files: list[str] = [] for target in targets: - used = _check_file_used( - target, bp, branch_name, source_text, entry_point_name - ) + used = _check_file_used(target, bp, branch_name, source_text, entry_point_name) if not used: # Check per-file bypass try: @@ -333,10 +350,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: if dead_files: dead_list = ", ".join(dead_files[:10]) suffix = f" (+{len(dead_files) - 10} more)" if len(dead_files) > 10 else "" - message = ( - f"{len(dead_files)}/{total_files} files unreferenced: " - f"{dead_list}{suffix}" - ) + message = f"{len(dead_files)}/{total_files} files unreferenced: {dead_list}{suffix}" else: message = f"All {total_files} files referenced -- no dead code" diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_content.py index bb74f8a3..70e09e3b 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_content.py @@ -31,22 +31,17 @@ def get_dead_code_standards() -> str: "[bold cyan]WHAT IT CHECKS:[/bold cyan]", " Operates at [bold]branch level[/bold] (not per-file). The checker:", "", - " 1. Collects all .py files from [dim]apps/modules/[/dim] and" - " [dim]apps/handlers/[/dim]", + " 1. Collects all .py files from [dim]apps/modules/[/dim] and [dim]apps/handlers/[/dim]", " (skipping __init__.py, __pycache__, .archive, etc.)", " 2. Reads ALL .py content under [dim]apps/[/dim] into a search corpus", " 3. For each collected file, checks whether it is referenced by:", "", - " [green]a)[/green] Full dotted import path" - " [dim](aipass.branch.apps.handlers.foo)[/dim]", - " [green]b)[/green] Relative dotted path" - " [dim](handlers.foo)[/dim]", + " [green]a)[/green] Full dotted import path [dim](aipass.branch.apps.handlers.foo)[/dim]", + " [green]b)[/green] Relative dotted path [dim](handlers.foo)[/dim]", " [green]c)[/green] Import statement containing the module stem", " [green]d)[/green] importlib.import_module reference", - " [green]e)[/green] Glob-based auto-discovery" - " [dim](glob(\"*_check.py\"), glob(\"*.py\"))[/dim]", - " [green]f)[/green] Filename string literal" - " [dim](\"my_handler.py\")[/dim]", + ' [green]e)[/green] Glob-based auto-discovery [dim](glob("*_check.py"), glob("*.py"))[/dim]', + ' [green]f)[/green] Filename string literal [dim]("my_handler.py")[/dim]', "", " [yellow]Always considered used:[/yellow] __init__.py, entry point files,", " and glob-discovered patterns (*_check.py, *_content.py).", @@ -59,11 +54,10 @@ def get_dead_code_standards() -> str: "", " [green]Good -- file is imported or glob-discovered:[/green]", " [dim]from aipass.branch.apps.handlers.json import json_handler[/dim]", - " [dim]# or discovered via glob(\"*_check.py\")[/dim]", + ' [dim]# or discovered via glob("*_check.py")[/dim]', "", " Violation message example:", - " [dim]3/25 files unreferenced: handlers/old/foo.py," - " modules/unused.py, ...[/dim]", + " [dim]3/25 files unreferenced: handlers/old/foo.py, modules/unused.py, ...[/dim]", "", "[bold cyan]HOW TO FIX:[/bold cyan]", " 1. Review each flagged file:", @@ -91,7 +85,7 @@ def get_dead_code_standards() -> str: "", "[bold cyan]BYPASS:[/bold cyan]", " Add an entry to [dim].seedgo/bypass.json[/dim]:", - " [dim]{\"standard\": \"dead_code\", \"file\": \"path/to/file.py\"}[/dim]", + ' [dim]{"standard": "dead_code", "file": "path/to/file.py"}[/dim]', " Entire standard or individual files can be bypassed.", "", "[bold cyan]REFERENCE:[/bold cyan]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_check.py index ec95d2d7..c4e44abf 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_check.py @@ -238,11 +238,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: } # -- Filter out bypassed lines -- - non_bypassed = [ - ln - for ln in hit_lines - if not is_bypassed(module_path, "debug_print", ln, bypass_rules) - ] + non_bypassed = [ln for ln in hit_lines if not is_bypassed(module_path, "debug_print", ln, bypass_rules)] # -- Build result -- checks: list[Dict] = [] @@ -262,10 +258,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: { "name": "Debug print calls", "passed": False, - "message": ( - f"{len(non_bypassed)} bare print() call(s) " - f"on lines {sample}{suffix}" - ), + "message": (f"{len(non_bypassed)} bare print() call(s) on lines {sample}{suffix}"), } ) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_content.py index f4c5b820..27e7ab67 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/debug_print_content.py @@ -35,9 +35,8 @@ def get_debug_print_standards() -> str: " - Inside docstrings (triple-quoted regions)", " - On comment lines ([dim]# print(...)[/dim])", " - In doctest / interactive examples ([dim]>>> print(...)[/dim])", - " - Inside [dim]if __name__ == \"__main__\":[/dim] blocks", - " - Method calls like [dim]console.print()[/dim] or" - " [dim]logger.print()[/dim]", + ' - Inside [dim]if __name__ == "__main__":[/dim] blocks', + " - Method calls like [dim]console.print()[/dim] or [dim]logger.print()[/dim]", " - __init__.py files", " - Test files (test_*.py, *_test.py, conftest.py)", "", @@ -48,16 +47,16 @@ def get_debug_print_standards() -> str: " Any bare print() call outside excluded zones is a violation.", "", " [red]Bad:[/red]", - " [dim]print(f\"Processing {name}\")[/dim]", - " [dim]print(\"DEBUG: value =\", result)[/dim]", + ' [dim]print(f"Processing {name}")[/dim]', + ' [dim]print("DEBUG: value =", result)[/dim]', " [dim]print(data)[/dim]", "", " [green]Good -- use structured logging:[/green]", - " [dim]logger.info(f\"Processing {name}\")[/dim]", - " [dim]logger.debug(f\"value = {result}\")[/dim]", + ' [dim]logger.info(f"Processing {name}")[/dim]', + ' [dim]logger.debug(f"value = {result}")[/dim]', "", " [green]Good -- use Rich console output:[/green]", - " [dim]console.print(f\"[cyan]Processing {name}[/cyan]\")[/dim]", + ' [dim]console.print(f"[cyan]Processing {name}[/cyan]")[/dim]', "", " Violation message example:", " [dim]3 bare print() call(s) on lines 42, 78, 115[/dim]", @@ -76,8 +75,7 @@ def get_debug_print_standards() -> str: " test files (test_*.py, *_test.py, conftest.py).", "", "[bold cyan]SCORING:[/bold cyan]", - " Single check per file: [green]pass[/green] (0 violations) or" - " [red]fail[/red] (any violations)", + " Single check per file: [green]pass[/green] (0 violations) or [red]fail[/red] (any violations)", " Score: 100 if passed, 0 if failed", " Threshold: score >= 75 to pass overall", " Line-level bypass filtering is supported -- bypassed lines are", @@ -85,10 +83,9 @@ def get_debug_print_standards() -> str: "", "[bold cyan]BYPASS:[/bold cyan]", " Add an entry to [dim].seedgo/bypass.json[/dim]:", - " [dim]{\"standard\": \"debug_print\", \"file\": \"path/to/file.py\"}[/dim]", + ' [dim]{"standard": "debug_print", "file": "path/to/file.py"}[/dim]', " Or bypass specific lines:", - " [dim]{\"standard\": \"debug_print\"," - " \"file\": \"file.py\", \"lines\": [42, 78]}[/dim]", + ' [dim]{"standard": "debug_print", "file": "file.py", "lines": [42, 78]}[/dim]', "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (debug_print)[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_check.py index f4c4217a..5dc0e7a2 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_check.py @@ -27,25 +27,25 @@ AUDIT_SCOPE = "all_files" # -- Nesting node types ----------------------------------------------------- -_NESTING_NODES = (ast.If, ast.For, ast.While, ast.Try, ast.With, - ast.ExceptHandler) +_NESTING_NODES = (ast.If, ast.For, ast.While, ast.Try, ast.With, ast.ExceptHandler) DEPTH_LIMIT = 4 # -- Bypass helper ----------------------------------------------------------- + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed.""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -54,6 +54,7 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r # -- AST depth analysis ------------------------------------------------------ + def _max_nesting_depth(node: ast.AST, current: int = 0) -> int: """ Recursively walk a function body and return the maximum nesting depth. @@ -74,6 +75,7 @@ def _max_nesting_depth(node: ast.AST, current: int = 0) -> int: # -- File scanning ----------------------------------------------------------- + def _scan_file(file_path: Path) -> list[dict]: """ Parse a single .py file and return a list of violation dicts. @@ -83,7 +85,7 @@ def _scan_file(file_path: Path) -> list[dict]: violations: list[dict] = [] try: - source = file_path.read_text(encoding='utf-8', errors='ignore') + source = file_path.read_text(encoding="utf-8", errors="ignore") tree = ast.parse(source, filename=str(file_path)) except SyntaxError: logger.info("Skipped %s: SyntaxError during parse", file_path) @@ -94,17 +96,20 @@ def _scan_file(file_path: Path) -> list[dict]: continue depth = _max_nesting_depth(node) if depth > DEPTH_LIMIT: - violations.append({ - 'func': node.name, - 'depth': depth, - 'line': node.lineno, - }) + violations.append( + { + "func": node.name, + "depth": depth, + "line": node.lineno, + } + ) return violations # -- Public checker entry point ---------------------------------------------- + def check_module(module_path: str, bypass_rules: list | None = None) -> dict: """ Check if a module complies with deep nesting standards. @@ -120,57 +125,58 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'deep_nesting', bypass_rules=bypass_rules): + if is_bypassed(module_path, "deep_nesting", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'DEEP_NESTING', + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "DEEP_NESTING", } # Skip __init__.py files - if path.name == '__init__.py': + if path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'Deep nesting', 'passed': True, 'message': '__init__.py skipped'}], - 'score': 100, - 'standard': 'DEEP_NESTING', + "passed": True, + "checks": [{"name": "Deep nesting", "passed": True, "message": "__init__.py skipped"}], + "score": 100, + "standard": "DEEP_NESTING", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'DEEP_NESTING', + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "DEEP_NESTING", } # Scan for deep nesting violations violations = _scan_file(path) if not violations: - checks.append({ - 'name': 'Deep nesting', - 'passed': True, - 'message': 'All functions within nesting limit (max depth 3)', - }) + checks.append( + { + "name": "Deep nesting", + "passed": True, + "message": "All functions within nesting limit (max depth 3)", + } + ) else: - func_details = ', '.join( - f'{v["func"]}() depth {v["depth"]} line {v["line"]}' - for v in violations + func_details = ", ".join(f"{v['func']}() depth {v['depth']} line {v['line']}" for v in violations) + checks.append( + { + "name": "Deep nesting", + "passed": False, + "message": ( + f"{len(violations)} function{'s' if len(violations) != 1 else ''} " + f"exceed nesting limit: {func_details}" + ), + } ) - checks.append({ - 'name': 'Deep nesting', - 'passed': False, - 'message': ( - f'{len(violations)} function{"s" if len(violations) != 1 else ""} ' - f'exceed nesting limit: {func_details}' - ), - }) # Calculate score - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks) * 100) if total_checks > 0 else 0 @@ -182,8 +188,8 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> dict: ) return { - 'passed': overall_passed, - 'score': score, - 'checks': checks, - 'standard': 'DEEP_NESTING', + "passed": overall_passed, + "score": score, + "checks": checks, + "standard": "DEEP_NESTING", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_content.py index fd6b5ed3..bb34044f 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/deep_nesting_content.py @@ -68,8 +68,7 @@ def get_deep_nesting_standards() -> str: " [dim] handle(item)[/dim]", "", " Violation message example:", - " [dim]2 functions exceed nesting limit: process() depth 5" - " line 15, build() depth 6 line 88[/dim]", + " [dim]2 functions exceed nesting limit: process() depth 5 line 15, build() depth 6 line 88[/dim]", "", "[bold cyan]HOW TO FIX:[/bold cyan]", " 1. Identify the deeply nested function from the violation message", @@ -94,10 +93,9 @@ def get_deep_nesting_standards() -> str: "", "[bold cyan]BYPASS:[/bold cyan]", " Add an entry to [dim].seedgo/bypass.json[/dim]:", - " [dim]{\"standard\": \"deep_nesting\", \"file\": \"path/to/file.py\"}[/dim]", + ' [dim]{"standard": "deep_nesting", "file": "path/to/file.py"}[/dim]', " Or bypass specific lines:", - " [dim]{\"standard\": \"deep_nesting\"," - " \"file\": \"file.py\", \"lines\": [15]}[/dim]", + ' [dim]{"standard": "deep_nesting", "file": "file.py", "lines": [15]}[/dim]', "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (deep_nesting)[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py index 69294453..cbdaff5c 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py @@ -29,12 +29,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -57,42 +57,42 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] path = Path(module_path) - if is_bypassed(module_path, 'documentation', bypass_rules=bypass_rules): + if is_bypassed(module_path, "documentation", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'DOCUMENTATION' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "DOCUMENTATION", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'DOCUMENTATION' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "DOCUMENTATION", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'DOCUMENTATION' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "DOCUMENTATION", } # Skip __init__.py files - if path.name == '__init__.py': + if path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'Documentation check', 'passed': True, 'message': '__init__.py file (skipped)'}], - 'score': 100, - 'standard': 'DOCUMENTATION' + "passed": True, + "checks": [{"name": "Documentation check", "passed": True, "message": "__init__.py file (skipped)"}], + "score": 100, + "standard": "DOCUMENTATION", } # Check 1: Module-level docstring @@ -103,18 +103,15 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: function_docs_check = check_function_docstrings(content, lines) checks.append(function_docs_check) - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "documentation"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'DOCUMENTATION' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "documentation"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "DOCUMENTATION"} def check_module_docstring(lines: List[str]) -> Dict: @@ -127,16 +124,12 @@ def check_module_docstring(lines: List[str]) -> Dict: for line in lines[:30]: stripped = line.strip() if stripped.startswith('"""') or stripped.startswith("'''"): - return { - 'name': 'Module docstring', - 'passed': True, - 'message': 'Module-level docstring present' - } + return {"name": "Module docstring", "passed": True, "message": "Module-level docstring present"} return { - 'name': 'Module docstring', - 'passed': False, - 'message': 'Missing module-level docstring (expected within first 30 lines)' + "name": "Module docstring", + "passed": False, + "message": "Missing module-level docstring (expected within first 30 lines)", } @@ -149,18 +142,14 @@ def check_function_docstrings(content: str, lines: List[str]) -> Dict: # noqa: public_functions = [] for i, line in enumerate(lines, 1): stripped = line.strip() - if stripped.startswith('def ') and not stripped.startswith('def _'): - match = re.match(r'def\s+(\w+)\s*\(', stripped) + if stripped.startswith("def ") and not stripped.startswith("def _"): + match = re.match(r"def\s+(\w+)\s*\(", stripped) if match: func_name = match.group(1) public_functions.append((func_name, i)) if not public_functions: - return { - 'name': 'Function docstrings', - 'passed': True, - 'message': 'No public functions to check' - } + return {"name": "Function docstrings", "passed": True, "message": "No public functions to check"} undocumented = [] for func_name, line_num in public_functions: @@ -174,22 +163,20 @@ def check_function_docstrings(content: str, lines: List[str]) -> Dict: # noqa: has_docstring = True break # Stop scanning if we hit another def or class -- no docstring found - if check_line > line_num and ( - check_stripped.startswith('def ') or check_stripped.startswith('class ') - ): + if check_line > line_num and (check_stripped.startswith("def ") or check_stripped.startswith("class ")): break if not has_docstring: - undocumented.append(f'{func_name} (line {line_num})') + undocumented.append(f"{func_name} (line {line_num})") if undocumented: return { - 'name': 'Function docstrings', - 'passed': False, - 'message': f'{len(undocumented)} public functions missing docstrings: {undocumented[0]}' + "name": "Function docstrings", + "passed": False, + "message": f"{len(undocumented)} public functions missing docstrings: {undocumented[0]}", } return { - 'name': 'Function docstrings', - 'passed': True, - 'message': f'All {len(public_functions)} public functions have docstrings' + "name": "Function docstrings", + "passed": True, + "message": f"All {len(public_functions)} public functions have docstrings", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_content.py index 4686cf45..52967507 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_content.py @@ -15,6 +15,7 @@ Truth-checked 2025-11-13 against spawn and seedgo production code. from aipass.seedgo.apps.handlers.json import json_handler + def get_documentation_standards() -> str: """Return formatted documentation standards content with Rich markup @@ -49,14 +50,14 @@ def get_documentation_standards() -> str: "", "[bold cyan]REQUIRED: MODULE DOCSTRING[/bold cyan]", "", - " [dim]\"\"\"", + ' [dim]"""', " Module Title", "", " Brief purpose description.", " Key features or workflow if needed.", "", " Usage examples (optional).", - " \"\"\"[/dim]", + ' """[/dim]', "", "[yellow]RULES:[/yellow]", " • Goes right after AIPass block", @@ -68,7 +69,7 @@ def get_documentation_standards() -> str: "[bold cyan]FUNCTION DOCSTRINGS (Google-style)[/bold cyan]", "", " [dim]def function_name(arg: Type) -> ReturnType:", - " \"\"\"", + ' """', " One-line summary", "", " Args:", @@ -79,7 +80,7 @@ def get_documentation_standards() -> str: "", " Raises:", " Exception: When raised (optional)", - " \"\"\"[/dim]", + ' """[/dim]', "", "[yellow]RULES:[/yellow]", " • Type hints REQUIRED on all functions", @@ -111,7 +112,7 @@ def get_documentation_standards() -> str: "", " [bold]Good:[/bold] Explain WHY, not WHAT", " [dim]# Memory file suffixes to check (JSON format)[/dim]", - " [dim]memory_suffixes = [\".json\", \".local.json\"][/dim]", + ' [dim]memory_suffixes = [".json", ".local.json"][/dim]', "", " [bold]Bad:[/bold] Obvious narration", " [dim]# Set x to 5[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py index 52eb6131..dc53c3a1 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py @@ -27,6 +27,7 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def _find_registry() -> Path: """Find AIPASS_REGISTRY.json by walking up from this file's location.""" current = Path(__file__).resolve().parent @@ -43,14 +44,14 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -64,7 +65,7 @@ def get_branch_from_path(file_path: str) -> Optional[Dict]: if not registry_path.exists(): return None - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) if not registry: @@ -74,17 +75,15 @@ def get_branch_from_path(file_path: str) -> Optional[Dict]: resolved_path = str(Path(file_path).resolve()) # Sort branches by path length (longest first) to match most specific - branches = sorted(registry.get('branches', []), - key=lambda b: len(b.get('path', '')), - reverse=True) + branches = sorted(registry.get("branches", []), key=lambda b: len(b.get("path", "")), reverse=True) for branch in branches: - raw_path = branch.get('path', '') + raw_path = branch.get("path", "") branch_path = Path(raw_path) if not branch_path.is_absolute(): branch_path = (registry_dir / branch_path).resolve() branch_path_str = str(branch_path) - if resolved_path.startswith(branch_path_str + '/') or resolved_path == branch_path_str: + if resolved_path.startswith(branch_path_str + "/") or resolved_path == branch_path_str: return branch return None @@ -103,17 +102,17 @@ def extract_branch_from_import(import_line: str) -> Optional[str]: 'from apps.handlers.json import X' -> None (local, no branch) """ # Pattern 1: branch.apps.handlers... - match = re.search(r'from\s+(\w+)\.apps\.handlers', import_line) + match = re.search(r"from\s+(\w+)\.apps\.handlers", import_line) if match: return match.group(1) # Pattern 2: aipass.branch.apps.handlers... - match = re.search(r'from\s+aipass\.(\w+)\.apps\.handlers', import_line) + match = re.search(r"from\s+aipass\.(\w+)\.apps\.handlers", import_line) if match: return match.group(1) # Pattern 3: import branch.apps.handlers... - match = re.search(r'import\s+(\w+)\.apps\.handlers', import_line) + match = re.search(r"import\s+(\w+)\.apps\.handlers", import_line) if match: return match.group(1) @@ -129,7 +128,7 @@ def extract_handler_package(import_line: str) -> Optional[str]: 'from apps.handlers.dashboard.refresh import X' -> 'dashboard' 'from flow.apps.handlers.plan.validator import X' -> 'plan' """ - match = re.search(r'apps\.handlers\.(\w+)', import_line) + match = re.search(r"apps\.handlers\.(\w+)", import_line) if match: return match.group(1) return None @@ -145,11 +144,11 @@ def get_file_handler_package(file_path: str) -> Optional[str]: """ path_str = str(file_path) - if 'apps/handlers/' not in path_str: + if "apps/handlers/" not in path_str: return None # Extract package after apps/handlers/ - match = re.search(r'apps/handlers/(\w+)', path_str) + match = re.search(r"apps/handlers/(\w+)", path_str) if match: return match.group(1) return None @@ -164,7 +163,7 @@ def _resolve_branch_path(file_path: str) -> Optional[Path]: branch_info = get_branch_from_path(file_path) if not branch_info: return None - raw_path = branch_info.get('path', '') + raw_path = branch_info.get("path", "") branch_path = Path(raw_path) if not branch_path.is_absolute(): registry_path = _find_registry() @@ -194,12 +193,8 @@ def check_handler_guard(module_path: str, bypass_rules: list | None = None) -> O # Check if bypassed init_path = branch_path / "apps" / "handlers" / "__init__.py" - if is_bypassed(str(init_path), 'encapsulation', bypass_rules=bypass_rules): - result = { - 'name': 'Handler security guard', - 'passed': True, - 'message': 'Handler guard check bypassed' - } + if is_bypassed(str(init_path), "encapsulation", bypass_rules=bypass_rules): + result = {"name": "Handler security guard", "passed": True, "message": "Handler guard check bypassed"} _handler_guard_cache[branch_key] = result return result @@ -211,54 +206,47 @@ def check_handler_guard(module_path: str, bypass_rules: list | None = None) -> O if not init_path.exists(): result = { - 'name': 'Handler security guard', - 'passed': False, - 'message': 'Missing handlers/__init__.py — no handler security guard' + "name": "Handler security guard", + "passed": False, + "message": "Missing handlers/__init__.py — no handler security guard", } _handler_guard_cache[branch_key] = result return result # Read the init file and check for guard patterns try: - content = init_path.read_text(encoding='utf-8') + content = init_path.read_text(encoding="utf-8") except Exception: logger.info("Cannot read handlers/__init__.py at %s", init_path) - result = { - 'name': 'Handler security guard', - 'passed': False, - 'message': 'Cannot read handlers/__init__.py' - } + result = {"name": "Handler security guard", "passed": False, "message": "Cannot read handlers/__init__.py"} _handler_guard_cache[branch_key] = result return result # Count non-empty, non-comment lines - code_lines = [ - ln for ln in content.split('\n') - if ln.strip() and not ln.strip().startswith('#') - ] + code_lines = [ln for ln in content.split("\n") if ln.strip() and not ln.strip().startswith("#")] # Guard detection: look for key patterns - guard_patterns = ['_guard_branch_access', 'inspect.stack', 'ImportError'] + guard_patterns = ["_guard_branch_access", "inspect.stack", "ImportError"] has_guard = any(pattern in content for pattern in guard_patterns) if has_guard: result = { - 'name': 'Handler security guard', - 'passed': True, - 'message': 'Handler security guard present (inspect.stack guard active)' + "name": "Handler security guard", + "passed": True, + "message": "Handler security guard present (inspect.stack guard active)", } elif len(code_lines) < 10: result = { - 'name': 'Handler security guard', - 'passed': False, - 'message': 'Missing handler security guard — cross-branch imports unprotected' + "name": "Handler security guard", + "passed": False, + "message": "Missing handler security guard — cross-branch imports unprotected", } else: # File has substantial code but no recognized guard patterns result = { - 'name': 'Handler security guard', - 'passed': False, - 'message': 'handlers/__init__.py has code but no recognized security guard pattern' + "name": "Handler security guard", + "passed": False, + "message": "handlers/__init__.py has code but no recognized security guard pattern", } _handler_guard_cache[branch_key] = result @@ -285,62 +273,56 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed - if is_bypassed(module_path, 'encapsulation', bypass_rules=bypass_rules): + if is_bypassed(module_path, "encapsulation", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'ENCAPSULATION' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "ENCAPSULATION", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'ENCAPSULATION' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "ENCAPSULATION", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'ENCAPSULATION' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "ENCAPSULATION", } # Detect this file's context file_branch = get_branch_from_path(module_path) - file_branch_name = file_branch.get('name', '').lower() if file_branch else None + file_branch_name = file_branch.get("name", "").lower() if file_branch else None file_handler_package = get_file_handler_package(module_path) is_handler_file = file_handler_package is not None - is_module_file = 'apps/modules/' in str(module_path) + is_module_file = "apps/modules/" in str(module_path) # Check 1: Cross-branch handler imports - cross_branch_check = check_cross_branch_imports( - lines, module_path, file_branch_name, bypass_rules - ) + cross_branch_check = check_cross_branch_imports(lines, module_path, file_branch_name, bypass_rules) checks.append(cross_branch_check) # Check 2: Cross-package handler imports (only for handler files) if is_handler_file: - cross_package_check = check_cross_package_imports( - lines, module_path, file_handler_package, bypass_rules - ) + cross_package_check = check_cross_package_imports(lines, module_path, file_handler_package, bypass_rules) checks.append(cross_package_check) # Check 3: Direct handler imports from non-handler/non-module files if not is_handler_file and not is_module_file: - direct_import_check = check_direct_handler_imports( - lines, module_path, bypass_rules - ) + direct_import_check = check_direct_handler_imports(lines, module_path, bypass_rules) checks.append(direct_import_check) # Check 4: Handler security guard presence (branch-level, cached) @@ -351,27 +333,25 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # Calculate score if not checks: return { - 'passed': True, - 'checks': [{'name': 'Encapsulation', 'passed': True, 'message': 'No checks applicable'}], - 'score': 100, - 'standard': 'ENCAPSULATION' + "passed": True, + "checks": [{"name": "Encapsulation", "passed": True, "message": "No checks applicable"}], + "score": 100, + "standard": "ENCAPSULATION", } - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 100 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "encapsulation"}) - return { - 'passed': score >= 75, - 'checks': checks, - 'score': score, - 'standard': 'ENCAPSULATION' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "encapsulation"} + ) + return {"passed": score >= 75, "checks": checks, "score": score, "standard": "ENCAPSULATION"} -def check_cross_branch_imports(lines: List[str], module_path: str, - file_branch: Optional[str], bypass_rules: list | None = None) -> Dict: +def check_cross_branch_imports( + lines: List[str], module_path: str, file_branch: Optional[str], bypass_rules: list | None = None +) -> Dict: """ Check for cross-branch handler imports @@ -398,11 +378,11 @@ def check_cross_branch_imports(lines: List[str], module_path: str, else: in_docstring = not in_docstring - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for imports - if not ('from ' in stripped or 'import ' in stripped): + if not ("from " in stripped or "import " in stripped): continue # Skip string literals - check if line is a string or apps.handlers is inside quotes @@ -413,23 +393,23 @@ def check_cross_branch_imports(lines: List[str], module_path: str, # Check if apps.handlers appears inside quotes (documentation examples) if '"apps.handlers' in stripped or "'apps.handlers" in stripped: continue - if 'apps.handlers' in stripped: + if "apps.handlers" in stripped: # Check if it's inside a string by looking for quotes before it - handler_pos = stripped.find('apps.handlers') + handler_pos = stripped.find("apps.handlers") before = stripped[:handler_pos] # If there's an odd number of quotes before, it's inside a string if before.count('"') % 2 == 1 or before.count("'") % 2 == 1: continue # Extract code part (before comment) - code_part = stripped.split('#')[0] if '#' in stripped else stripped + code_part = stripped.split("#")[0] if "#" in stripped else stripped # Check for handler imports - if 'apps.handlers' not in code_part: + if "apps.handlers" not in code_part: continue # Check if bypassed - if is_bypassed(module_path, 'encapsulation', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "encapsulation", line=i, bypass_rules=bypass_rules): continue # Extract the branch being imported from @@ -445,30 +425,33 @@ def check_cross_branch_imports(lines: List[str], module_path: str, # Disallow cross-branch handler imports (even from service branches) # Service branches should be accessed via modules, not handlers - violations.append({ - 'line': i, - 'code': code_part.strip(), - 'from_branch': imported_branch, - 'to_branch': file_branch or 'unknown' - }) + violations.append( + { + "line": i, + "code": code_part.strip(), + "from_branch": imported_branch, + "to_branch": file_branch or "unknown", + } + ) if violations: first = violations[0] return { - 'name': 'Cross-branch handler imports', - 'passed': False, - 'message': f"Line {first['line']}: {first['from_branch']}.apps.handlers imported (use modules entry point)" + "name": "Cross-branch handler imports", + "passed": False, + "message": f"Line {first['line']}: {first['from_branch']}.apps.handlers imported (use modules entry point)", } return { - 'name': 'Cross-branch handler imports', - 'passed': True, - 'message': 'No cross-branch handler imports detected' + "name": "Cross-branch handler imports", + "passed": True, + "message": "No cross-branch handler imports detected", } -def check_cross_package_imports(lines: List[str], module_path: str, - file_package: str, bypass_rules: list | None = None) -> Dict: +def check_cross_package_imports( + lines: List[str], module_path: str, file_package: str, bypass_rules: list | None = None +) -> Dict: """ Check for cross-package handler imports within same branch @@ -482,7 +465,7 @@ def check_cross_package_imports(lines: List[str], module_path: str, violations = [] # Allowed default handlers that can be imported anywhere - allowed_handlers = {'json_handler', 'file_handler'} + allowed_handlers = {"json_handler", "file_handler"} in_docstring = False for i, line in enumerate(lines, 1): @@ -497,11 +480,11 @@ def check_cross_package_imports(lines: List[str], module_path: str, else: in_docstring = not in_docstring - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for imports - if not ('from ' in stripped or 'import ' in stripped): + if not ("from " in stripped or "import " in stripped): continue # Skip string literals @@ -511,21 +494,21 @@ def check_cross_package_imports(lines: List[str], module_path: str, continue if '"apps.handlers' in stripped or "'apps.handlers" in stripped: continue - if 'apps.handlers' in stripped: - handler_pos = stripped.find('apps.handlers') + if "apps.handlers" in stripped: + handler_pos = stripped.find("apps.handlers") before = stripped[:handler_pos] if before.count('"') % 2 == 1 or before.count("'") % 2 == 1: continue # Skip relative imports (same package) - if stripped.startswith('from .'): + if stripped.startswith("from ."): continue # Extract code part - code_part = stripped.split('#')[0] if '#' in stripped else stripped + code_part = stripped.split("#")[0] if "#" in stripped else stripped # Check for handler imports (local, not cross-branch) - if 'apps.handlers' not in code_part: + if "apps.handlers" not in code_part: continue # Skip cross-branch imports (handled by other check) @@ -534,7 +517,7 @@ def check_cross_package_imports(lines: List[str], module_path: str, continue # Check if bypassed - if is_bypassed(module_path, 'encapsulation', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "encapsulation", line=i, bypass_rules=bypass_rules): continue # Extract the handler package being imported @@ -558,30 +541,26 @@ def check_cross_package_imports(lines: List[str], module_path: str, continue # This is a cross-package handler import - violations.append({ - 'line': i, - 'code': code_part.strip(), - 'from_package': imported_package, - 'to_package': file_package - }) + violations.append( + {"line": i, "code": code_part.strip(), "from_package": imported_package, "to_package": file_package} + ) if violations: first = violations[0] return { - 'name': 'Cross-package handler imports', - 'passed': False, - 'message': f"Line {first['line']}: handlers.{first['from_package']} imported from handlers.{first['to_package']}" + "name": "Cross-package handler imports", + "passed": False, + "message": f"Line {first['line']}: handlers.{first['from_package']} imported from handlers.{first['to_package']}", } return { - 'name': 'Cross-package handler imports', - 'passed': True, - 'message': 'No forbidden cross-package handler imports' + "name": "Cross-package handler imports", + "passed": True, + "message": "No forbidden cross-package handler imports", } -def check_direct_handler_imports(lines: List[str], module_path: str, - bypass_rules: list | None = None) -> Dict: +def check_direct_handler_imports(lines: List[str], module_path: str, bypass_rules: list | None = None) -> Dict: """ Check that non-handler, non-module files don't import handlers directly @@ -597,7 +576,7 @@ def check_direct_handler_imports(lines: List[str], module_path: str, violations = [] # Allowed default handlers - allowed_handlers = {'json_handler', 'file_handler'} + allowed_handlers = {"json_handler", "file_handler"} in_docstring = False for i, line in enumerate(lines, 1): @@ -612,11 +591,11 @@ def check_direct_handler_imports(lines: List[str], module_path: str, else: in_docstring = not in_docstring - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for imports - if not ('from ' in stripped or 'import ' in stripped): + if not ("from " in stripped or "import " in stripped): continue # Skip string literals @@ -626,21 +605,21 @@ def check_direct_handler_imports(lines: List[str], module_path: str, continue if '"apps.handlers' in stripped or "'apps.handlers" in stripped: continue - if 'apps.handlers' in stripped: - handler_pos = stripped.find('apps.handlers') + if "apps.handlers" in stripped: + handler_pos = stripped.find("apps.handlers") before = stripped[:handler_pos] if before.count('"') % 2 == 1 or before.count("'") % 2 == 1: continue # Extract code part - code_part = stripped.split('#')[0] if '#' in stripped else stripped + code_part = stripped.split("#")[0] if "#" in stripped else stripped # Check for handler imports - if 'apps.handlers' not in code_part: + if "apps.handlers" not in code_part: continue # Check if bypassed - if is_bypassed(module_path, 'encapsulation', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "encapsulation", line=i, bypass_rules=bypass_rules): continue # Allow default handlers @@ -654,21 +633,14 @@ def check_direct_handler_imports(lines: List[str], module_path: str, continue # This file shouldn't be importing handlers directly - violations.append({ - 'line': i, - 'code': code_part.strip() - }) + violations.append({"line": i, "code": code_part.strip()}) if violations: first = violations[0] return { - 'name': 'Direct handler imports', - 'passed': False, - 'message': f"Line {first['line']}: Handler imported directly (use module entry point)" + "name": "Direct handler imports", + "passed": False, + "message": f"Line {first['line']}: Handler imported directly (use module entry point)", } - return { - 'name': 'Direct handler imports', - 'passed': True, - 'message': 'No direct handler imports from entry point' - } + return {"name": "Direct handler imports", "passed": True, "message": "No direct handler imports from entry point"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py index e65e85c4..4a3be8be 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py @@ -21,17 +21,18 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -43,33 +44,33 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] path = Path(module_path) - if is_bypassed(module_path, 'error_handling', bypass_rules=bypass_rules): + if is_bypassed(module_path, "error_handling", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'ERROR_HANDLING' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "ERROR_HANDLING", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'ERROR_HANDLING' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "ERROR_HANDLING", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'ERROR_HANDLING' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "ERROR_HANDLING", } # Only check: Error handling (for all files, not just non-test files) @@ -80,33 +81,32 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # If no checks were added (no try/except blocks), pass if not checks: return { - 'passed': True, - 'checks': [{'name': 'Error handling', 'passed': True, 'message': 'No try/except blocks detected (not applicable)'}], - 'score': 100, - 'standard': 'ERROR_HANDLING' + "passed": True, + "checks": [ + {"name": "Error handling", "passed": True, "message": "No try/except blocks detected (not applicable)"} + ], + "score": 100, + "standard": "ERROR_HANDLING", } - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "error_handling"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'ERROR_HANDLING' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "error_handling"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "ERROR_HANDLING"} def _is_silent_except(lines: List[str], pass_index: int, pass_line: str) -> bool: pass_indent = len(pass_line) - len(pass_line.lstrip()) for j in range(pass_index, min(pass_index + 3, len(lines))): next_line = lines[j].strip() - is_pass_line = next_line == 'pass' or next_line.startswith('pass ') or next_line.startswith('pass#') + is_pass_line = next_line == "pass" or next_line.startswith("pass ") or next_line.startswith("pass#") if next_line and not is_pass_line: - if lines[j].startswith(' ') and len(lines[j]) - len(lines[j].lstrip()) > pass_indent: + if lines[j].startswith(" ") and len(lines[j]) - len(lines[j].lstrip()) > pass_indent: return False break return True @@ -114,7 +114,7 @@ def _is_silent_except(lines: List[str], pass_index: int, pass_line: str) -> bool def check_error_handling(content: str, lines: List[str], module_path: str = "") -> Optional[Dict]: """Check for proper error handling patterns""" - try_count = content.count('try:') + try_count = content.count("try:") if try_count == 0: return None @@ -134,26 +134,26 @@ def check_error_handling(content: str, lines: List[str], module_path: str = "") in_docstring = not in_docstring if in_docstring: continue - if 'except' in stripped and ':' in stripped: + if "except" in stripped and ":" in stripped: in_except = True except_line = i continue if in_except: - if stripped == 'pass' or stripped.startswith('pass ') or stripped.startswith('pass#'): + if stripped == "pass" or stripped.startswith("pass ") or stripped.startswith("pass#"): if _is_silent_except(lines, i, line): silent_failures.append(f"line {except_line}") - if line.strip() and not line.startswith(' ') and not line.startswith('\t'): + if line.strip() and not line.startswith(" ") and not line.startswith("\t"): in_except = False if silent_failures: return { - 'name': 'Error handling', - 'passed': False, - 'message': f'Silent failure detected (except: pass) in {Path(module_path).name if module_path else "file"} at {silent_failures[0]} - errors should log/return' + "name": "Error handling", + "passed": False, + "message": f"Silent failure detected (except: pass) in {Path(module_path).name if module_path else 'file'} at {silent_failures[0]} - errors should log/return", } return { - 'name': 'Error handling', - 'passed': True, - 'message': f'Error handling present ({try_count} try/except blocks with proper handling)' + "name": "Error handling", + "passed": True, + "message": f"Error handling present ({try_count} try/except blocks with proper handling)", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py index c795ba32..60c6c49d 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py @@ -23,20 +23,21 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -69,45 +70,45 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'handlers', bypass_rules=bypass_rules): + if is_bypassed(module_path, "handlers", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'HANDLERS' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "HANDLERS", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'HANDLERS' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "HANDLERS", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'HANDLERS' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "HANDLERS", } # Only check files in handlers/ directory - is_handler = 'apps/handlers/' in module_path + is_handler = "apps/handlers/" in module_path if not is_handler: return { - 'passed': True, - 'checks': [{'name': 'Handler check', 'passed': True, 'message': 'Not a handler file (skipped)'}], - 'score': 100, - 'standard': 'HANDLERS' + "passed": True, + "checks": [{"name": "Handler check", "passed": True, "message": "Not a handler file (skipped)"}], + "score": 100, + "standard": "HANDLERS", } # Check 1: Handler independence (no cross-handler imports except defaults) @@ -125,7 +126,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(orchestration_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 @@ -133,12 +134,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "handlers"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'HANDLERS' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "HANDLERS"} def check_handler_independence(content: str, lines: List[str], module_path: str) -> Dict: @@ -157,7 +153,7 @@ def check_handler_independence(content: str, lines: List[str], module_path: str) path_parts = Path(module_path).parts own_package = None for i, part in enumerate(path_parts): - if part == 'handlers' and i + 1 < len(path_parts): + if part == "handlers" and i + 1 < len(path_parts): own_package = path_parts[i + 1] break @@ -178,31 +174,31 @@ def check_handler_independence(content: str, lines: List[str], module_path: str) in_docstring = not in_docstring # Skip docstrings, comments and empty lines - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for handler imports - if 'apps.handlers' in stripped and ('from ' in stripped or 'import ' in stripped): + if "apps.handlers" in stripped and ("from " in stripped or "import " in stripped): # Skip if in a string (rough check) if '"from ' in stripped or "'from " in stripped: continue # Extract code part (before comment) - code_part = stripped.split('#')[0] if '#' in stripped else stripped + code_part = stripped.split("#")[0] if "#" in stripped else stripped - if 'apps.handlers' not in code_part: + if "apps.handlers" not in code_part: continue # Allowed: Default handlers (json_handler) - if 'handlers.json import json_handler' in code_part: + if "handlers.json import json_handler" in code_part: continue # Allowed: Same package imports (relative imports like "from .decorators") - if code_part.strip().startswith('from .'): + if code_part.strip().startswith("from ."): continue # Allowed: Same package absolute imports - if own_package and f'handlers.{own_package}' in code_part: + if own_package and f"handlers.{own_package}" in code_part: continue # Forbidden: Cross-handler imports @@ -210,16 +206,12 @@ def check_handler_independence(content: str, lines: List[str], module_path: str) if forbidden_imports: return { - 'name': 'Handler independence', - 'passed': False, - 'message': f'Cross-handler imports detected (except defaults): {forbidden_imports[0]}' + "name": "Handler independence", + "passed": False, + "message": f"Cross-handler imports detected (except defaults): {forbidden_imports[0]}", } - return { - 'name': 'Handler independence', - 'passed': True, - 'message': 'No forbidden cross-handler imports detected' - } + return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-handler imports detected"} def check_auto_detection(content: str) -> Optional[Dict]: @@ -229,28 +221,28 @@ def check_auto_detection(content: str) -> Optional[Dict]: If handler has module_name parameter, should use inspect.stack() auto-detection """ # Check if any function accepts module_name parameter - has_module_name_param = bool(re.search(r'def\s+\w+\([^)]*module_name', content)) + has_module_name_param = bool(re.search(r"def\s+\w+\([^)]*module_name", content)) if not has_module_name_param: return None # No module_name parameter, auto-detection not needed # Check for auto-detection implementation - has_inspect_import = 'import inspect' in content - has_stack_usage = 'inspect.stack()' in content - has_auto_detect_function = '_get_caller_module_name' in content or 'get_caller' in content + has_inspect_import = "import inspect" in content + has_stack_usage = "inspect.stack()" in content + has_auto_detect_function = "_get_caller_module_name" in content or "get_caller" in content if has_auto_detect_function or (has_inspect_import and has_stack_usage): return { - 'name': 'Auto-detection pattern', - 'passed': True, - 'message': 'Auto-detection pattern implemented (inspect.stack())' + "name": "Auto-detection pattern", + "passed": True, + "message": "Auto-detection pattern implemented (inspect.stack())", } # Has module_name param but no auto-detection return { - 'name': 'Auto-detection pattern', - 'passed': False, - 'message': 'Has module_name parameter but missing auto-detection (use inspect.stack())' + "name": "Auto-detection pattern", + "passed": False, + "message": "Has module_name parameter but missing auto-detection (use inspect.stack())", } @@ -280,23 +272,23 @@ def check_no_orchestration(content: str, lines: List[str]) -> Optional[Dict]: in_docstring = not in_docstring # Skip docstrings, comments and empty lines - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for module imports - if 'apps.modules' in stripped and ('from ' in stripped or 'import ' in stripped): + if "apps.modules" in stripped and ("from " in stripped or "import " in stripped): # Skip if in a string if '"from ' in stripped or "'from " in stripped: continue # Extract code part - code_part = stripped.split('#')[0] if '#' in stripped else stripped + code_part = stripped.split("#")[0] if "#" in stripped else stripped - if 'apps.modules' not in code_part: + if "apps.modules" not in code_part: continue # Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules) - if 'prax.apps.modules.logger' in code_part or 'cli.apps.modules' in code_part: + if "prax.apps.modules.logger" in code_part or "cli.apps.modules" in code_part: continue # Forbidden: Module imports (orchestration) @@ -304,13 +296,9 @@ def check_no_orchestration(content: str, lines: List[str]) -> Optional[Dict]: if module_imports: return { - 'name': 'No orchestration', - 'passed': False, - 'message': f'Handler imports modules (orchestration): {module_imports[0]}' + "name": "No orchestration", + "passed": False, + "message": f"Handler imports modules (orchestration): {module_imports[0]}", } - return { - 'name': 'No orchestration', - 'passed': True, - 'message': 'No module imports detected (pure implementation)' - } + return {"name": "No orchestration", "passed": True, "message": "No module imports detected (pure implementation)"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_content.py index f8941434..5c7f05a1 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_content.py @@ -16,6 +16,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_handlers_standards() -> str: """Return formatted handlers standards content with Rich markup diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_check.py index 0761f982..9c424188 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_check.py @@ -78,9 +78,15 @@ _PLACEHOLDER_SUFFIX_RE = re.compile( ) # Words on the line (outside the key literal) that signal example context. -_EXAMPLE_CONTEXT_WORDS = frozenset({ - "example", "template", "placeholder", "sample", "demo", -}) +_EXAMPLE_CONTEXT_WORDS = frozenset( + { + "example", + "template", + "placeholder", + "sample", + "demo", + } +) # Pure comment line. _PAT_COMMENT = re.compile(r"^\s*#") @@ -91,6 +97,7 @@ _PAT_REGEX_CONTEXT = re.compile(r"""re\.compile|r["']|\\[dws\^]""") # -- Helpers ---------------------------------------------------------------- + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed.""" if not bypass_rules: @@ -119,6 +126,7 @@ def _is_placeholder(key_value: str) -> bool: # -- Core detection --------------------------------------------------------- + def _scan_line(lineno: int, line: str) -> list[tuple[int, str]]: """ Check a single source line for hardcoded key literals. @@ -188,6 +196,7 @@ def _scan_file(file_path: Path) -> list[tuple[int, str]]: # -- Public entry point ----------------------------------------------------- + def check_module(module_path: str, bypass_rules: list | None = None) -> dict: """ Check if a Python file contains hardcoded API keys. @@ -276,17 +285,21 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> dict: line_numbers = [f[0] for f in findings] preview = ", ".join(str(ln) for ln in line_numbers[:3]) suffix = f" (and {len(line_numbers) - 3} more)" if len(line_numbers) > 3 else "" - checks.append({ - "name": "Hardcoded API keys", - "passed": False, - "message": f"Found {len(findings)} hardcoded key(s) on lines {preview}{suffix}", - }) + checks.append( + { + "name": "Hardcoded API keys", + "passed": False, + "message": f"Found {len(findings)} hardcoded key(s) on lines {preview}{suffix}", + } + ) else: - checks.append({ - "name": "Hardcoded API keys", - "passed": True, - "message": "No hardcoded API keys detected", - }) + checks.append( + { + "name": "Hardcoded API keys", + "passed": True, + "message": "No hardcoded API keys detected", + } + ) # Score passed_checks = sum(1 for c in checks if c["passed"]) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_content.py index 876fee88..a0d8c7ee 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/hardcoded_key_content.py @@ -45,36 +45,32 @@ def get_hardcoded_key_standards() -> str: " [yellow]Smart filtering -- these are NOT flagged:[/yellow]", " - Comment lines ([dim]# sk-or-v1-...[/dim])", " - Docstring regions (triple-quoted blocks)", - " - Placeholder values ([dim]your_key_here, xxx, example," - " placeholder, ...[/dim])", - " - Placeholder suffixes ([dim]...-example, ...-test," - " ...-placeholder[/dim])", - " - Lines with example context words" - " ([dim]example, template, sample, demo[/dim])", + " - Placeholder values ([dim]your_key_here, xxx, example, placeholder, ...[/dim])", + " - Placeholder suffixes ([dim]...-example, ...-test, ...-placeholder[/dim])", + " - Lines with example context words ([dim]example, template, sample, demo[/dim])", " - Regex compilation contexts ([dim]re.compile(...)[/dim])", "", "[bold cyan]VIOLATIONS:[/bold cyan]", " Any key-like string literal that passes all filters is a violation.", "", " [red]Bad:[/red]", - " [dim]API_KEY = \"sk-or-v1-abc123real456key789\"[/dim]", - " [dim]client = OpenAI(api_key=\"sk-proj-actual-secret-key-here123\")[/dim]", + ' [dim]API_KEY = "sk-or-v1-abc123real456key789"[/dim]', + ' [dim]client = OpenAI(api_key="sk-proj-actual-secret-key-here123")[/dim]', "", " [green]Good -- use environment variables:[/green]", " [dim]import os[/dim]", - " [dim]API_KEY = os.environ[\"OPENROUTER_API_KEY\"][/dim]", + ' [dim]API_KEY = os.environ["OPENROUTER_API_KEY"][/dim]', "", " [green]Good -- use config files:[/green]", - " [dim]config = json.loads(Path(\"config.json\")" - ".read_text(encoding=\"utf-8\"))[/dim]", - " [dim]API_KEY = config[\"api_key\"][/dim]", + ' [dim]config = json.loads(Path("config.json").read_text(encoding="utf-8"))[/dim]', + ' [dim]API_KEY = config["api_key"][/dim]', "", " Violation message example:", " [dim]Found 2 hardcoded key(s) on lines 15, 42[/dim]", "", "[bold cyan]HOW TO FIX:[/bold cyan]", " 1. Move the key to an environment variable or .env file", - " 2. Replace the literal with [dim]os.environ[\"KEY_NAME\"][/dim]", + ' 2. Replace the literal with [dim]os.environ["KEY_NAME"][/dim]', " 3. Add the .env file to .gitignore if not already present", " 4. Rotate the exposed key immediately -- it is compromised", " 5. Re-run the audit to confirm zero violations", @@ -84,8 +80,7 @@ def get_hardcoded_key_standards() -> str: " Runs against every .py file in the branch. Skips __init__.py.", "", "[bold cyan]SCORING:[/bold cyan]", - " Single check per file: [green]pass[/green] (0 keys found) or" - " [red]fail[/red] (any keys found)", + " Single check per file: [green]pass[/green] (0 keys found) or [red]fail[/red] (any keys found)", " Score: 100 if passed, 0 if failed", " Threshold: score >= 75 to pass overall", " Line-level bypass filtering is supported -- bypassed lines are", @@ -93,10 +88,9 @@ def get_hardcoded_key_standards() -> str: "", "[bold cyan]BYPASS:[/bold cyan]", " Add an entry to [dim].seedgo/bypass.json[/dim]:", - " [dim]{\"standard\": \"hardcoded_key\", \"file\": \"path/to/file.py\"}[/dim]", + ' [dim]{"standard": "hardcoded_key", "file": "path/to/file.py"}[/dim]', " Or bypass specific lines:", - " [dim]{\"standard\": \"hardcoded_key\"," - " \"file\": \"file.py\", \"lines\": [15]}[/dim]", + ' [dim]{"standard": "hardcoded_key", "file": "file.py", "lines": [15]}[/dim]', "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (hardcoded_key)[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/help_text_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/help_text_content.py index 8231f9fb..97a9354b 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/help_text_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/help_text_content.py @@ -33,9 +33,9 @@ def get_help_text_standards() -> str: " references to [red]python3[/red] or [red]python[/red] as a command invocation", "", " [yellow]Detects:[/yellow]", - ' - [red]python3 some/script.py[/red] inside string literals', - ' - [red]python -m module[/red] inside string literals', - ' - References inside triple-quoted docstrings and help text', + " - [red]python3 some/script.py[/red] inside string literals", + " - [red]python -m module[/red] inside string literals", + " - References inside triple-quoted docstrings and help text", "", " [yellow]Ignores:[/yellow]", " - Shebangs ([dim]#!/usr/bin/env python3[/dim])", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py index 464ab292..82e19edd 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py @@ -30,12 +30,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -58,50 +58,56 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Python package marker — no imports required by convention - if path.name == '__init__.py': + if path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'Package marker', 'passed': True, 'message': '__init__.py — Python package marker, no import checks'}], - 'score': 100, - 'standard': 'IMPORTS' + "passed": True, + "checks": [ + { + "name": "Package marker", + "passed": True, + "message": "__init__.py — Python package marker, no import checks", + } + ], + "score": 100, + "standard": "IMPORTS", } - if is_bypassed(module_path, 'imports', bypass_rules=bypass_rules): + if is_bypassed(module_path, "imports", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'IMPORTS' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "IMPORTS", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'IMPORTS' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "IMPORTS", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'IMPORTS' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "IMPORTS", } filtered_lines = filter_docstrings(lines) import_section_end = find_import_section_end(filtered_lines) import_lines = filtered_lines[:import_section_end] - is_handler = '/handlers/' in module_path - is_init_file = path.name == '__init__.py' - is_small_file = len([line for line in lines if line.strip() and not line.strip().startswith('#')]) < 20 + is_handler = "/handlers/" in module_path + is_init_file = path.name == "__init__.py" + is_small_file = len([line for line in lines if line.strip() and not line.strip().startswith("#")]) < 20 # Check 1: No AIPASS_ROOT (pip packages must not use it) if not is_init_file: @@ -134,18 +140,13 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: if bare_check: checks.append(bare_check) - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "imports"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'IMPORTS' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "IMPORTS"} def _process_docstring_marker(stripped, in_docstring, docstring_marker): @@ -174,9 +175,7 @@ def filter_docstrings(lines: List[str]) -> List[str]: stripped = line.strip() if '"""' in stripped or "'''" in stripped: - skip, in_docstring, docstring_marker = _process_docstring_marker( - stripped, in_docstring, docstring_marker - ) + skip, in_docstring, docstring_marker = _process_docstring_marker(stripped, in_docstring, docstring_marker) if skip: continue @@ -192,7 +191,7 @@ def find_import_section_end(lines: List[str]) -> int: """Find where import section ends (first def/class).""" for i, line in enumerate(lines): stripped = line.strip() - if stripped.startswith('def ') or stripped.startswith('class ') or stripped.startswith('async def '): + if stripped.startswith("def ") or stripped.startswith("class ") or stripped.startswith("async def "): return i return len(lines) @@ -202,26 +201,22 @@ def check_no_aipass_root(lines: List[str], file_path: str = "", bypass_rules: li Check that file does NOT use AIPASS_ROOT. Pip packages resolve paths via installed package paths, not AIPASS_ROOT. """ - if is_bypassed(file_path, 'imports', None, bypass_rules): - return {'name': 'No AIPASS_ROOT', 'passed': True, 'message': 'Bypassed by bypass rules'} + if is_bypassed(file_path, "imports", None, bypass_rules): + return {"name": "No AIPASS_ROOT", "passed": True, "message": "Bypassed by bypass rules"} for i, line in enumerate(lines, 1): stripped = line.strip() - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): continue - code_part = line.split('#')[0] if '#' in line else line - if 'AIPASS_ROOT' in code_part: + code_part = line.split("#")[0] if "#" in line else line + if "AIPASS_ROOT" in code_part: return { - 'name': 'No AIPASS_ROOT', - 'passed': False, - 'message': f'AIPASS_ROOT found on line {i} (pip packages must not use AIPASS_ROOT)' + "name": "No AIPASS_ROOT", + "passed": False, + "message": f"AIPASS_ROOT found on line {i} (pip packages must not use AIPASS_ROOT)", } - return { - 'name': 'No AIPASS_ROOT', - 'passed': True, - 'message': 'No AIPASS_ROOT usage (correct for pip packages)' - } + return {"name": "No AIPASS_ROOT", "passed": True, "message": "No AIPASS_ROOT usage (correct for pip packages)"} def check_no_sys_path(lines: List[str], file_path: str = "", bypass_rules: list | None = None) -> Dict: @@ -229,25 +224,25 @@ def check_no_sys_path(lines: List[str], file_path: str = "", bypass_rules: list Check that file does NOT hack sys.path. Pip packages resolve imports via installed package paths. """ - if is_bypassed(file_path, 'imports', None, bypass_rules): - return {'name': 'No sys.path hacking', 'passed': True, 'message': 'Bypassed by bypass rules'} + if is_bypassed(file_path, "imports", None, bypass_rules): + return {"name": "No sys.path hacking", "passed": True, "message": "Bypassed by bypass rules"} for i, line in enumerate(lines, 1): stripped = line.strip() - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): continue - code_part = line.split('#')[0] if '#' in line else line - if re.search(r'sys\.path\.(insert|append)\s*\(', code_part): + code_part = line.split("#")[0] if "#" in line else line + if re.search(r"sys\.path\.(insert|append)\s*\(", code_part): return { - 'name': 'No sys.path hacking', - 'passed': False, - 'message': f'sys.path manipulation found on line {i} (pip packages must not hack sys.path)' + "name": "No sys.path hacking", + "passed": False, + "message": f"sys.path manipulation found on line {i} (pip packages must not hack sys.path)", } return { - 'name': 'No sys.path hacking', - 'passed': True, - 'message': 'No sys.path manipulation (correct for pip packages)' + "name": "No sys.path hacking", + "passed": True, + "message": "No sys.path manipulation (correct for pip packages)", } @@ -256,78 +251,72 @@ def check_prax_logger(lines: List[str], file_path: str = "", bypass_rules: list Check for Prax logger import via aipass.prax namespace. Pattern: from aipass.prax import logger """ - if is_bypassed(file_path, 'imports', None, bypass_rules): - return {'name': 'Prax logger import', 'passed': True, 'message': 'Bypassed by bypass rules'} + if is_bypassed(file_path, "imports", None, bypass_rules): + return {"name": "Prax logger import", "passed": True, "message": "Bypassed by bypass rules"} for i, line in enumerate(lines, 1): stripped = line.strip() - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): continue - if 'from aipass.prax' in line and 'logger' in line: - return { - 'name': 'Prax logger import', - 'passed': True, - 'message': f'Found on line {i}' - } + if "from aipass.prax" in line and "logger" in line: + return {"name": "Prax logger import", "passed": True, "message": f"Found on line {i}"} return { - 'name': 'Prax logger import (recommended)', - 'passed': False, - 'message': 'Prax logger import not found (recommended: from aipass.prax import logger)' + "name": "Prax logger import (recommended)", + "passed": False, + "message": "Prax logger import not found (recommended: from aipass.prax import logger)", } -def check_handler_independence(lines: List[str], module_path: str = "", bypass_rules: list | None = None) -> Optional[Dict]: +def check_handler_independence( + lines: List[str], module_path: str = "", bypass_rules: list | None = None +) -> Optional[Dict]: """ Check that handlers don't import from parent branch modules. Allowed: from aipass.prax import ... (infrastructure) Allowed: from aipass.cli import ... (infrastructure) Forbidden: from .apps.modules import ... """ - if is_bypassed(module_path, 'imports', None, bypass_rules): - return {'name': 'Handler independence', 'passed': True, 'message': 'Bypassed by bypass rules'} + if is_bypassed(module_path, "imports", None, bypass_rules): + return {"name": "Handler independence", "passed": True, "message": "Bypassed by bypass rules"} parent_branch = None if module_path: path_parts = Path(module_path).parts for i, part in enumerate(path_parts): - if part == 'apps' and i > 0: + if part == "apps" and i > 0: parent_branch = path_parts[i - 1] break for i, line in enumerate(lines, 1): stripped = line.strip() - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): continue - if '.apps.modules' in line and ('from ' in line or 'import ' in line): - code_part = line.split('#')[0] if '#' in line else line - if '.apps.modules' not in code_part: + if ".apps.modules" in line and ("from " in line or "import " in line): + code_part = line.split("#")[0] if "#" in line else line + if ".apps.modules" not in code_part: continue # Allowed: aipass.prax, aipass.cli (infrastructure services) - if 'aipass.prax' in code_part or 'aipass.cli' in code_part: + if "aipass.prax" in code_part or "aipass.cli" in code_part: continue - if parent_branch and f'{parent_branch}.apps.modules' in code_part: + if parent_branch and f"{parent_branch}.apps.modules" in code_part: return { - 'name': 'Handler independence', - 'passed': False, - 'message': f'Handler importing from parent module on line {i} (violates independence rule)' + "name": "Handler independence", + "passed": False, + "message": f"Handler importing from parent module on line {i} (violates independence rule)", } if not parent_branch: return { - 'name': 'Handler independence', - 'passed': False, - 'message': f'Handler importing from branch module on line {i} (violates independence rule)' + "name": "Handler independence", + "passed": False, + "message": f"Handler importing from branch module on line {i} (violates independence rule)", } - return { - 'name': 'Handler independence', - 'passed': True, - 'message': 'No forbidden module imports detected' - } + return {"name": "Handler independence", "passed": True, "message": "No forbidden module imports detected"} def check_import_order(lines: List[str], file_path: str = "", bypass_rules: list | None = None) -> Optional[Dict]: @@ -339,15 +328,15 @@ def check_import_order(lines: List[str], file_path: str = "", bypass_rules: list 2. Third-party packages (import rich, etc.) 3. aipass.* namespace imports (from aipass.prax, from aipass.cli, etc.) """ - if is_bypassed(file_path, 'imports', None, bypass_rules): - return {'name': 'Import order', 'passed': True, 'message': 'Bypassed by bypass rules'} + if is_bypassed(file_path, "imports", None, bypass_rules): + return {"name": "Import order", "passed": True, "message": "Bypassed by bypass rules"} imports = [] for i, line in enumerate(lines, 1): stripped = line.strip() - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): continue - if stripped.startswith('import ') or stripped.startswith('from '): + if stripped.startswith("import ") or stripped.startswith("from "): imports.append((i, stripped)) if not imports: @@ -359,34 +348,51 @@ def check_import_order(lines: List[str], file_path: str = "", bypass_rules: list # Common stdlib modules stdlib_prefixes = ( - 'import os', 'import sys', 'import re', 'import json', 'import time', - 'import logging', 'import subprocess', 'import shutil', 'import copy', - 'import hashlib', 'import datetime', 'import tempfile', 'import ast', - 'import argparse', 'import importlib', 'import inspect', 'import unittest', - 'from pathlib', 'from typing', 'from datetime', 'from collections', - 'from functools', 'from dataclasses', 'from enum', 'from abc', - 'from io', 'from os', 'from contextlib', + "import os", + "import sys", + "import re", + "import json", + "import time", + "import logging", + "import subprocess", + "import shutil", + "import copy", + "import hashlib", + "import datetime", + "import tempfile", + "import ast", + "import argparse", + "import importlib", + "import inspect", + "import unittest", + "from pathlib", + "from typing", + "from datetime", + "from collections", + "from functools", + "from dataclasses", + "from enum", + "from abc", + "from io", + "from os", + "from contextlib", ) for line_num, import_stmt in imports: if any(import_stmt.startswith(p) for p in stdlib_prefixes): last_stdlib_line = line_num - elif 'from aipass.' in import_stmt or import_stmt.startswith('import aipass'): + elif "from aipass." in import_stmt or import_stmt.startswith("import aipass"): if first_aipass_line is None: first_aipass_line = line_num if first_aipass_line and last_stdlib_line and first_aipass_line < last_stdlib_line: return { - 'name': 'Import order', - 'passed': False, - 'message': f'aipass.* import (line {first_aipass_line}) before stdlib import (line {last_stdlib_line})' + "name": "Import order", + "passed": False, + "message": f"aipass.* import (line {first_aipass_line}) before stdlib import (line {last_stdlib_line})", } - return { - 'name': 'Import order', - 'passed': True, - 'message': 'Import order correct (stdlib before aipass.*)' - } + return {"name": "Import order", "passed": True, "message": "Import order correct (stdlib before aipass.*)"} def check_no_bare_imports(lines: List[str], file_path: str = "", bypass_rules: list | None = None) -> Optional[Dict]: @@ -406,84 +412,83 @@ def check_no_bare_imports(lines: List[str], file_path: str = "", bypass_rules: l - from .{name} import ... (relative re-export in __init__.py ONLY — excluded by caller) - Standard library / third-party imports """ - if is_bypassed(file_path, 'imports', None, bypass_rules): - return {'name': 'No bare imports', 'passed': True, 'message': 'Bypassed by bypass rules'} + if is_bypassed(file_path, "imports", None, bypass_rules): + return {"name": "No bare imports", "passed": True, "message": "Bypassed by bypass rules"} # Known AIPass module names (used to detect bare module imports like "from drone.apps...") aipass_modules = { - 'drone', 'seedgo', 'prax', 'cli', 'flow', - 'ai_mail', 'api', 'trigger', 'spawn', 'devpulse', + "drone", + "seedgo", + "prax", + "cli", + "flow", + "ai_mail", + "api", + "trigger", + "spawn", + "devpulse", } # Old namespaces that should not appear - old_namespaces = {'seed', 'cortex', 'nexus', 'atlas', 'sentinel'} + old_namespaces = {"seed", "cortex", "nexus", "atlas", "sentinel"} violations = [] for i, line in enumerate(lines, 1): stripped = line.strip() - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): continue - code_part = line.split('#')[0].strip() if '#' in line else stripped + code_part = line.split("#")[0].strip() if "#" in line else stripped # Only check import statements - if not (code_part.startswith('from ') or code_part.startswith('import ')): + if not (code_part.startswith("from ") or code_part.startswith("import ")): continue # Extract the module path from "from X import Y" or "import X" - if code_part.startswith('from '): + if code_part.startswith("from "): # "from X.Y.Z import thing" -> extract "X.Y.Z" - match = re.match(r'from\s+([\w.]+)', code_part) + match = re.match(r"from\s+([\w.]+)", code_part) if not match: continue import_path = match.group(1) else: # "import X.Y.Z" -> extract "X.Y.Z" - match = re.match(r'import\s+([\w.]+)', code_part) + match = re.match(r"import\s+([\w.]+)", code_part) if not match: continue import_path = match.group(1) - parts = import_path.split('.') - first_part = parts[0] if parts else '' + parts = import_path.split(".") + first_part = parts[0] if parts else "" # Check 1: Bare handler/module imports (from handlers.X or from modules.X) - if first_part in ('handlers', 'modules'): + if first_part in ("handlers", "modules"): violations.append( f'Line {i}: bare import "from {import_path}" ' - f'(must use aipass.* namespace, e.g. from aipass.seedgo.apps.standards.aipass.{import_path})' + f"(must use aipass.* namespace, e.g. from aipass.seedgo.apps.standards.aipass.{import_path})" ) continue # Check 2: Old namespaces (pre-AIPass imports that should not appear) if first_part in old_namespaces and len(parts) > 1: violations.append( - f'Line {i}: old namespace "from {import_path}" ' - f'(old namespace, must use aipass.* namespace)' + f'Line {i}: old namespace "from {import_path}" (old namespace, must use aipass.* namespace)' ) continue # Check 3: Bare AIPass module imports (from drone.apps... instead of from aipass.drone.apps...) - if first_part in aipass_modules and len(parts) > 1 and 'apps' in parts: + if first_part in aipass_modules and len(parts) > 1 and "apps" in parts: violations.append( f'Line {i}: bare module import "from {import_path}" ' - f'(missing aipass. prefix, should be from aipass.{import_path})' + f"(missing aipass. prefix, should be from aipass.{import_path})" ) continue if violations: # Show up to 3 violations in the message shown = violations[:3] - extra = f' (+{len(violations) - 3} more)' if len(violations) > 3 else '' - return { - 'name': 'No bare imports', - 'passed': False, - 'message': '; '.join(shown) + extra - } + extra = f" (+{len(violations) - 3} more)" if len(violations) > 3 else "" + return {"name": "No bare imports", "passed": False, "message": "; ".join(shown) + extra} - return { - 'name': 'No bare imports', - 'passed': True, - 'message': 'All imports use proper aipass.* namespace' - } + return {"name": "No bare imports", "passed": True, "message": "All imports use proper aipass.* namespace"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py index 5b2defbb..ae32f1dd 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py @@ -15,6 +15,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_imports_standards() -> str: """Return formatted import standards content with Rich markup @@ -36,8 +37,7 @@ def get_imports_standards() -> str: " • [dim]from aipass.prax.apps.modules.logger import system_logger as logger[/dim]", "", "[bold]4. Services (CLI, etc.)[/bold]", - " • [dim]from aipass.cli.apps.modules" - " import console, header, success[/dim]", + " • [dim]from aipass.cli.apps.modules import console, header, success[/dim]", "", "[bold]5. Internal handlers[/bold]", " • [dim]from aipass.seedgo.apps.handlers.json import json_handler[/dim]", @@ -53,8 +53,7 @@ def get_imports_standards() -> str: " [dim]from typing import Dict, List, Optional[/dim]", "", " [dim]from aipass.prax.apps.modules.logger import system_logger as logger[/dim]", - " [dim]from aipass.cli.apps.modules" - " import console, header[/dim]", + " [dim]from aipass.cli.apps.modules import console, header[/dim]", " [dim]from aipass.seedgo.apps.handlers.json import json_handler[/dim]", "", "[bold cyan]CRITICAL RULES:[/bold cyan]", @@ -80,11 +79,11 @@ def get_imports_standards() -> str: " • Auto-generates config at [branch]/seedgo_json/user_config.json", "", "[bold]Best Practice - Use Drone:[/bold]", - " [dim]subprocess.run([\"drone\", \"email\", \"send\", \"@recipient\", \"Subject\", \"Message\"])[/dim]", + ' [dim]subprocess.run(["drone", "email", "send", "@recipient", "Subject", "Message"])[/dim]', " • Drone handles PWD detection automatically", "", "[bold]Direct Invocation (Advanced):[/bold]", - " [dim]subprocess.run([\"drone\", \"@ai_mail\", \"send\", ...],[/dim]", + ' [dim]subprocess.run(["drone", "@ai_mail", "send", ...],[/dim]', " [dim] cwd=str(branch_dir)) # Force correct working directory[/dim]", " • Must use cwd= parameter for PWD detection to work", "", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py index cb17f22f..1d5e1f56 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py @@ -34,14 +34,14 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -74,52 +74,52 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'introspection', bypass_rules=bypass_rules): + if is_bypassed(module_path, "introspection", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'INTROSPECTION' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "INTROSPECTION", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'INTROSPECTION' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "INTROSPECTION", } # Skip __init__.py files - if path.name == '__init__.py': + if path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'Introspection check', 'passed': True, 'message': '__init__.py skipped'}], - 'score': 100, - 'standard': 'INTROSPECTION' + "passed": True, + "checks": [{"name": "Introspection check", "passed": True, "message": "__init__.py skipped"}], + "score": 100, + "standard": "INTROSPECTION", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'INTROSPECTION' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "INTROSPECTION", } # Empty file if not content.strip(): return { - 'passed': True, - 'checks': [{'name': 'Introspection check', 'passed': True, 'message': 'Empty file skipped'}], - 'score': 100, - 'standard': 'INTROSPECTION' + "passed": True, + "checks": [{"name": "Introspection check", "passed": True, "message": "Empty file skipped"}], + "score": 100, + "standard": "INTROSPECTION", } # Parse AST @@ -128,23 +128,29 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: except SyntaxError as e: logger.info("Skipped %s: SyntaxError during parse", path) return { - 'passed': False, - 'checks': [{'name': 'File parseable', 'passed': False, 'message': f'Syntax error: {e}'}], - 'score': 0, - 'standard': 'INTROSPECTION' + "passed": False, + "checks": [{"name": "File parseable", "passed": False, "message": f"Syntax error: {e}"}], + "score": 0, + "standard": "INTROSPECTION", } # Determine file type from path is_entry_point = _is_entry_point(module_path, path) - is_module = '/modules/' in module_path and path.parent.name == 'modules' + is_module = "/modules/" in module_path and path.parent.name == "modules" # If neither entry point nor module, skip if not is_entry_point and not is_module: return { - 'passed': True, - 'checks': [{'name': 'Introspection check', 'passed': True, 'message': 'Not an entry point or module file (not applicable)'}], - 'score': 100, - 'standard': 'INTROSPECTION' + "passed": True, + "checks": [ + { + "name": "Introspection check", + "passed": True, + "message": "Not an entry point or module file (not applicable)", + } + ], + "score": 100, + "standard": "INTROSPECTION", } # Check 1: print_introspection exists (applies to both entry points and modules) @@ -181,20 +187,17 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(help_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 # Overall pass if score >= 75% overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "introspection"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'INTROSPECTION' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "introspection"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "INTROSPECTION"} def _is_entry_point(module_path: str, path: Path) -> bool: @@ -204,11 +207,11 @@ def _is_entry_point(module_path: str, path: Path) -> bool: Entry points live at apps/{name}.py — their parent directory is 'apps'. Files in apps/modules/, apps/handlers/, apps/plugins/ etc. are NOT entry points. """ - if not path.name.endswith('.py'): + if not path.name.endswith(".py"): return False - if 'apps/' not in module_path: + if "apps/" not in module_path: return False - return path.parent.name == 'apps' + return path.parent.name == "apps" def check_print_introspection_exists(tree: ast.Module, filename: str) -> Dict: @@ -216,17 +219,17 @@ def check_print_introspection_exists(tree: ast.Module, filename: str) -> Dict: Use AST to check if def print_introspection exists as a top-level function. """ for node in tree.body: - if isinstance(node, ast.FunctionDef) and node.name == 'print_introspection': + if isinstance(node, ast.FunctionDef) and node.name == "print_introspection": return { - 'name': 'print_introspection exists', - 'passed': True, - 'message': f'Found print_introspection() at line {node.lineno} in {filename}' + "name": "print_introspection exists", + "passed": True, + "message": f"Found print_introspection() at line {node.lineno} in {filename}", } return { - 'name': 'print_introspection exists', - 'passed': False, - 'message': f'Missing def print_introspection() in {filename}' + "name": "print_introspection exists", + "passed": False, + "message": f"Missing def print_introspection() in {filename}", } @@ -252,9 +255,9 @@ def check_execution_order(tree: ast.Module, content: str, filename: str) -> Opti if main_func is None: # No main function or __name__ block — can't check order return { - 'name': 'Execution order', - 'passed': True, - 'message': f'No main() or __name__ block found in {filename} (skipped)' + "name": "Execution order", + "passed": True, + "message": f"No main() or __name__ block found in {filename} (skipped)", } # Walk the body of main to find conditionals @@ -277,45 +280,45 @@ def check_execution_order(tree: ast.Module, content: str, filename: str) -> Opti if no_args_line is not None and help_check_line is not None: if no_args_line < help_check_line: return { - 'name': 'Execution order', - 'passed': True, - 'message': f'No-args check (line {no_args_line}) before --help check (line {help_check_line})' + "name": "Execution order", + "passed": True, + "message": f"No-args check (line {no_args_line}) before --help check (line {help_check_line})", } else: return { - 'name': 'Execution order', - 'passed': False, - 'message': f'--help check (line {help_check_line}) before no-args check (line {no_args_line}) — no-args should come first' + "name": "Execution order", + "passed": False, + "message": f"--help check (line {help_check_line}) before no-args check (line {no_args_line}) — no-args should come first", } # If only help check found (no no-args check) if help_check_line is not None and no_args_line is None: return { - 'name': 'Execution order', - 'passed': False, - 'message': f'Found --help check but no no-args check in {filename} — add empty args handling before --help' + "name": "Execution order", + "passed": False, + "message": f"Found --help check but no no-args check in {filename} — add empty args handling before --help", } # If only no-args check found (no help check) — that's fine, help may be elsewhere if no_args_line is not None and help_check_line is None: return { - 'name': 'Execution order', - 'passed': True, - 'message': f'No-args check found at line {no_args_line} (no --help conditional to compare against)' + "name": "Execution order", + "passed": True, + "message": f"No-args check found at line {no_args_line} (no --help conditional to compare against)", } # Neither found — can't determine order return { - 'name': 'Execution order', - 'passed': True, - 'message': f'No args/help conditionals detected in main() of {filename} (skipped)' + "name": "Execution order", + "passed": True, + "message": f"No args/help conditionals detected in main() of {filename} (skipped)", } def _find_main_function(tree: ast.Module) -> Optional[ast.FunctionDef]: """Find the top-level main() function definition.""" for node in tree.body: - if isinstance(node, ast.FunctionDef) and node.name == 'main': + if isinstance(node, ast.FunctionDef) and node.name == "main": return node return None @@ -323,7 +326,7 @@ def _find_main_function(tree: ast.Module) -> Optional[ast.FunctionDef]: def _find_handle_command_function(tree: ast.Module) -> Optional[ast.FunctionDef]: """Find the top-level handle_command() function definition.""" for node in tree.body: - if isinstance(node, ast.FunctionDef) and node.name == 'handle_command': + if isinstance(node, ast.FunctionDef) and node.name == "handle_command": return node return None @@ -353,17 +356,17 @@ def check_module_handle_command_gate(tree: ast.Module, filename: str) -> Optiona if handle_cmd is None: # No handle_command — module may use a different pattern, skip return { - 'name': 'handle_command no-args gate', - 'passed': True, - 'message': f'No handle_command() found in {filename} (skipped)' + "name": "handle_command no-args gate", + "passed": True, + "message": f"No handle_command() found in {filename} (skipped)", } # Walk handle_command body to find a no-args conditional that calls introspection # Known introspection-related function names (direct or wrapper) introspection_names = { - 'print_introspection', - '_show_audit_introspection', - '_show_pack_module_introspection', + "print_introspection", + "_show_audit_introspection", + "_show_pack_module_introspection", } for node in ast.walk(handle_cmd): @@ -377,16 +380,16 @@ def check_module_handle_command_gate(tree: ast.Module, filename: str) -> Optiona calls = _get_function_calls_in_block(node.body) if calls & introspection_names: return { - 'name': 'handle_command no-args gate', - 'passed': True, - 'message': f'handle_command() gates on no-args at line {node.lineno} → introspection' + "name": "handle_command no-args gate", + "passed": True, + "message": f"handle_command() gates on no-args at line {node.lineno} → introspection", } # No no-args gate found that dispatches to introspection return { - 'name': 'handle_command no-args gate', - 'passed': False, - 'message': f'handle_command() in {filename} has no no-args gate calling print_introspection() — module will not show introspection when called with no arguments' + "name": "handle_command no-args gate", + "passed": False, + "message": f"handle_command() in {filename} has no no-args gate calling print_introspection() — module will not show introspection when called with no arguments", } @@ -401,11 +404,13 @@ def _find_name_main_block(tree: ast.Module) -> Optional[ast.If]: test = node.test if isinstance(test, ast.Compare): # Left side: __name__ - if isinstance(test.left, ast.Name) and test.left.id == '__name__': + if isinstance(test.left, ast.Name) and test.left.id == "__name__": # Right side: '__main__' - if (test.comparators and - isinstance(test.comparators[0], ast.Constant) and - test.comparators[0].value == '__main__'): + if ( + test.comparators + and isinstance(test.comparators[0], ast.Constant) + and test.comparators[0].value == "__main__" + ): return node return None @@ -428,7 +433,7 @@ def _is_no_args_check(node: ast.If) -> bool: if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not): operand = test.operand # not args - if isinstance(operand, ast.Name) and operand.id == 'args': + if isinstance(operand, ast.Name) and operand.id == "args": return True # not sys.argv[1:] if isinstance(operand, ast.Subscript): @@ -439,11 +444,11 @@ def _is_no_args_check(node: ast.If) -> bool: left = test.left # Check if left side is len(args) or len(sys.argv) - if isinstance(left, ast.Call) and isinstance(left.func, ast.Name) and left.func.id == 'len': + if isinstance(left, ast.Call) and isinstance(left.func, ast.Name) and left.func.id == "len": if left.args: arg = left.args[0] # len(args) == 0 - if isinstance(arg, ast.Name) and arg.id == 'args': + if isinstance(arg, ast.Name) and arg.id == "args": # Check comparator is 0 or 1 if test.comparators and isinstance(test.comparators[0], ast.Constant): val = test.comparators[0].value @@ -451,8 +456,7 @@ def _is_no_args_check(node: ast.If) -> bool: return True # len(sys.argv) == 1 or len(sys.argv) < 2 if isinstance(arg, ast.Attribute): - if (isinstance(arg.value, ast.Name) and arg.value.id == 'sys' and - arg.attr == 'argv'): + if isinstance(arg.value, ast.Name) and arg.value.id == "sys" and arg.attr == "argv": if test.comparators and isinstance(test.comparators[0], ast.Constant): val = test.comparators[0].value if val in (1, 2): @@ -481,7 +485,7 @@ def _ast_contains_help_string(node: ast.AST) -> bool: """ # Direct string constant if isinstance(node, ast.Constant) and isinstance(node.value, str): - if node.value in ('--help', '-h'): + if node.value in ("--help", "-h"): return True # Walk all child nodes @@ -516,41 +520,41 @@ def check_correct_dispatch(tree: ast.Module, filename: str) -> Optional[Dict]: # Check no-args block if _is_no_args_check(node): calls = _get_function_calls_in_block(node.body) - if 'print_help' in calls and 'print_introspection' not in calls: + if "print_help" in calls and "print_introspection" not in calls: return { - 'name': 'Correct dispatch', - 'passed': False, - 'message': f'No-args block calls print_help() in {filename} (line {node.lineno}) — should call print_introspection() (introspection != help)' + "name": "Correct dispatch", + "passed": False, + "message": f"No-args block calls print_help() in {filename} (line {node.lineno}) — should call print_introspection() (introspection != help)", } # Check --help block — look for help strings in the condition if _is_help_check(node): calls = _get_function_calls_in_block(node.body) # Calls introspection-related functions instead of print_help - introspection_funcs = calls & {'print_introspection', '_show_pack_module_introspection'} - if introspection_funcs and 'print_help' not in calls: + introspection_funcs = calls & {"print_introspection", "_show_pack_module_introspection"} + if introspection_funcs and "print_help" not in calls: return { - 'name': 'Correct dispatch', - 'passed': False, - 'message': f'--help block calls {introspection_funcs.pop()}() in {filename} (line {node.lineno}) — should call print_help() (help != introspection)' + "name": "Correct dispatch", + "passed": False, + "message": f"--help block calls {introspection_funcs.pop()}() in {filename} (line {node.lineno}) — should call print_help() (help != introspection)", } # Also check: condition contains --help string AND body calls introspection # This catches compound conditionals like: if not remaining or remaining[0] in ["--help"] if _ast_contains_help_string(node.test): calls = _get_function_calls_in_block(node.body) - introspection_funcs = calls & {'print_introspection', '_show_pack_module_introspection'} - if introspection_funcs and 'print_help' not in calls: + introspection_funcs = calls & {"print_introspection", "_show_pack_module_introspection"} + if introspection_funcs and "print_help" not in calls: return { - 'name': 'Correct dispatch', - 'passed': False, - 'message': f'Block with --help condition calls {introspection_funcs.pop()}() in {filename} (line {node.lineno}) — --help should show help, not introspection' + "name": "Correct dispatch", + "passed": False, + "message": f"Block with --help condition calls {introspection_funcs.pop()}() in {filename} (line {node.lineno}) — --help should show help, not introspection", } return { - 'name': 'Correct dispatch', - 'passed': True, - 'message': 'No-args → introspection, --help → help (correct separation)' + "name": "Correct dispatch", + "passed": True, + "message": "No-args → introspection, --help → help (correct separation)", } @@ -577,7 +581,7 @@ def check_content_references(tree: ast.Module, filename: str) -> Optional[Dict]: Help/introspection text that references python3 is misleading and causes agents to follow instructions that don't work. """ - target_funcs = {'print_introspection', 'print_help'} + target_funcs = {"print_introspection", "print_help"} python3_refs = [] found_funcs = set() @@ -588,24 +592,24 @@ def check_content_references(tree: ast.Module, filename: str) -> Optional[Dict]: for child in ast.walk(node): if isinstance(child, ast.Constant) and isinstance(child.value, str): val = child.value.lower() - if 'python3 ' in val or 'python3\n' in val: + if "python3 " in val or "python3\n" in val: python3_refs.append((node.name, child.lineno)) if not found_funcs: return None # No relevant functions to check if python3_refs: - refs_str = ', '.join(f'{fn}() line {ln}' for fn, ln in python3_refs[:3]) + refs_str = ", ".join(f"{fn}() line {ln}" for fn, ln in python3_refs[:3]) return { - 'name': 'Content references', - 'passed': False, - 'message': f'Help/introspection text references python3 instead of drone commands: {refs_str} in {filename} — use "drone @branch command" instead' + "name": "Content references", + "passed": False, + "message": f'Help/introspection text references python3 instead of drone commands: {refs_str} in {filename} — use "drone @branch command" instead', } return { - 'name': 'Content references', - 'passed': True, - 'message': 'Help/introspection text uses correct command references' + "name": "Content references", + "passed": True, + "message": "Help/introspection text uses correct command references", } @@ -625,13 +629,13 @@ def check_module_help_interception(tree: ast.Module, filename: str) -> Optional[ for node in ast.walk(handle_cmd): if isinstance(node, ast.If) and _is_help_check(node): return { - 'name': 'Module help interception', - 'passed': True, - 'message': f'handle_command() intercepts --help at line {node.lineno}' + "name": "Module help interception", + "passed": True, + "message": f"handle_command() intercepts --help at line {node.lineno}", } return { - 'name': 'Module help interception', - 'passed': False, - 'message': f'handle_command() in {filename} does not intercept --help — flag may fall through to business logic' + "name": "Module help interception", + "passed": False, + "message": f"handle_command() in {filename} does not intercept --help — flag may fall through to business logic", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_content.py index c802f8ec..0c558ea1 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_content.py @@ -15,6 +15,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_introspection_standards() -> str: """Return formatted introspection standards content with Rich markup @@ -32,17 +33,16 @@ def get_introspection_standards() -> str: "[bold cyan]LEVEL 1 — Entry Point (apps/{name}.py) with no args:[/bold cyan]", "", " • Shows branch name + description", - " • Auto-discovers modules in [dim]modules/*.py[/dim]" - " that have [dim]handle_command()[/dim]", + " • Auto-discovers modules in [dim]modules/*.py[/dim] that have [dim]handle_command()[/dim]", " • Lists discovered module names", " • Points to [dim]--help[/dim] for full usage", " • Function name: [bold]print_introspection()[/bold]", "", " [dim]def print_introspection():[/dim]", ' [dim] """Show branch structure via auto-discovery"""[/dim]', - " [dim] modules_dir = Path(__file__).parent / \"modules\"[/dim]", - " [dim] for file_path in sorted(modules_dir.glob(\"*.py\")):[/dim]", - " [dim] if file_path.name.startswith(\"_\"):[/dim]", + ' [dim] modules_dir = Path(__file__).parent / "modules"[/dim]', + ' [dim] for file_path in sorted(modules_dir.glob("*.py")):[/dim]', + ' [dim] if file_path.name.startswith("_"):[/dim]', " [dim] continue[/dim]", " [dim] # load and check for handle_command()[/dim]", "", @@ -63,9 +63,9 @@ def get_introspection_standards() -> str: "", " [dim]def print_introspection():[/dim]", ' [dim] """Show module structure - connected handlers"""[/dim]', - " [dim] console.print(\"[bold cyan]create_plan[/bold cyan] Module\")[/dim]", - " [dim] console.print(\"[yellow]Connected Handlers:[/yellow]\")[/dim]", - " [dim] console.print(\" handlers/plan/ - command_parser.py, ...\")[/dim]", + ' [dim] console.print("[bold cyan]create_plan[/bold cyan] Module")[/dim]', + ' [dim] console.print("[yellow]Connected Handlers:[/yellow]")[/dim]', + ' [dim] console.print(" handlers/plan/ - command_parser.py, ...")[/dim]', "", " [green]Output example:[/green]", " [dim]create_plan Module[/dim]", @@ -107,14 +107,12 @@ def get_introspection_standards() -> str: "", "[bold cyan]AUTO-DISCOVERY REQUIREMENTS:[/bold cyan]", "", - " [green]✓[/green] Level 1: Scan [dim]modules/*.py[/dim] dynamically" - " (no hardcoded module lists)", + " [green]✓[/green] Level 1: Scan [dim]modules/*.py[/dim] dynamically (no hardcoded module lists)", " [green]✓[/green] Level 1: Filter by [dim]handle_command()[/dim] presence", " [green]✓[/green] Level 2: List connected handlers grouped by domain", " [green]✓[/green] Both levels: Function named [dim]print_introspection()[/dim]", "", - " [red]✗[/red] Hardcoded module lists:" - " [dim]MODULES = ['create', 'delete', 'list'][/dim]", + " [red]✗[/red] Hardcoded module lists: [dim]MODULES = ['create', 'delete', 'list'][/dim]", " [red]✗[/red] Combined introspection + help in one function", " [red]✗[/red] Help before introspection in execution order", "", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py index 375a8358..438ccce7 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py @@ -36,12 +36,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -67,92 +67,88 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ path = Path(module_path) - if is_bypassed(module_path, 'json_structure', bypass_rules=bypass_rules): + if is_bypassed(module_path, "json_structure", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'JSON STRUCTURE' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "JSON STRUCTURE", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'JSON STRUCTURE' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "JSON STRUCTURE", } # Skip __init__.py files - if path.name == '__init__.py': + if path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'JSON structure check', 'passed': True, 'message': '__init__.py file (skipped)'}], - 'score': 100, - 'standard': 'JSON STRUCTURE' + "passed": True, + "checks": [{"name": "JSON structure check", "passed": True, "message": "__init__.py file (skipped)"}], + "score": 100, + "standard": "JSON STRUCTURE", } try: - content = path.read_text(encoding='utf-8') + content = path.read_text(encoding="utf-8") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'JSON STRUCTURE' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "JSON STRUCTURE", } path_str = str(path) # --- Case (a): json_handler.py in a json/ directory --- - if 'json_handler' in path.name and path.parent.name == 'json': + if "json_handler" in path.name and path.parent.name == "json": checks = _check_json_handler_config(path, content, bypass_rules) - passed_count = sum(1 for c in checks if c['passed']) + passed_count = sum(1 for c in checks if c["passed"]) total = len(checks) score = int((passed_count / total * 100)) if total > 0 else 0 - return { - 'passed': score >= 75, - 'checks': checks, - 'score': score, - 'standard': 'JSON STRUCTURE' - } + return {"passed": score >= 75, "checks": checks, "score": score, "standard": "JSON STRUCTURE"} # --- Determine if the file is in modules/ or handlers/ --- - in_modules = 'apps/modules' in path_str or 'apps\\modules' in path_str - in_handlers = 'apps/handlers' in path_str or 'apps\\handlers' in path_str + in_modules = "apps/modules" in path_str or "apps\\modules" in path_str + in_handlers = "apps/handlers" in path_str or "apps\\handlers" in path_str # Exclude files inside the json/ handler directory itself (they ARE the # json infrastructure, not consumers of it) - if in_handlers and path.parent.name == 'json': + if in_handlers and path.parent.name == "json": return { - 'passed': True, - 'checks': [{'name': 'JSON structure check', 'passed': True, - 'message': 'JSON handler infrastructure file (not applicable)'}], - 'score': 100, - 'standard': 'JSON STRUCTURE' + "passed": True, + "checks": [ + { + "name": "JSON structure check", + "passed": True, + "message": "JSON handler infrastructure file (not applicable)", + } + ], + "score": 100, + "standard": "JSON STRUCTURE", } # --- Cases (b) and (c): code wiring check --- if in_modules or in_handlers: checks = _check_code_wiring(path, content) - passed_count = sum(1 for c in checks if c['passed']) + passed_count = sum(1 for c in checks if c["passed"]) total = len(checks) score = int((passed_count / total * 100)) if total > 0 else 0 - return { - 'passed': passed_count == total, - 'checks': checks, - 'score': score, - 'standard': 'JSON STRUCTURE' - } + return {"passed": passed_count == total, "checks": checks, "score": score, "standard": "JSON STRUCTURE"} # --- Case (d): file outside modules/ and handlers/ (entry point, etc.) --- return { - 'passed': True, - 'checks': [{'name': 'JSON structure check', 'passed': True, - 'message': 'Not in modules/ or handlers/ (not applicable)'}], - 'score': 100, - 'standard': 'JSON STRUCTURE' + "passed": True, + "checks": [ + {"name": "JSON structure check", "passed": True, "message": "Not in modules/ or handlers/ (not applicable)"} + ], + "score": 100, + "standard": "JSON STRUCTURE", } @@ -160,6 +156,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # Internal helpers # ------------------------------------------------------------------ + def _check_code_wiring(_path: Path, content: str) -> List[Dict]: """ Check that a module/handler file has the three-JSON wiring: @@ -176,25 +173,31 @@ def _check_code_wiring(_path: Path, content: str) -> List[Dict]: # from aipass.flow.apps.handlers.json import json_handler # from ...handlers.json import json_handler has_import = bool( - re.search(r'from\s+\S*\.json\s+import\s+json_handler', content) - or re.search(r'from\s+\S*json\s+import\s+json_handler', content) - or re.search(r'import\s+json_handler', content) + re.search(r"from\s+\S*\.json\s+import\s+json_handler", content) + or re.search(r"from\s+\S*json\s+import\s+json_handler", content) + or re.search(r"import\s+json_handler", content) + ) + checks.append( + { + "name": "json_handler import", + "passed": has_import, + "message": "Imports json_handler" + if has_import + else "Missing json_handler import — add: from aipass..apps.handlers.json import json_handler", + } ) - checks.append({ - 'name': 'json_handler import', - 'passed': has_import, - 'message': 'Imports json_handler' if has_import - else 'Missing json_handler import — add: from aipass..apps.handlers.json import json_handler' - }) # Check 2: calls json_handler.log_operation() - has_log_operation = 'json_handler.log_operation' in content - checks.append({ - 'name': 'log_operation call', - 'passed': has_log_operation, - 'message': 'Calls json_handler.log_operation()' if has_log_operation - else 'Missing json_handler.log_operation() call — every module/handler must log operations' - }) + has_log_operation = "json_handler.log_operation" in content + checks.append( + { + "name": "log_operation call", + "passed": has_log_operation, + "message": "Calls json_handler.log_operation()" + if has_log_operation + else "Missing json_handler.log_operation() call — every module/handler must log operations", + } + ) return checks @@ -212,77 +215,92 @@ def _check_json_handler_config(_handler_path: Path, content: str, _bypass_rules: checks: List[Dict] = [] # Check 1: No hardcoded absolute paths - has_path_home = bool(re.search(r'Path\.home\(\)', content)) + has_path_home = bool(re.search(r"Path\.home\(\)", content)) # Only flag _ROOT constants that use Path.home() (legacy pattern) # Allow _ROOT = Path(__file__).resolve()... (relative, pip-safe) has_branch_root = False - for line in content.split('\n'): + for line in content.split("\n"): stripped = line.strip() - if stripped.startswith('#'): + if stripped.startswith("#"): continue - if re.search(r'\w+_ROOT\s*=', stripped) and 'Path.home()' in stripped: + if re.search(r"\w+_ROOT\s*=", stripped) and "Path.home()" in stripped: has_branch_root = True break if has_path_home or has_branch_root: issues = [] if has_path_home: - issues.append('Path.home()') + issues.append("Path.home()") if has_branch_root: - issues.append('hardcoded _ROOT constant') - checks.append({ - 'name': 'No absolute paths', - 'passed': False, - 'message': f'Found {", ".join(issues)} — pip packages should use relative paths' - }) + issues.append("hardcoded _ROOT constant") + checks.append( + { + "name": "No absolute paths", + "passed": False, + "message": f"Found {', '.join(issues)} — pip packages should use relative paths", + } + ) else: - checks.append({ - 'name': 'No absolute paths', - 'passed': True, - 'message': 'No hardcoded absolute paths (correct for pip packages)' - }) + checks.append( + { + "name": "No absolute paths", + "passed": True, + "message": "No hardcoded absolute paths (correct for pip packages)", + } + ) # Check 2: Uses relative path resolution - has_relative = bool(re.search(r'Path\(__file__\)', content) - or re.search(r'\.resolve\(\)', content) - or re.search(r'\.parent', content)) + has_relative = bool( + re.search(r"Path\(__file__\)", content) + or re.search(r"\.resolve\(\)", content) + or re.search(r"\.parent", content) + ) - checks.append({ - 'name': 'Relative path resolution', - 'passed': has_relative, - 'message': 'Uses relative path resolution (Path(__file__).parent)' if has_relative - else 'Missing relative path resolution — should use Path(__file__).resolve().parent' - }) + checks.append( + { + "name": "Relative path resolution", + "passed": has_relative, + "message": "Uses relative path resolution (Path(__file__).parent)" + if has_relative + else "Missing relative path resolution — should use Path(__file__).resolve().parent", + } + ) # Check 3: No template directory references # The standard says: "The CODE PATTERN is the template -- no json_templates/ directory" # Check for path constants or strings referencing json_templates has_template_dir = False - for line in content.split('\n'): + for line in content.split("\n"): stripped = line.strip() - if stripped.startswith('#'): + if stripped.startswith("#"): continue - if 'json_templates' in stripped: + if "json_templates" in stripped: has_template_dir = True break - checks.append({ - 'name': 'No template directory', - 'passed': not has_template_dir, - 'message': 'No json_templates/ references (correct — code is the template)' if not has_template_dir - else 'References json_templates/ directory — standard requires auto-create from code defaults, not file templates' - }) + checks.append( + { + "name": "No template directory", + "passed": not has_template_dir, + "message": "No json_templates/ references (correct — code is the template)" + if not has_template_dir + else "References json_templates/ directory — standard requires auto-create from code defaults, not file templates", + } + ) # Check 4: No load_template() function # The correct pattern uses inline defaults (_create_default or similar). # A load_template() that reads from files violates the auto-create principle. - has_load_template = bool(re.search(r'def\s+load_template\s*\(', content)) + has_load_template = bool(re.search(r"def\s+load_template\s*\(", content)) - checks.append({ - 'name': 'No file-based templates', - 'passed': not has_load_template, - 'message': 'No load_template() function (correct — uses inline code defaults)' if not has_load_template - else 'Has load_template() function — standard requires inline code defaults, not file-based templates' - }) + checks.append( + { + "name": "No file-based templates", + "passed": not has_load_template, + "message": "No load_template() function (correct — uses inline code defaults)" + if not has_load_template + else "Has load_template() function — standard requires inline code defaults, not file-based templates", + } + ) return checks diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py index 38b9a7e8..c6c5af15 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py @@ -53,17 +53,17 @@ def get_json_structure_standards() -> str: " [dim]from aipass.{branch}.apps.handlers.json import json_handler[/dim]", "", " [dim]# At end of main operations:[/dim]", - " [dim]json_handler.log_operation(\"operation_name\", {[/dim]", - " [dim] \"key\": \"value\",[/dim]", - " [dim] \"success\": True[/dim]", + ' [dim]json_handler.log_operation("operation_name", {[/dim]', + ' [dim] "key": "value",[/dim]', + ' [dim] "success": True[/dim]', " [dim]})[/dim]", "", " [bold]Example (flow branch):[/bold]", " [dim]from aipass.flow.apps.handlers.json import json_handler[/dim]", "", - " [dim]json_handler.log_operation(\"plan_created\", {[/dim]", - " [dim] \"command\": command,[/dim]", - " [dim] \"args\": args[/dim]", + ' [dim]json_handler.log_operation("plan_created", {[/dim]', + ' [dim] "command": command,[/dim]', + ' [dim] "args": args[/dim]', " [dim]})[/dim]", "", " [yellow]Both the import AND the log_operation call are required.[/yellow]", @@ -78,7 +78,7 @@ def get_json_structure_standards() -> str: "", " [dim]_BRANCH_ROOT = Path(__file__).resolve().parents[3][/dim]", " [dim]_BRANCH_NAME = _BRANCH_ROOT.name[/dim]", - " [dim]JSON_DIR = _BRANCH_ROOT / f\"{_BRANCH_NAME}_json\"[/dim]", + ' [dim]JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json"[/dim]', "", " [green]No per-branch customization needed.[/green]", " Spawn ships the template, branches just copy it.", @@ -105,9 +105,9 @@ def get_json_structure_standards() -> str: " Bypass only if the circular import is truly unavoidable.", "", " [dim]Example bypass entry:[/dim]", - " [dim]{\"file\": \"apps/handlers/init/bootstrap.py\",[/dim]", - " [dim] \"standard\": \"json_structure\",[/dim]", - " [dim] \"reason\": \"Pure Python bootstrap — no branch imports by design\"}[/dim]", + ' [dim]{"file": "apps/handlers/init/bootstrap.py",[/dim]', + ' [dim] "standard": "json_structure",[/dim]', + ' [dim] "reason": "Pure Python bootstrap — no branch imports by design"}[/dim]', "", "─" * 70, "", @@ -141,7 +141,7 @@ def get_json_structure_standards() -> str: " Keeps most recent N entries.", "", " [dim]{[/dim]", - " [dim] \"config\": { \"max_log_entries\": 100 }[/dim]", + ' [dim] "config": { "max_log_entries": 100 }[/dim]', " [dim]}[/dim]", "", "─" * 70, diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_check.py index b490a524..428965a3 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_check.py @@ -29,17 +29,18 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -65,56 +66,60 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] path = Path(module_path) - if is_bypassed(module_path, 'log_handler', bypass_rules=bypass_rules): + if is_bypassed(module_path, "log_handler", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'LOG_HANDLER' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "LOG_HANDLER", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'LOG_HANDLER' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "LOG_HANDLER", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'LOG_HANDLER' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "LOG_HANDLER", } # Skip files that don't set up logging handlers at all has_handler_setup = re.search( - r'logging\.(FileHandler|StreamHandler)\s*\(|' - r'\.addHandler\s*\(', - content + r"logging\.(FileHandler|StreamHandler)\s*\(|" + r"\.addHandler\s*\(", + content, ) if not has_handler_setup: return { - 'passed': True, - 'checks': [{'name': 'Log handler check', 'passed': True, 'message': 'No log handler setup found (skipped)'}], - 'score': 100, - 'standard': 'LOG_HANDLER' + "passed": True, + "checks": [ + {"name": "Log handler check", "passed": True, "message": "No log handler setup found (skipped)"} + ], + "score": 100, + "standard": "LOG_HANDLER", } # Exempt prax logging infrastructure (it IS the RotatingFileHandler implementation) - if 'prax' in module_path and ('logging' in module_path or 'setup.py' in module_path or 'terminal' in module_path): + if "prax" in module_path and ("logging" in module_path or "setup.py" in module_path or "terminal" in module_path): return { - 'passed': True, - 'checks': [{'name': 'Log handler check', 'passed': True, 'message': 'Prax logging infrastructure (exempt)'}], - 'score': 100, - 'standard': 'LOG_HANDLER' + "passed": True, + "checks": [ + {"name": "Log handler check", "passed": True, "message": "Prax logging infrastructure (exempt)"} + ], + "score": 100, + "standard": "LOG_HANDLER", } # Check 1: No raw logging.FileHandler @@ -126,18 +131,13 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(stream_handler_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "log_handler"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'LOG_HANDLER' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "LOG_HANDLER"} def check_no_raw_file_handler(lines: List[str], file_path: str, bypass_rules: list | None = None) -> Dict: @@ -150,67 +150,67 @@ def check_no_raw_file_handler(lines: List[str], file_path: str, bypass_rules: li for i, line in enumerate(lines, 1): stripped = line.strip() # Skip comments and docstrings - if stripped.startswith('#'): + if stripped.startswith("#"): continue # Detect logging.FileHandler (but not RotatingFileHandler) - if re.search(r'logging\.FileHandler\s*\(', line): - if not is_bypassed(file_path, 'log_handler', i, bypass_rules): + if re.search(r"logging\.FileHandler\s*\(", line): + if not is_bypassed(file_path, "log_handler", i, bypass_rules): violations.append(i) if violations: return { - 'name': 'No raw FileHandler', - 'passed': False, - 'message': f'Raw logging.FileHandler on lines {violations[:5]} - use RotatingFileHandler via prax system_logger' + "name": "No raw FileHandler", + "passed": False, + "message": f"Raw logging.FileHandler on lines {violations[:5]} - use RotatingFileHandler via prax system_logger", } - return { - 'name': 'No raw FileHandler', - 'passed': True, - 'message': 'No raw logging.FileHandler found' - } + return {"name": "No raw FileHandler", "passed": True, "message": "No raw logging.FileHandler found"} -def check_no_raw_stream_handler(lines: List[str], file_path: str, content: str, bypass_rules: list | None = None) -> Dict: +def check_no_raw_stream_handler( + lines: List[str], file_path: str, content: str, bypass_rules: list | None = None +) -> Dict: """ Check that logging.StreamHandler is not used for log file output. StreamHandler attached to loggers that also write to files indicates a logging setup that bypasses prax. """ # Only flag StreamHandler if the file also sets up file-based logging - has_file_logging = bool(re.search( - r'logging\.(FileHandler|RotatingFileHandler)\s*\(|' - r'system_logs|\.log["\']', - content - )) + has_file_logging = bool( + re.search( + r"logging\.(FileHandler|RotatingFileHandler)\s*\(|" + r'system_logs|\.log["\']', + content, + ) + ) if not has_file_logging: return { - 'name': 'No raw StreamHandler with file logging', - 'passed': True, - 'message': 'No file-based logging setup found (check not applicable)' + "name": "No raw StreamHandler with file logging", + "passed": True, + "message": "No file-based logging setup found (check not applicable)", } violations = [] for i, line in enumerate(lines, 1): stripped = line.strip() - if stripped.startswith('#'): + if stripped.startswith("#"): continue - if re.search(r'logging\.StreamHandler\s*\(', line): - if not is_bypassed(file_path, 'log_handler', i, bypass_rules): + if re.search(r"logging\.StreamHandler\s*\(", line): + if not is_bypassed(file_path, "log_handler", i, bypass_rules): violations.append(i) if violations: return { - 'name': 'No raw StreamHandler with file logging', - 'passed': False, - 'message': f'Raw logging.StreamHandler with file logging on lines {violations[:5]} - use prax system_logger instead' + "name": "No raw StreamHandler with file logging", + "passed": False, + "message": f"Raw logging.StreamHandler with file logging on lines {violations[:5]} - use prax system_logger instead", } return { - 'name': 'No raw StreamHandler with file logging', - 'passed': True, - 'message': 'No raw StreamHandler with file logging found' + "name": "No raw StreamHandler with file logging", + "passed": True, + "message": "No raw StreamHandler with file logging found", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_content.py index da0db9fc..d39c8bc2 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_handler_content.py @@ -14,6 +14,7 @@ Provides Rich-formatted reference text for the log handler rotation standard. from aipass.seedgo.apps.handlers.json import json_handler + def get_log_handler_standards() -> str: """Return Rich-formatted log handler standards text""" json_handler.log_operation("standard_content_queried", {"standard": "log_handler"}) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_check.py index 2b1ead50..98a9be78 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_check.py @@ -29,17 +29,18 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -65,43 +66,43 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] path = Path(module_path) - if is_bypassed(module_path, 'log_level', bypass_rules=bypass_rules): + if is_bypassed(module_path, "log_level", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'LOG_LEVEL' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "LOG_LEVEL", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'LOG_LEVEL' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "LOG_LEVEL", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'LOG_LEVEL' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "LOG_LEVEL", } # Only check files that use logger - has_logger = re.search(r'\blogger\.(error|warning|info|debug)\s*\(', content) + has_logger = re.search(r"\blogger\.(error|warning|info|debug)\s*\(", content) if not has_logger: return { - 'passed': True, - 'checks': [{'name': 'Log level check', 'passed': True, 'message': 'No logger calls found (skipped)'}], - 'score': 100, - 'standard': 'LOG_LEVEL' + "passed": True, + "checks": [{"name": "Log level check", "passed": True, "message": "No logger calls found (skipped)"}], + "score": 100, + "standard": "LOG_LEVEL", } # Check 1: ERROR not used for user input errors @@ -114,18 +115,13 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(command_routing_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "log_level"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'LOG_LEVEL' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "LOG_LEVEL"} def _get_non_code_lines(lines: List[str]) -> set: @@ -154,7 +150,7 @@ def _get_non_code_lines(lines: List[str]) -> set: skip.add(i) continue - if in_docstring or stripped.startswith('#'): + if in_docstring or stripped.startswith("#"): skip.add(i) return skip @@ -176,16 +172,16 @@ def check_error_not_user_input(lines: List[str], file_path: str, bypass_rules: l "unrecognized", "no such command", "bad syntax", "missing required" """ user_input_patterns = [ - r'unknown\s+command', - r'unknown\s+action', - r'unrecognized', - r'invalid\s+arg', - r'invalid\s+command', - r'invalid\s+option', - r'bad\s+syntax', - r'no\s+module\s+handled', - r'not\s+a\s+valid\s+command', - r'command\s+not\s+(found|recognized|supported)', + r"unknown\s+command", + r"unknown\s+action", + r"unrecognized", + r"invalid\s+arg", + r"invalid\s+command", + r"invalid\s+option", + r"bad\s+syntax", + r"no\s+module\s+handled", + r"not\s+a\s+valid\s+command", + r"command\s+not\s+(found|recognized|supported)", ] violations = [] @@ -195,33 +191,35 @@ def check_error_not_user_input(lines: List[str], file_path: str, bypass_rules: l if i in skip_lines: continue - if re.search(r'logger\.error\s*\(', line): + if re.search(r"logger\.error\s*\(", line): if _matches_user_input_pattern(line.lower(), user_input_patterns): - if not is_bypassed(file_path, 'log_level', i, bypass_rules): + if not is_bypassed(file_path, "log_level", i, bypass_rules): violations.append(i) if violations: return { - 'name': 'ERROR reserved for system failures', - 'passed': False, - 'message': f'ERROR level used for user input on lines {violations[:5]} - use WARNING level instead' + "name": "ERROR reserved for system failures", + "passed": False, + "message": f"ERROR level used for user input on lines {violations[:5]} - use WARNING level instead", } return { - 'name': 'ERROR reserved for system failures', - 'passed': True, - 'message': 'ERROR level correctly used for system failures only' + "name": "ERROR reserved for system failures", + "passed": True, + "message": "ERROR level correctly used for system failures only", } -def check_command_routing_level(content: str, lines: List[str], file_path: str, bypass_rules: list | None = None) -> Optional[Dict]: +def check_command_routing_level( + content: str, lines: List[str], file_path: str, bypass_rules: list | None = None +) -> Optional[Dict]: """ Check that command routing failures use WARNING, not ERROR. Entry points and modules that route commands should log unrecognized commands as WARNING (user typed wrong thing) not ERROR (system broke). """ - has_command_routing = bool(re.search(r'(route_command|handle_command|args\.command)', content)) + has_command_routing = bool(re.search(r"(route_command|handle_command|args\.command)", content)) if not has_command_routing: return None @@ -232,21 +230,21 @@ def check_command_routing_level(content: str, lines: List[str], file_path: str, if i in skip_lines: continue - if re.search(r'logger\.error\s*\(', line): + if re.search(r"logger\.error\s*\(", line): line_lower = line.lower() - if re.search(r'(unknown\s+command|unrecognized|not\s+handled|no\s+module\s+handled)', line_lower): - if not is_bypassed(file_path, 'log_level', i, bypass_rules): + if re.search(r"(unknown\s+command|unrecognized|not\s+handled|no\s+module\s+handled)", line_lower): + if not is_bypassed(file_path, "log_level", i, bypass_rules): violations.append(i) if violations: return { - 'name': 'Command routing uses WARNING', - 'passed': False, - 'message': f'Command routing failures logged as ERROR on lines {violations[:5]} - should be WARNING' + "name": "Command routing uses WARNING", + "passed": False, + "message": f"Command routing failures logged as ERROR on lines {violations[:5]} - should be WARNING", } return { - 'name': 'Command routing uses WARNING', - 'passed': True, - 'message': 'Command routing failures correctly use WARNING level' + "name": "Command routing uses WARNING", + "passed": True, + "message": "Command routing failures correctly use WARNING level", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_content.py index 24774567..45060245 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_level_content.py @@ -14,6 +14,7 @@ Provides Rich-formatted reference text for the log level hygiene standard. from aipass.seedgo.apps.handlers.json import json_handler + def get_log_level_standards() -> str: """Return Rich-formatted log level hygiene standards text""" json_handler.log_operation("standard_content_queried", {"standard": "log_level"}) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py index 7245d41a..ff6c7adb 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py @@ -55,12 +55,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -86,89 +86,95 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) checks = [] - if is_bypassed(module_path, 'log_structure', bypass_rules=bypass_rules): + if is_bypassed(module_path, "log_structure", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'LOG_STRUCTURE' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "LOG_STRUCTURE", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'LOG_STRUCTURE' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "LOG_STRUCTURE", } # Check 1: Branch-root log placement — logs/ directory at the branch root branch_root = _find_branch_root(path) logs_dir = branch_root / "logs" has_logs_dir = logs_dir.is_dir() - checks.append({ - 'name': 'Branch-root logs/ directory', - 'passed': has_logs_dir, - 'message': f'logs/ directory exists at branch root {branch_root}/' - if has_logs_dir - else f'Missing logs/ directory at branch root {branch_root}/ — two-tier model requires logs/ at branch root' - }) + checks.append( + { + "name": "Branch-root logs/ directory", + "passed": has_logs_dir, + "message": f"logs/ directory exists at branch root {branch_root}/" + if has_logs_dir + else f"Missing logs/ directory at branch root {branch_root}/ — two-tier model requires logs/ at branch root", + } + ) # Check 2-3: Scan file for hardcoded log paths try: - content = path.read_text(encoding='utf-8') + content = path.read_text(encoding="utf-8") except Exception as e: logger.info("Cannot read %s: %s", path, e) - checks.append({ - 'name': 'File readable', - 'passed': False, - 'message': f'Error reading file: {e}' - }) - passed = all(c['passed'] for c in checks) - score = int(sum(1 for c in checks if c['passed']) / len(checks) * 100) - return {'passed': passed, 'checks': checks, 'score': score, 'standard': 'LOG_STRUCTURE'} + checks.append({"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}) + passed = all(c["passed"] for c in checks) + score = int(sum(1 for c in checks if c["passed"]) / len(checks) * 100) + return {"passed": passed, "checks": checks, "score": score, "standard": "LOG_STRUCTURE"} - lines = content.split('\n') + lines = content.split("\n") # Check 2: No hardcoded absolute log paths abs_log_issues = [] for i, line in enumerate(lines, 1): stripped = line.strip() # Skip comments and docstrings - if stripped.startswith('#') or stripped.startswith('"""') or stripped.startswith("'''"): + if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"): continue # Look for absolute paths in log-related contexts if re.search(r'["\'][/\\](?:home|tmp|var|etc)[/\\].*\.log', stripped): abs_log_issues.append(i) - checks.append({ - 'name': 'No hardcoded log paths', - 'passed': len(abs_log_issues) == 0, - 'message': 'No hardcoded absolute log paths found' if not abs_log_issues - else f'Hardcoded log paths on lines: {abs_log_issues}' - }) + checks.append( + { + "name": "No hardcoded log paths", + "passed": len(abs_log_issues) == 0, + "message": "No hardcoded absolute log paths found" + if not abs_log_issues + else f"Hardcoded log paths on lines: {abs_log_issues}", + } + ) # Check 3: No /home/ references in logging setup home_log_issues = [] for i, line in enumerate(lines, 1): stripped = line.strip() - if stripped.startswith('#') or stripped.startswith('"""') or stripped.startswith("'''"): + if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"): continue # Look for /home/ in log file handler or path config - if re.search(r'/home/\w+', stripped) and ('log' in stripped.lower() or 'LOG' in stripped): + if re.search(r"/home/\w+", stripped) and ("log" in stripped.lower() or "LOG" in stripped): home_log_issues.append(i) - checks.append({ - 'name': 'No /home/ in log config', - 'passed': len(home_log_issues) == 0, - 'message': 'No /home/ references in logging configuration' if not home_log_issues - else f'/home/ references in log config on lines: {home_log_issues}' - }) + checks.append( + { + "name": "No /home/ in log config", + "passed": len(home_log_issues) == 0, + "message": "No /home/ references in logging configuration" + if not home_log_issues + else f"/home/ references in log config on lines: {home_log_issues}", + } + ) - passed = all(c['passed'] for c in checks) - score = int(sum(1 for c in checks if c['passed']) / len(checks) * 100) if checks else 0 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "log_structure"}) - return {'passed': passed, 'checks': checks, 'score': score, 'standard': 'LOG_STRUCTURE'} + passed = all(c["passed"] for c in checks) + score = int(sum(1 for c in checks if c["passed"]) / len(checks) * 100) if checks else 0 + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "log_structure"} + ) + return {"passed": passed, "checks": checks, "score": score, "standard": "LOG_STRUCTURE"} def check_branch_post(branch_path: str) -> tuple: @@ -190,8 +196,7 @@ def check_branch_post(branch_path: str) -> tuple: in_dirs = [f for f in bp.rglob("*.log") if f.parent.name == "logs"] repo = next( - (p for p in [bp] + list(bp.parents) - if (p / "AIPASS_REGISTRY.json").is_file()), + (p for p in [bp] + list(bp.parents) if (p / "AIPASS_REGISTRY.json").is_file()), None, ) if repo and (repo / "system_logs").is_dir(): @@ -199,10 +204,16 @@ def check_branch_post(branch_path: str) -> tuple: system_count = len(list(sd.glob(f"{bp.name}_*.log"))) if in_dirs and system_count == 0: scores.append(50) - violations.append({ - "file": "(branch-level)", "path": str(sd), "score": 50, - "issues": [f"Branch has {len(in_dirs)} local log(s) but 0 system logs — prax dispatch may be misconfigured"], - }) + violations.append( + { + "file": "(branch-level)", + "path": str(sd), + "score": 50, + "issues": [ + f"Branch has {len(in_dirs)} local log(s) but 0 system logs — prax dispatch may be misconfigured" + ], + } + ) else: scores.append(100) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_content.py index 59251612..0b54ae5c 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_content.py @@ -16,6 +16,7 @@ logs/ at branch root only. from aipass.seedgo.apps.handlers.json import json_handler + def get_log_structure_standards() -> str: """Return formatted log structure standards for display.""" lines = [ diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_check.py index 5c803a2d..03d56bc8 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_check.py @@ -30,19 +30,21 @@ from aipass.seedgo.apps.handlers.json import json_handler AUDIT_SCOPE = "all_files" # Patterns built via concatenation to avoid self-detection by checkers -_GETLOGGER_PAT = r'logging' + r'\.getLogger\s*\(' -_FILEHANDLER_PAT = r'logging' + r'\.FileHandler\s*\(' +_GETLOGGER_PAT = r"logging" + r"\.getLogger\s*\(" +_FILEHANDLER_PAT = r"logging" + r"\.FileHandler\s*\(" + + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -67,51 +69,51 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] path = Path(module_path) - if is_bypassed(module_path, 'log_visibility', bypass_rules=bypass_rules): + if is_bypassed(module_path, "log_visibility", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'LOG_VISIBILITY' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "LOG_VISIBILITY", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'LOG_VISIBILITY' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "LOG_VISIBILITY", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'LOG_VISIBILITY' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "LOG_VISIBILITY", } # Exempt prax logging infrastructure (it IS the implementation) if _is_prax_logging_infra(module_path): return { - 'passed': True, - 'checks': [{'name': 'Log visibility', 'passed': True, 'message': 'Prax logging infrastructure (exempt)'}], - 'score': 100, - 'standard': 'LOG_VISIBILITY' + "passed": True, + "checks": [{"name": "Log visibility", "passed": True, "message": "Prax logging infrastructure (exempt)"}], + "score": 100, + "standard": "LOG_VISIBILITY", } # Exempt test files - if path.name.startswith('test_') or path.name.endswith('_test.py') or '/tests/' in module_path: + if path.name.startswith("test_") or path.name.endswith("_test.py") or "/tests/" in module_path: return { - 'passed': True, - 'checks': [{'name': 'Log visibility', 'passed': True, 'message': 'Test file (exempt)'}], - 'score': 100, - 'standard': 'LOG_VISIBILITY' + "passed": True, + "checks": [{"name": "Log visibility", "passed": True, "message": "Test file (exempt)"}], + "score": 100, + "standard": "LOG_VISIBILITY", } has_getlogger = bool(re.search(_GETLOGGER_PAT, content)) @@ -120,10 +122,10 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # If file doesn't use logging at all, skip if not has_getlogger and not has_filehandler: return { - 'passed': True, - 'checks': [{'name': 'Log visibility', 'passed': True, 'message': 'No logging usage found (skipped)'}], - 'score': 100, - 'standard': 'LOG_VISIBILITY' + "passed": True, + "checks": [{"name": "Log visibility", "passed": True, "message": "No logging usage found (skipped)"}], + "score": 100, + "standard": "LOG_VISIBILITY", } # CHECK 1: ALL files using getLogger must have prax import @@ -138,29 +140,26 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(check2) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "log_visibility"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'LOG_VISIBILITY' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "log_visibility"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "LOG_VISIBILITY"} def _is_prax_logging_infra(file_path: str) -> bool: """Check if file is part of prax logging infrastructure""" - if 'prax' not in file_path: + if "prax" not in file_path: return False # Prax logging handlers and setup - if '/logging/' in file_path or '/logger' in file_path: + if "/logging/" in file_path or "/logger" in file_path: return True # Prax logger module - if 'logger.py' in file_path: + if "logger.py" in file_path: return True return False @@ -170,35 +169,26 @@ def _check_prax_import(lines: List[str], content: str, file_path: str, bypass_ru Check 1: Files using stdlib getLogger must also import prax system_logger. Applies to ALL files — no handler exemption. """ - has_prax_import = bool(re.search( - r'from\s+aipass\.prax\.apps\.modules\.logger\s+import', - content - )) + has_prax_import = bool(re.search(r"from\s+aipass\.prax\.apps\.modules\.logger\s+import", content)) if has_prax_import: return { - 'name': 'Prax logger imported', - 'passed': True, - 'message': 'File imports prax system_logger alongside stdlib getLogger' + "name": "Prax logger imported", + "passed": True, + "message": "File imports prax system_logger alongside stdlib getLogger", } # Find violation lines - violation_lines = _find_pattern_lines( - lines, _GETLOGGER_PAT, file_path, bypass_rules - ) + violation_lines = _find_pattern_lines(lines, _GETLOGGER_PAT, file_path, bypass_rules) if violation_lines: return { - 'name': 'Prax logger imported', - 'passed': False, - 'message': f'stdlib getLogger() on lines {violation_lines[:5]} without prax system_logger import' + "name": "Prax logger imported", + "passed": False, + "message": f"stdlib getLogger() on lines {violation_lines[:5]} without prax system_logger import", } - return { - 'name': 'Prax logger imported', - 'passed': True, - 'message': 'No stdlib getLogger() violations' - } + return {"name": "Prax logger imported", "passed": True, "message": "No stdlib getLogger() violations"} def _check_local_filehandler(lines: List[str], file_path: str, bypass_rules: list | None = None) -> Dict: @@ -208,15 +198,13 @@ def _check_local_filehandler(lines: List[str], file_path: str, bypass_rules: lis If FileHandler points to system_logs/, it's visible. Otherwise it's blind. """ # Find FileHandler creation lines - violation_lines = _find_pattern_lines( - lines, _FILEHANDLER_PAT, file_path, bypass_rules - ) + violation_lines = _find_pattern_lines(lines, _FILEHANDLER_PAT, file_path, bypass_rules) if not violation_lines: return { - 'name': 'No local FileHandler', - 'passed': True, - 'message': 'No local-only FileHandler creating blind logs' + "name": "No local FileHandler", + "passed": True, + "message": "No local-only FileHandler creating blind logs", } # Check if any FileHandler points to system_logs (which would be OK) @@ -226,24 +214,20 @@ def _check_local_filehandler(lines: List[str], file_path: str, bypass_rules: lis # Check surrounding context (FileHandler path might be on previous/same line) context_start = max(0, line_num - 4) context_end = min(len(lines), line_num + 2) - context = '\n'.join(lines[context_start:context_end]) + context = "\n".join(lines[context_start:context_end]) - if 'system_logs' in context or 'system_log' in context: + if "system_logs" in context or "system_log" in context: continue # This FileHandler writes to system_logs — visible blind_lines.append(line_num) if blind_lines: return { - 'name': 'No local FileHandler', - 'passed': False, - 'message': f'FileHandler to local logs on lines {blind_lines[:5]} — invisible to Prax monitor' + "name": "No local FileHandler", + "passed": False, + "message": f"FileHandler to local logs on lines {blind_lines[:5]} — invisible to Prax monitor", } - return { - 'name': 'No local FileHandler', - 'passed': True, - 'message': 'FileHandler writes to system_logs (visible)' - } + return {"name": "No local FileHandler", "passed": True, "message": "FileHandler writes to system_logs (visible)"} def _find_pattern_lines(lines: List[str], pattern: str, file_path: str, bypass_rules: list | None = None) -> List[int]: @@ -268,11 +252,11 @@ def _find_pattern_lines(lines: List[str], pattern: str, file_path: str, bypass_r continue # Skip comments - if stripped.startswith('#'): + if stripped.startswith("#"): continue if re.search(pattern, line): - if not is_bypassed(file_path, 'log_visibility', i, bypass_rules): + if not is_bypassed(file_path, "log_visibility", i, bypass_rules): violation_lines.append(i) return violation_lines diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_content.py index 17ccc619..e33f1fe5 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_visibility_content.py @@ -12,7 +12,6 @@ Log Visibility Standards Content Provides Rich-formatted reference text for the log visibility standard. """ - from aipass.seedgo.apps.handlers.json import json_handler _GETLOGGER = "logging" + ".getLogger" diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/meta_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/meta_check.py index a85c3a06..5fc0d1a0 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/meta_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/meta_check.py @@ -55,12 +55,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -80,40 +80,40 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ path = Path(module_path) - if is_bypassed(module_path, 'meta', bypass_rules=bypass_rules): + if is_bypassed(module_path, "meta", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'META' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "META", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'META' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "META", } # Skip __init__.py files - if path.name == '__init__.py': + if path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'META check', 'passed': True, 'message': '__init__.py file (skipped)'}], - 'score': 100, - 'standard': 'META' + "passed": True, + "checks": [{"name": "META check", "passed": True, "message": "__init__.py file (skipped)"}], + "score": 100, + "standard": "META", } try: - content = path.read_text(encoding='utf-8') + content = path.read_text(encoding="utf-8") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'META' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "META", } checks = [] @@ -123,27 +123,22 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(presence) # Check 2: META placement (must be line 1) - if presence['passed']: + if presence["passed"]: placement = check_meta_placement(content) checks.append(placement) # Check 3: Required fields (only if block exists) - if presence['passed']: + if presence["passed"]: fields = check_required_fields(content, path.name) checks.extend(fields) - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "meta"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'META' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "META"} def check_meta_presence(content: str) -> Dict: @@ -153,37 +148,33 @@ def check_meta_presence(content: str) -> Dict: if has_header and has_footer: return { - 'name': 'META block present', - 'passed': True, - 'message': 'META block with header and footer markers found' + "name": "META block present", + "passed": True, + "message": "META block with header and footer markers found", } missing = [] if not has_header: - missing.append('header') + missing.append("header") if not has_footer: - missing.append('footer') + missing.append("footer") return { - 'name': 'META block present', - 'passed': False, - 'message': f'Missing META block ({", ".join(missing)} marker not found)' + "name": "META block present", + "passed": False, + "message": f"Missing META block ({', '.join(missing)} marker not found)", } def check_meta_placement(content: str) -> Dict: """Check that META block starts at line 1 (very top of file).""" - first_line = content.split('\n', 1)[0].strip() + first_line = content.split("\n", 1)[0].strip() if first_line == META_HEADER or first_line == META_HEADER_LEGACY: - return { - 'name': 'META placement', - 'passed': True, - 'message': 'META block is at the top of the file (line 1)' - } + return {"name": "META placement", "passed": True, "message": "META block is at the top of the file (line 1)"} return { - 'name': 'META placement', - 'passed': False, - 'message': 'META block must be the very first line of the file — move it above docstrings and imports' + "name": "META placement", + "passed": False, + "message": "META block must be the very first line of the file — move it above docstrings and imports", } @@ -198,24 +189,22 @@ def check_required_fields(content: str, filename: str) -> List[Dict]: # Extra validation: Name field should reference the actual filename name_match = re.search(r"# Name:\s+(\S+\.py)", content) if name_match and name_match.group(1) != filename: - results.append({ - 'name': f'META {field_name}', - 'passed': False, - 'message': f'Name field says "{name_match.group(1)}" but file is "{filename}"' - }) + results.append( + { + "name": f"META {field_name}", + "passed": False, + "message": f'Name field says "{name_match.group(1)}" but file is "{filename}"', + } + ) continue if match: - results.append({ - 'name': f'META {field_name}', - 'passed': True, - 'message': f'{field_name} field present and valid' - }) + results.append( + {"name": f"META {field_name}", "passed": True, "message": f"{field_name} field present and valid"} + ) else: - results.append({ - 'name': f'META {field_name}', - 'passed': False, - 'message': f'Missing or invalid {field_name} field' - }) + results.append( + {"name": f"META {field_name}", "passed": False, "message": f"Missing or invalid {field_name} field"} + ) return results diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/modules_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/modules_check.py index 5692d245..2aa18052 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/modules_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/modules_check.py @@ -23,20 +23,21 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -69,49 +70,49 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'modules', bypass_rules=bypass_rules): + if is_bypassed(module_path, "modules", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'MODULES' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "MODULES", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'MODULES' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "MODULES", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'MODULES' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "MODULES", } # Only check files in modules/ directory - is_module = 'apps/modules/' in module_path + is_module = "apps/modules/" in module_path if not is_module: return { - 'passed': True, - 'checks': [{'name': 'Module check', 'passed': True, 'message': 'Not a module file (skipped)'}], - 'score': 100, - 'standard': 'MODULES' + "passed": True, + "checks": [{"name": "Module check", "passed": True, "message": "Not a module file (skipped)"}], + "score": 100, + "standard": "MODULES", } # Check 1: handle_command pattern (for non-__init__ files) - if not path.name == '__init__.py': + if not path.name == "__init__.py": handle_cmd_check = check_handle_command(content) if handle_cmd_check: checks.append(handle_cmd_check) @@ -136,7 +137,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(orchestration_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 @@ -144,12 +145,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "modules"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'MODULES' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "MODULES"} def _track_docstring_state(stripped: str, in_docstring: bool) -> tuple: @@ -178,7 +174,7 @@ def check_handle_command(content: str) -> Optional[Dict]: Modules should have: def handle_command(command: str, args: List[str]) -> bool """ - lines = content.split('\n') + lines = content.split("\n") # Filter out comments and docstrings code_lines = [] @@ -193,38 +189,38 @@ def check_handle_command(content: str) -> Optional[Dict]: continue # Skip docstrings and comments - if in_docstring or stripped.startswith('#'): + if in_docstring or stripped.startswith("#"): continue code_lines.append(line) # Search only actual code - code_only = '\n'.join(code_lines) + code_only = "\n".join(code_lines) # Check for handle_command function - has_handle_command = bool(re.search(r'def\s+handle_command\s*\(', code_only)) + has_handle_command = bool(re.search(r"def\s+handle_command\s*\(", code_only)) if not has_handle_command: return { - 'name': 'handle_command pattern', - 'passed': False, - 'message': 'Missing handle_command(command, args) -> bool for drone routing' + "name": "handle_command pattern", + "passed": False, + "message": "Missing handle_command(command, args) -> bool for drone routing", } # Check return type annotation - has_bool_return = bool(re.search(r'def\s+handle_command\([^)]*\)\s*->\s*bool', code_only)) + has_bool_return = bool(re.search(r"def\s+handle_command\([^)]*\)\s*->\s*bool", code_only)) if not has_bool_return: return { - 'name': 'handle_command pattern', - 'passed': False, - 'message': 'handle_command exists but missing -> bool return type annotation' + "name": "handle_command pattern", + "passed": False, + "message": "handle_command exists but missing -> bool return type annotation", } return { - 'name': 'handle_command pattern', - 'passed': True, - 'message': 'handle_command(command, args) -> bool pattern implemented' + "name": "handle_command pattern", + "passed": True, + "message": "handle_command(command, args) -> bool pattern implemented", } @@ -242,34 +238,22 @@ def check_file_size(lines: List[str], module_path: str) -> Dict: line_count = len(lines) if line_count < 150: - return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (simple - perfect size)' - } + return {"name": "File size", "passed": True, "message": f"{line_count} lines (simple - perfect size)"} elif line_count < 250: - return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (standard - good size)' - } + return {"name": "File size", "passed": True, "message": f"{line_count} lines (standard - good size)"} elif line_count < 400: - return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (complex - acceptable, watch it)' - } + return {"name": "File size", "passed": True, "message": f"{line_count} lines (complex - acceptable, watch it)"} elif line_count < 600: return { - 'name': 'File size', - 'passed': True, - 'message': f'{line_count} lines (heavy - consider splitting into domains)' + "name": "File size", + "passed": True, + "message": f"{line_count} lines (heavy - consider splitting into domains)", } else: return { - 'name': 'File size', - 'passed': False, - 'message': f'{line_count} lines (too large - split required for AI comprehension)' + "name": "File size", + "passed": False, + "message": f"{line_count} lines (too large - split required for AI comprehension)", } @@ -290,7 +274,7 @@ def check_no_direct_file_ops(content: str, lines: List[str]) -> Optional[Dict]: in_docstring = not in_docstring # Skip docstrings, comments and empty lines - if in_docstring or not stripped or stripped.startswith('#'): + if in_docstring or not stripped or stripped.startswith("#"): continue # Check for direct file operations @@ -299,41 +283,41 @@ def check_no_direct_file_ops(content: str, lines: List[str]) -> Optional[Dict]: # Forbidden: open(), .write_text(), .read_text(), .mkdir(), json.dump(), json.load() # Skip import lines - if stripped.startswith('from ') or stripped.startswith('import '): + if stripped.startswith("from ") or stripped.startswith("import "): continue # Check for forbidden operations # Match bare open() calls but NOT function names containing "open" # e.g. open(file) matches, but _handle_open(args) does NOT - if re.search(r'(? """ # Phase 1: Quick line scan for candidates (module-level only = no leading whitespace) # Only match assignments at column 0 (module level) - list_pattern = re.compile(r'^([a-z][a-z0-9_]*)\s*=\s*\[') - dict_pattern = re.compile(r'^([a-z][a-z0-9_]*)\s*=\s*\{') + list_pattern = re.compile(r"^([a-z][a-z0-9_]*)\s*=\s*\[") + dict_pattern = re.compile(r"^([a-z][a-z0-9_]*)\s*=\s*\{") candidates = _scan_candidates(lines, list_pattern, dict_pattern) if not candidates: - return { - 'name': 'No business logic', - 'passed': True, - 'message': 'No hardcoded data structures detected' - } + return {"name": "No business logic", "passed": True, "message": "No hardcoded data structures detected"} # Phase 2: AST verification (only module-level assignments) violations = [] @@ -425,10 +405,10 @@ def check_no_business_logic(content: str, lines: List[str], module_path: str) -> element_count = 0 if isinstance(value, ast.List): - var_type = 'list' + var_type = "list" element_count = len(value.elts) elif isinstance(value, ast.Dict): - var_type = 'dict' + var_type = "dict" element_count = len(value.keys) else: continue # Not a list or dict @@ -442,12 +422,7 @@ def check_no_business_logic(content: str, lines: List[str], module_path: str) -> continue # This is a confirmed violation - violations.append({ - 'line': node.lineno, - 'var': var_name, - 'type': var_type, - 'count': element_count - }) + violations.append({"line": node.lineno, "var": var_name, "type": var_type, "count": element_count}) except SyntaxError: logger.info("Skipped business logic check: SyntaxError in %s", module_path) @@ -460,17 +435,9 @@ def check_no_business_logic(content: str, lines: List[str], module_path: str) -> # Report first violation v = violations[0] msg = f"Line {v['line']}: '{v['var']}' has hardcoded {v['type']} with {v['count']} elements (move to config)" - return { - 'name': 'No business logic', - 'passed': False, - 'message': msg - } + return {"name": "No business logic", "passed": False, "message": msg} - return { - 'name': 'No business logic', - 'passed': True, - 'message': 'No hardcoded data structures detected' - } + return {"name": "No business logic", "passed": True, "message": "No hardcoded data structures detected"} def check_thin_orchestration(content: str, module_path: str, bypass_rules: list | None = None) -> Optional[Dict]: @@ -502,30 +469,30 @@ def check_thin_orchestration(content: str, module_path: str, bypass_rules: list """ # Standard allowed functions in modules ALLOWED_FUNCTIONS = { - 'handle_command', - 'print_help', - 'print_introspection', - 'main', + "handle_command", + "print_help", + "print_introspection", + "main", } # Prefixes that indicate orchestration patterns (not implementation) ORCHESTRATION_PREFIXES = ( - '_', # Private helpers - 'print_', # Display wrappers - 'handle_', # Sub-command routing - 'show_', # Display/presentation - 'route_', # Routing/dispatch - 'dispatch_', # Dispatching - 'list_', # Query/listing - 'get_', # Data queries/getters - 'load_', # Loading configs/data - 'close_', # Lifecycle orchestration (close plans, sessions) - 'restore_', # Lifecycle orchestration (restore plans, state) - 'create_', # Lifecycle orchestration (create plans, resources) - 'delete_', # Lifecycle orchestration (delete plans, resources) - 'update_', # Lifecycle orchestration (update state, records) - 'send_', # Dispatch orchestration (send emails, events) - 'run_', # Execution orchestration (run tasks, workflows) + "_", # Private helpers + "print_", # Display wrappers + "handle_", # Sub-command routing + "show_", # Display/presentation + "route_", # Routing/dispatch + "dispatch_", # Dispatching + "list_", # Query/listing + "get_", # Data queries/getters + "load_", # Loading configs/data + "close_", # Lifecycle orchestration (close plans, sessions) + "restore_", # Lifecycle orchestration (restore plans, state) + "create_", # Lifecycle orchestration (create plans, resources) + "delete_", # Lifecycle orchestration (delete plans, resources) + "update_", # Lifecycle orchestration (update state, records) + "send_", # Dispatch orchestration (send emails, events) + "run_", # Execution orchestration (run tasks, workflows) ) # Max lines for a function to be considered a thin wrapper @@ -534,12 +501,8 @@ def check_thin_orchestration(content: str, module_path: str, bypass_rules: list THIN_WRAPPER_MAX_LINES = 40 # Check bypass - if is_bypassed(module_path, 'modules', bypass_rules=bypass_rules): - return { - 'name': 'Thin orchestration', - 'passed': True, - 'message': 'Bypassed - thin orchestration check skipped' - } + if is_bypassed(module_path, "modules", bypass_rules=bypass_rules): + return {"name": "Thin orchestration", "passed": True, "message": "Bypassed - thin orchestration check skipped"} try: tree = ast.parse(content, filename=module_path) @@ -568,11 +531,7 @@ def check_thin_orchestration(content: str, module_path: str, bypass_rules: list continue # This is a non-standard function with substantial body - implementation logic - non_standard_functions.append({ - 'name': func_name, - 'line': node.lineno, - 'lines': func_lines - }) + non_standard_functions.append({"name": func_name, "line": node.lineno, "lines": func_lines}) if non_standard_functions: # Report violation @@ -580,13 +539,13 @@ def check_thin_orchestration(content: str, module_path: str, bypass_rules: list extra = f" +{len(non_standard_functions) - 5} more" if len(non_standard_functions) > 5 else "" return { - 'name': 'Thin orchestration', - 'passed': False, - 'message': f"Module has {len(non_standard_functions)} implementation function(s) that belong in handlers: {', '.join(func_list)}{extra}" + "name": "Thin orchestration", + "passed": False, + "message": f"Module has {len(non_standard_functions)} implementation function(s) that belong in handlers: {', '.join(func_list)}{extra}", } return { - 'name': 'Thin orchestration', - 'passed': True, - 'message': 'Module is thin orchestrator (standard functions only)' + "name": "Thin orchestration", + "passed": True, + "message": "Module is thin orchestrator (standard functions only)", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/modules_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/modules_content.py index 985a5439..0d03c510 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/modules_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/modules_content.py @@ -15,6 +15,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_modules_standards() -> str: """Return formatted module standards content with Rich markup @@ -65,8 +66,7 @@ def get_modules_standards() -> str: "", "[bold cyan]ESSENTIAL IMPORTS:[/bold cyan]", " [dim]from aipass.prax.apps.modules.logger import system_logger as logger[/dim]", - " [dim]from aipass.cli.apps.modules" - " import console, header, success, error[/dim]", + " [dim]from aipass.cli.apps.modules import console, header, success, error[/dim]", " [dim]from aipass.seedgo.apps.handlers.json import json_handler[/dim]", "", "[bold cyan]WARNINGS:[/bold cyan]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py index 14cef5aa..c8c3831a 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py @@ -22,20 +22,21 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: # Must match standard - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue # Must match file (check if rule file path is in the full path) - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -68,34 +69,34 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'naming', bypass_rules=bypass_rules): + if is_bypassed(module_path, "naming", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'NAMING' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "NAMING", } # Validate file exists if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'NAMING' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "NAMING", } # Read file try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'NAMING' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "NAMING", } # Check 1: File naming (snake_case, no redundant prefixes) @@ -118,7 +119,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(class_naming_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 @@ -126,12 +127,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "naming"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'NAMING' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "NAMING"} def check_file_naming(module_path: str, path: Path) -> Dict: @@ -147,27 +143,23 @@ def check_file_naming(module_path: str, path: Path) -> Dict: filename = path.stem # filename without extension # Python-reserved package marker — cannot be renamed - if path.name == '__init__.py': - return { - 'name': 'File naming', - 'passed': True, - 'message': '__init__.py (Python-reserved package marker)' - } + if path.name == "__init__.py": + return {"name": "File naming", "passed": True, "message": "__init__.py (Python-reserved package marker)"} # Check if it's the documented exception - if filename == 'json_handler' and '/handlers/json/' in module_path: + if filename == "json_handler" and "/handlers/json/" in module_path: return { - 'name': 'File naming', - 'passed': True, - 'message': f'{filename}.py (documented exception - standardized handler)' + "name": "File naming", + "passed": True, + "message": f"{filename}.py (documented exception - standardized handler)", } # Check for snake_case - if not re.match(r'^[a-z][a-z0-9_]*$', filename): + if not re.match(r"^[a-z][a-z0-9_]*$", filename): return { - 'name': 'File naming', - 'passed': False, - 'message': f'{filename}.py uses invalid characters (use snake_case: lowercase + underscores)' + "name": "File naming", + "passed": False, + "message": f"{filename}.py uses invalid characters (use snake_case: lowercase + underscores)", } # Check for redundant prefixes @@ -176,29 +168,37 @@ def check_file_naming(module_path: str, path: Path) -> Dict: parent_dir = path.parts[-2] # Check if filename starts with parent directory name - if filename.startswith(f'{parent_dir}_'): + if filename.startswith(f"{parent_dir}_"): return { - 'name': 'File naming', - 'passed': False, - 'message': f'{filename}.py has redundant prefix (in {parent_dir}/ dir, use {filename.replace(f"{parent_dir}_", "")}.py)' + "name": "File naming", + "passed": False, + "message": f"{filename}.py has redundant prefix (in {parent_dir}/ dir, use {filename.replace(f'{parent_dir}_', '')}.py)", } # Check for standard verbs (informational) - standard_verbs = ['create', 'ops', 'load', 'save', 'initialize', 'formatters', 'decorators', - 'logger', 'prompts', 'content', 'check', 'handler'] + standard_verbs = [ + "create", + "ops", + "load", + "save", + "initialize", + "formatters", + "decorators", + "logger", + "prompts", + "content", + "check", + "handler", + ] uses_standard_verb = any(verb in filename for verb in standard_verbs) if uses_standard_verb: - return { - 'name': 'File naming', - 'passed': True, - 'message': f'{filename}.py (snake_case, uses standard verb)' - } + return {"name": "File naming", "passed": True, "message": f"{filename}.py (snake_case, uses standard verb)"} else: return { - 'name': 'File naming', - 'passed': True, - 'message': f'{filename}.py (snake_case, custom name - consider standard verbs)' + "name": "File naming", + "passed": True, + "message": f"{filename}.py (snake_case, custom name - consider standard verbs)", } @@ -211,7 +211,7 @@ def check_function_naming(content: str) -> Optional[Dict]: - No single-letter names (except in list comprehensions/loops) """ # Find all function definitions - function_pattern = r'^\s*(?:async\s+)?def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(' + function_pattern = r"^\s*(?:async\s+)?def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(" functions = re.findall(function_pattern, content, re.MULTILINE) if not functions: @@ -221,28 +221,27 @@ def check_function_naming(content: str) -> Optional[Dict]: bad_functions = [] for func_name in functions: # Skip dunder methods - if func_name.startswith('__') and func_name.endswith('__'): + if func_name.startswith("__") and func_name.endswith("__"): continue # Check for snake_case - if not re.match(r'^[a-z_][a-z0-9_]*$', func_name): + if not re.match(r"^[a-z_][a-z0-9_]*$", func_name): bad_functions.append(func_name) if bad_functions: return { - 'name': 'Function naming', - 'passed': False, - 'message': f'Non-snake_case functions: {", ".join(bad_functions[:3])}{"..." if len(bad_functions) > 3 else ""}' + "name": "Function naming", + "passed": False, + "message": f"Non-snake_case functions: {', '.join(bad_functions[:3])}{'...' if len(bad_functions) > 3 else ''}", } return { - 'name': 'Function naming', - 'passed': True, - 'message': f'{len(functions)} functions checked - all snake_case' + "name": "Function naming", + "passed": True, + "message": f"{len(functions)} functions checked - all snake_case", } - def check_constant_naming(content: str) -> Optional[Dict]: """ Check constant naming conventions @@ -255,17 +254,17 @@ def check_constant_naming(content: str) -> Optional[Dict]: """ # First pass: collect imported names to exclude from constant checking imported_names = set() - for line in content.split('\n'): + for line in content.split("\n"): stripped = line.strip() # Match: from X import Y, Z - if stripped.startswith('from ') and ' import ' in stripped: - import_part = stripped.split(' import ', 1)[1] + if stripped.startswith("from ") and " import " in stripped: + import_part = stripped.split(" import ", 1)[1] # Handle 'as' aliases: logger = system_logger - for item in import_part.split(','): + for item in import_part.split(","): item = item.strip() - if ' as ' in item: + if " as " in item: # "system_logger as logger" -> get "logger" - imported_names.add(item.split(' as ')[1].strip()) + imported_names.add(item.split(" as ")[1].strip()) else: # Direct import like "console" imported_names.add(item.strip()) @@ -280,7 +279,7 @@ def check_constant_naming(content: str) -> Optional[Dict]: bad_constants = [] in_multiline_string = False - for line in content.split('\n'): + for line in content.split("\n"): stripped = line.strip() # Track multiline strings @@ -294,15 +293,15 @@ def check_constant_naming(content: str) -> Optional[Dict]: continue # Only consider lines with zero indentation (true module-level) - if line and (line[0] == ' ' or line[0] == '\t'): + if line and (line[0] == " " or line[0] == "\t"): continue # Find assignments - if '=' not in stripped or stripped.startswith('#'): + if "=" not in stripped or stripped.startswith("#"): continue # Extract variable name and assignment value - match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$', stripped) + match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$", stripped) if not match: continue @@ -310,7 +309,7 @@ def check_constant_naming(content: str) -> Optional[Dict]: assigned_value = match.group(2) # Skip __dunder__ variables (__all__, __version__, etc.) - if const_name.startswith('__') and const_name.endswith('__'): + if const_name.startswith("__") and const_name.endswith("__"): continue # Skip if this is an imported name (like logger, console) @@ -319,7 +318,7 @@ def check_constant_naming(content: str) -> Optional[Dict]: # Skip if assignment is a function call or class instantiation (has parentheses) # Examples: logger = logging.getLogger(...), console = Console() - if '(' in assigned_value: + if "(" in assigned_value: continue # Skip if assigning an imported value to a variable @@ -338,15 +337,15 @@ def check_constant_naming(content: str) -> Optional[Dict]: if bad_constants: return { - 'name': 'Constant naming', - 'passed': False, - 'message': f'Non-UPPER_CASE constants: {", ".join(bad_constants[:3])}' + "name": "Constant naming", + "passed": False, + "message": f"Non-UPPER_CASE constants: {', '.join(bad_constants[:3])}", } return { - 'name': 'Constant naming', - 'passed': True, - 'message': f'{len(constants)} constants checked - all UPPER_CASE' + "name": "Constant naming", + "passed": True, + "message": f"{len(constants)} constants checked - all UPPER_CASE", } @@ -358,7 +357,7 @@ def check_class_naming(content: str) -> Optional[Dict]: - PascalCase class names """ # Find all class definitions - class_pattern = r'^\s*class\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*[\(:]' + class_pattern = r"^\s*class\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*[\(:]" classes = re.findall(class_pattern, content, re.MULTILINE) if not classes: @@ -368,18 +367,14 @@ def check_class_naming(content: str) -> Optional[Dict]: bad_classes = [] for class_name in classes: # Check for PascalCase - if not re.match(r'^[A-Z][a-zA-Z0-9]*$', class_name): + if not re.match(r"^[A-Z][a-zA-Z0-9]*$", class_name): bad_classes.append(class_name) if bad_classes: return { - 'name': 'Class naming', - 'passed': False, - 'message': f'Non-PascalCase classes: {", ".join(bad_classes[:3])}' + "name": "Class naming", + "passed": False, + "message": f"Non-PascalCase classes: {', '.join(bad_classes[:3])}", } - return { - 'name': 'Class naming', - 'passed': True, - 'message': f'{len(classes)} classes checked - all PascalCase' - } + return {"name": "Class naming", "passed": True, "message": f"{len(classes)} classes checked - all PascalCase"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/naming_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/naming_content.py index 346e28ef..db82062d 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/naming_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/naming_content.py @@ -15,6 +15,7 @@ Module orchestrates, handler implements. from aipass.seedgo.apps.handlers.json import json_handler + def get_naming_standards() -> str: """Return formatted naming standards content with Rich markup diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_check.py index da4bf10c..1b98959e 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_check.py @@ -30,17 +30,18 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: all Python files AUDIT_SCOPE = "all_files" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -69,7 +70,7 @@ def _get_non_code_lines(lines: List[str]) -> set: skip.add(i) continue - if in_docstring or stripped.startswith('#'): + if in_docstring or stripped.startswith("#"): skip.add(i) return skip @@ -94,46 +95,47 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] path = Path(module_path) - if is_bypassed(module_path, 'permission_flags', bypass_rules=bypass_rules): + if is_bypassed(module_path, "permission_flags", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'PERMISSION_FLAGS' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "PERMISSION_FLAGS", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'PERMISSION_FLAGS' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "PERMISSION_FLAGS", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'PERMISSION_FLAGS' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "PERMISSION_FLAGS", } # Skip files that don't reference any permission patterns has_permission_ref = re.search( - r'skip.?permissions|dangerously.?skip|permission.?mode|bypass.?permission', - content, re.IGNORECASE + r"skip.?permissions|dangerously.?skip|permission.?mode|bypass.?permission", content, re.IGNORECASE ) if not has_permission_ref: return { - 'passed': True, - 'checks': [{'name': 'Permission flags check', 'passed': True, 'message': 'No permission flags found (skipped)'}], - 'score': 100, - 'standard': 'PERMISSION_FLAGS' + "passed": True, + "checks": [ + {"name": "Permission flags check", "passed": True, "message": "No permission flags found (skipped)"} + ], + "score": 100, + "standard": "PERMISSION_FLAGS", } # Check 1: No dangerous skip-permissions flags @@ -141,18 +143,15 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(dangerous_flags_check) # Calculate score - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "permission_flags"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'PERMISSION_FLAGS' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "permission_flags"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "PERMISSION_FLAGS"} def check_no_dangerous_flags(lines: List[str], file_path: str, bypass_rules: list | None = None) -> Dict: @@ -162,10 +161,10 @@ def check_no_dangerous_flags(lines: List[str], file_path: str, bypass_rules: lis """ # Patterns that are PROHIBITED dangerous_patterns = [ - r'dangerously.?skip.?permissions', - r'--skip.?permissions', - r'--no.?permissions', - r'allow.?dangerously.?skip', + r"dangerously.?skip.?permissions", + r"--skip.?permissions", + r"--no.?permissions", + r"allow.?dangerously.?skip", ] violations = [] @@ -177,19 +176,19 @@ def check_no_dangerous_flags(lines: List[str], file_path: str, bypass_rules: lis for pattern in dangerous_patterns: if re.search(pattern, line, re.IGNORECASE): - if not is_bypassed(file_path, 'permission_flags', i, bypass_rules): + if not is_bypassed(file_path, "permission_flags", i, bypass_rules): violations.append(i) break if violations: return { - 'name': 'No dangerous permission flags', - 'passed': False, - 'message': f'Dangerous permission bypass flags on lines {violations[:5]} - use --permission-mode bypassPermissions instead' + "name": "No dangerous permission flags", + "passed": False, + "message": f"Dangerous permission bypass flags on lines {violations[:5]} - use --permission-mode bypassPermissions instead", } return { - 'name': 'No dangerous permission flags', - 'passed': True, - 'message': 'Only approved permission flags used (--permission-mode bypassPermissions)' + "name": "No dangerous permission flags", + "passed": True, + "message": "Only approved permission flags used (--permission-mode bypassPermissions)", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_content.py index a3e9f77d..fe7f294e 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/permission_flags_content.py @@ -14,6 +14,7 @@ Provides Rich-formatted reference text for the permission flags standard. from aipass.seedgo.apps.handlers.json import json_handler + def get_permission_flags_standards() -> str: """Return Rich-formatted permission flags standards text""" json_handler.log_operation("standard_content_queried", {"standard": "permission_flags"}) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py index 14d3d049..9a250310 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py @@ -31,17 +31,18 @@ from aipass.seedgo.apps.handlers.json import json_handler # Audit scope: entry points only (apps/{name}.py) AUDIT_SCOPE = "entry_point" + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed""" if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -67,12 +68,12 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks = [] # Check if entire standard is bypassed for this file - if is_bypassed(module_path, 'readme', bypass_rules=bypass_rules): + if is_bypassed(module_path, "readme", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'README' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "README", } # Derive branch root: module_path is apps/[branch].py, go up 2 levels @@ -80,43 +81,38 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: branch_root = entry_path.parent.parent # Check 1: README exists - readme_path = branch_root / 'README.md' + readme_path = branch_root / "README.md" readme_exists_check = check_readme_exists(readme_path) checks.append(readme_exists_check) # If README doesn't exist, all other checks fail - if not readme_exists_check['passed']: - for name in ['Required sections', 'Last Updated freshness', - 'Directory tree accuracy', 'Module list completeness', - 'Command list presence']: - checks.append({ - 'name': name, - 'passed': False, - 'message': 'Cannot check - README.md missing' - }) + if not readme_exists_check["passed"]: + for name in [ + "Required sections", + "Last Updated freshness", + "Directory tree accuracy", + "Module list completeness", + "Command list presence", + ]: + checks.append({"name": name, "passed": False, "message": "Cannot check - README.md missing"}) - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 - return { - 'passed': score >= 75, - 'checks': checks, - 'score': score, - 'standard': 'README' - } + return {"passed": score >= 75, "checks": checks, "score": score, "standard": "README"} # Read README content try: - content = readme_path.read_text(encoding='utf-8') - lines = content.split('\n') + content = readme_path.read_text(encoding="utf-8") + lines = content.split("\n") except Exception as e: logger.info("Cannot read README at %s: %s", readme_path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading README: {e}'}], - 'score': 0, - 'standard': 'README' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading README: {e}"}], + "score": 0, + "standard": "README", } # Check 2: Required sections present @@ -140,33 +136,20 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks.append(commands_check) # Calculate score - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "readme"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'README' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "README"} def check_readme_exists(readme_path: Path) -> Dict: """Check that README.md exists at branch root""" if readme_path.exists() and readme_path.is_file(): - return { - 'name': 'README exists', - 'passed': True, - 'message': f'Found at {readme_path}' - } - return { - 'name': 'README exists', - 'passed': False, - 'message': f'README.md not found at {readme_path.parent}' - } + return {"name": "README exists", "passed": True, "message": f"Found at {readme_path}"} + return {"name": "README exists", "passed": False, "message": f"README.md not found at {readme_path.parent}"} def check_required_sections(lines: List[str], file_path: str, bypass_rules: list | None = None) -> Dict: @@ -178,59 +161,40 @@ def check_required_sections(lines: List[str], file_path: str, bypass_rules: list - Commands OR Usage - Integration Points OR Depends On OR Provides To """ - if is_bypassed(file_path, 'readme', None, bypass_rules): - return { - 'name': 'Required sections', - 'passed': True, - 'message': 'Bypassed by bypass rules' - } + if is_bypassed(file_path, "readme", None, bypass_rules): + return {"name": "Required sections", "passed": True, "message": "Bypassed by bypass rules"} - content_lower = '\n'.join(lines).lower() + content_lower = "\n".join(lines).lower() # Group 1: Architecture / Directory Structure - group1_patterns = ['architecture', 'directory structure'] - group1_found = any( - re.search(r'^#{1,3}\s+.*' + re.escape(p), content_lower, re.MULTILINE) - for p in group1_patterns - ) + group1_patterns = ["architecture", "directory structure"] + group1_found = any(re.search(r"^#{1,3}\s+.*" + re.escape(p), content_lower, re.MULTILINE) for p in group1_patterns) # Group 2: Commands / Usage - group2_patterns = ['commands', 'usage'] - group2_found = any( - re.search(r'^#{1,3}\s+.*' + re.escape(p), content_lower, re.MULTILINE) - for p in group2_patterns - ) + group2_patterns = ["commands", "usage"] + group2_found = any(re.search(r"^#{1,3}\s+.*" + re.escape(p), content_lower, re.MULTILINE) for p in group2_patterns) # Group 3: Integration Points / Depends On / Provides To - group3_patterns = ['integration points', 'depends on', 'provides to'] - group3_found = any( - re.search(r'^#{1,3}\s+.*' + re.escape(p), content_lower, re.MULTILINE) - for p in group3_patterns - ) + group3_patterns = ["integration points", "depends on", "provides to"] + group3_found = any(re.search(r"^#{1,3}\s+.*" + re.escape(p), content_lower, re.MULTILINE) for p in group3_patterns) missing = [] if not group1_found: - missing.append('Architecture/Directory Structure') + missing.append("Architecture/Directory Structure") if not group2_found: - missing.append('Commands/Usage') + missing.append("Commands/Usage") if not group3_found: - missing.append('Integration Points/Depends On/Provides To') + missing.append("Integration Points/Depends On/Provides To") if not missing: - return { - 'name': 'Required sections', - 'passed': True, - 'message': 'All required sections found' - } + return {"name": "Required sections", "passed": True, "message": "All required sections found"} - return { - 'name': 'Required sections', - 'passed': False, - 'message': f'Missing sections: {", ".join(missing)}' - } + return {"name": "Required sections", "passed": False, "message": f"Missing sections: {', '.join(missing)}"} -def check_last_updated_freshness(lines: List[str], branch_root: Path, file_path: str, bypass_rules: list | None = None) -> Dict: +def check_last_updated_freshness( + lines: List[str], branch_root: Path, file_path: str, bypass_rules: list | None = None +) -> Dict: """ Check that Last Updated date is within 7 days of newest .py file modification. @@ -240,41 +204,33 @@ def check_last_updated_freshness(lines: List[str], branch_root: Path, file_path: - **Last Updated:** YYYY-MM-DD - **Last Updated**: YYYY-MM-DD """ - if is_bypassed(file_path, 'readme', None, bypass_rules): - return { - 'name': 'Last Updated freshness', - 'passed': True, - 'message': 'Bypassed by bypass rules' - } + if is_bypassed(file_path, "readme", None, bypass_rules): + return {"name": "Last Updated freshness", "passed": True, "message": "Bypassed by bypass rules"} # Find Last Updated line # Accept both italic (*) and bold (**) markdown formatting readme_date = None - date_pattern = re.compile(r'\*{0,2}Last Updated\*{0,2}:\*{0,2}\s*(\d{4}-\d{2}-\d{2})') + date_pattern = re.compile(r"\*{0,2}Last Updated\*{0,2}:\*{0,2}\s*(\d{4}-\d{2}-\d{2})") for line in lines: match = date_pattern.search(line) if match: try: - readme_date = datetime.strptime(match.group(1), '%Y-%m-%d') + readme_date = datetime.strptime(match.group(1), "%Y-%m-%d") except ValueError: logger.info("Malformed date in README: %s", match.group(1)) readme_date = None # Malformed date string break if readme_date is None: - return { - 'name': 'Last Updated freshness', - 'passed': False, - 'message': 'No "Last Updated" date found in README' - } + return {"name": "Last Updated freshness", "passed": False, "message": 'No "Last Updated" date found in README'} # Find newest .py file modification time in the branch newest_py_mtime = None - apps_dir = branch_root / 'apps' + apps_dir = branch_root / "apps" if apps_dir.exists(): - for py_file in apps_dir.rglob('*.py'): - if py_file.name == '__init__.py': + for py_file in apps_dir.rglob("*.py"): + if py_file.name == "__init__.py": continue try: mtime = datetime.fromtimestamp(py_file.stat().st_mtime) @@ -286,26 +242,22 @@ def check_last_updated_freshness(lines: List[str], branch_root: Path, file_path: if newest_py_mtime is None: # No Python files to compare against - pass by default - return { - 'name': 'Last Updated freshness', - 'passed': True, - 'message': 'No .py files found to compare against' - } + return {"name": "Last Updated freshness", "passed": True, "message": "No .py files found to compare against"} # Compare: flag if README date is >7 days behind newest code change days_behind = (newest_py_mtime - readme_date).days if days_behind <= 7: return { - 'name': 'Last Updated freshness', - 'passed': True, - 'message': f'README date {readme_date.strftime("%Y-%m-%d")} is within 7 days of latest code change' + "name": "Last Updated freshness", + "passed": True, + "message": f"README date {readme_date.strftime('%Y-%m-%d')} is within 7 days of latest code change", } return { - 'name': 'Last Updated freshness', - 'passed': False, - 'message': f'README date {readme_date.strftime("%Y-%m-%d")} is {days_behind} days behind newest code change ({newest_py_mtime.strftime("%Y-%m-%d")})' + "name": "Last Updated freshness", + "passed": False, + "message": f"README date {readme_date.strftime('%Y-%m-%d')} is {days_behind} days behind newest code change ({newest_py_mtime.strftime('%Y-%m-%d')})", } @@ -317,34 +269,30 @@ def check_directory_tree(lines: List[str], branch_root: Path, file_path: str, by "Architecture" heading, verify that directories mentioned in the tree actually exist on disk. """ - if is_bypassed(file_path, 'readme', None, bypass_rules): - return { - 'name': 'Directory tree accuracy', - 'passed': True, - 'message': 'Bypassed by bypass rules' - } + if is_bypassed(file_path, "readme", None, bypass_rules): + return {"name": "Directory tree accuracy", "passed": True, "message": "Bypassed by bypass rules"} # Find the tree section: look for a heading with architecture/directory structure, # then find the next fenced code block - content = '\n'.join(lines) + content = "\n".join(lines) tree_block = _extract_tree_block(content) if tree_block is None: # No tree section found - pass (it's optional to have one) return { - 'name': 'Directory tree accuracy', - 'passed': True, - 'message': 'No directory tree block found (optional check)' + "name": "Directory tree accuracy", + "passed": True, + "message": "No directory tree block found (optional check)", } # Extract directory names from tree block, line by line # Strip inline comments (text after #) to avoid false positives # Skip the first non-empty line (root label, e.g., "seedgo/" or "src/aipass/.../spawn/") # Common tree formats: "apps/", "├── apps/", "│ ├── handlers/", " apps/" - dir_pattern = re.compile(r'[\w\-_.]+/') + dir_pattern = re.compile(r"[\w\-_.]+/") branch_name = branch_root.name.lower() mentioned_dirs = set() - tree_lines = tree_block.split('\n') + tree_lines = tree_block.split("\n") # Skip the first non-empty line (it's the tree root label) first_content_skipped = False @@ -353,30 +301,26 @@ def check_directory_tree(lines: List[str], branch_root: Path, file_path: str, by first_content_skipped = True continue # Strip inline comments to avoid matching words in comments - if '#' in tree_line: - tree_line = tree_line[:tree_line.index('#')] + if "#" in tree_line: + tree_line = tree_line[: tree_line.index("#")] for match in dir_pattern.finditer(tree_line): - dir_name = match.group().rstrip('/') + dir_name = match.group().rstrip("/") # Skip the branch root name itself # (trees typically start with the branch name, e.g., "seedgo/") if dir_name.lower() == branch_name: continue - if dir_name in ('__pycache__', '.git', 'node_modules'): + if dir_name in ("__pycache__", ".git", "node_modules"): continue # Skip hidden directories (start with .) - if dir_name.startswith('.'): + if dir_name.startswith("."): continue # Skip glob/wildcard patterns (e.g., "*_check.py" produces "*_check/") - if '*' in dir_name: + if "*" in dir_name: continue mentioned_dirs.add(dir_name) if not mentioned_dirs: - return { - 'name': 'Directory tree accuracy', - 'passed': True, - 'message': 'No directories detected in tree block' - } + return {"name": "Directory tree accuracy", "passed": True, "message": "No directories detected in tree block"} # Check which mentioned directories exist somewhere under branch root missing_dirs = [] @@ -392,15 +336,15 @@ def check_directory_tree(lines: List[str], branch_root: Path, file_path: str, by if not missing_dirs: return { - 'name': 'Directory tree accuracy', - 'passed': True, - 'message': f'All {len(mentioned_dirs)} directories in tree verified' + "name": "Directory tree accuracy", + "passed": True, + "message": f"All {len(mentioned_dirs)} directories in tree verified", } return { - 'name': 'Directory tree accuracy', - 'passed': False, - 'message': f'Directories in tree not found on disk: {", ".join(sorted(missing_dirs))}' + "name": "Directory tree accuracy", + "passed": False, + "message": f"Directories in tree not found on disk: {', '.join(sorted(missing_dirs))}", } @@ -411,17 +355,14 @@ def _extract_tree_block(content: str) -> Optional[str]: Returns the code block content, or None if not found. """ # Find heading line - heading_pattern = re.compile( - r'^#{1,3}\s+.*(architecture|directory\s+structure)', - re.IGNORECASE | re.MULTILINE - ) + heading_pattern = re.compile(r"^#{1,3}\s+.*(architecture|directory\s+structure)", re.IGNORECASE | re.MULTILINE) heading_match = heading_pattern.search(content) if not heading_match: return None # Look for next fenced code block after the heading - after_heading = content[heading_match.end():] - fence_pattern = re.compile(r'```[^\n]*\n(.*?)```', re.DOTALL) + after_heading = content[heading_match.end() :] + fence_pattern = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL) fence_match = fence_pattern.search(after_heading) if not fence_match: return None @@ -436,57 +377,49 @@ def check_module_list(lines: List[str], branch_root: Path, file_path: str, bypas Scans apps/modules/*.py (excluding __init__.py) and checks if each module name appears somewhere in the README content. """ - if is_bypassed(file_path, 'readme', None, bypass_rules): - return { - 'name': 'Module list completeness', - 'passed': True, - 'message': 'Bypassed by bypass rules' - } + if is_bypassed(file_path, "readme", None, bypass_rules): + return {"name": "Module list completeness", "passed": True, "message": "Bypassed by bypass rules"} - modules_dir = branch_root / 'apps' / 'modules' + modules_dir = branch_root / "apps" / "modules" if not modules_dir.exists(): return { - 'name': 'Module list completeness', - 'passed': True, - 'message': 'No apps/modules/ directory found (skipped)' + "name": "Module list completeness", + "passed": True, + "message": "No apps/modules/ directory found (skipped)", } # Get actual module files module_files = [] - for py_file in sorted(modules_dir.glob('*.py')): - if py_file.name == '__init__.py': + for py_file in sorted(modules_dir.glob("*.py")): + if py_file.name == "__init__.py": continue module_files.append(py_file.stem) if not module_files: - return { - 'name': 'Module list completeness', - 'passed': True, - 'message': 'No module files found in apps/modules/' - } + return {"name": "Module list completeness", "passed": True, "message": "No module files found in apps/modules/"} # Check if each module name appears in README - content_lower = '\n'.join(lines).lower() + content_lower = "\n".join(lines).lower() missing_modules = [] for module_name in module_files: # Check for module name (with underscores or spaces or as-is) name_lower = module_name.lower() # Also check with underscores replaced by spaces - name_spaced = name_lower.replace('_', ' ') + name_spaced = name_lower.replace("_", " ") if name_lower not in content_lower and name_spaced not in content_lower: missing_modules.append(module_name) if not missing_modules: return { - 'name': 'Module list completeness', - 'passed': True, - 'message': f'All {len(module_files)} modules mentioned in README' + "name": "Module list completeness", + "passed": True, + "message": f"All {len(module_files)} modules mentioned in README", } return { - 'name': 'Module list completeness', - 'passed': False, - 'message': f'Modules not mentioned in README: {", ".join(missing_modules)}' + "name": "Module list completeness", + "passed": False, + "message": f"Modules not mentioned in README: {', '.join(missing_modules)}", } @@ -497,26 +430,22 @@ def check_command_list(lines: List[str], file_path: str, bypass_rules: list | No Finds the Commands or Usage heading and checks that there is content between it and the next heading. """ - if is_bypassed(file_path, 'readme', None, bypass_rules): - return { - 'name': 'Command list presence', - 'passed': True, - 'message': 'Bypassed by bypass rules' - } + if is_bypassed(file_path, "readme", None, bypass_rules): + return {"name": "Command list presence", "passed": True, "message": "Bypassed by bypass rules"} # Find Commands or Usage heading command_heading_idx = None for i, line in enumerate(lines): stripped = line.strip() - if re.match(r'^#{1,3}\s+.*(commands|usage)', stripped, re.IGNORECASE): + if re.match(r"^#{1,3}\s+.*(commands|usage)", stripped, re.IGNORECASE): command_heading_idx = i break if command_heading_idx is None: return { - 'name': 'Command list presence', - 'passed': False, - 'message': 'No Commands/Usage section found in README' + "name": "Command list presence", + "passed": False, + "message": "No Commands/Usage section found in README", } # Check for content between this heading and next heading (or EOF) @@ -524,7 +453,7 @@ def check_command_list(lines: List[str], file_path: str, bypass_rules: list | No for i in range(command_heading_idx + 1, len(lines)): stripped = lines[i].strip() # Stop at next heading - if re.match(r'^#{1,3}\s+', stripped): + if re.match(r"^#{1,3}\s+", stripped): break # Count non-empty lines if stripped: @@ -532,13 +461,9 @@ def check_command_list(lines: List[str], file_path: str, bypass_rules: list | No if content_lines > 0: return { - 'name': 'Command list presence', - 'passed': True, - 'message': f'Commands/Usage section has {content_lines} content lines' + "name": "Command list presence", + "passed": True, + "message": f"Commands/Usage section has {content_lines} content lines", } - return { - 'name': 'Command list presence', - 'passed': False, - 'message': 'Commands/Usage section is empty' - } + return {"name": "Command list presence", "passed": False, "message": "Commands/Usage section is empty"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_content.py index a5faa2cb..e82dce50 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_content.py @@ -12,9 +12,9 @@ README Standards Content Provides Rich-formatted reference text for the README standard. """ - from aipass.seedgo.apps.handlers.json import json_handler + def get_readme_standards() -> str: """Return Rich-formatted README standards text""" json_handler.log_operation("standard_content_queried", {"standard": "readme"}) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py index 3aee2651..f7bf01da 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py @@ -121,8 +121,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict: ) return { "passed": True, - "checks": [{"name": "Ruff check", "passed": True, - "message": "Standard bypassed via .seedgo/bypass.json"}], + "checks": [{"name": "Ruff check", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], "score": 100, "standard": "RUFF_CHECK", "advisory": True, @@ -136,8 +135,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict: ) return { "passed": True, - "checks": [{"name": "Ruff check", "passed": True, - "message": "ruff not installed — check skipped"}], + "checks": [{"name": "Ruff check", "passed": True, "message": "ruff not installed — check skipped"}], "score": 100, "status": "skipped", "standard": "RUFF_CHECK", @@ -166,8 +164,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict: ) return { "passed": False, - "checks": [{"name": "Ruff check", "passed": False, - "message": "ruff check timed out after 60s"}], + "checks": [{"name": "Ruff check", "passed": False, "message": "ruff check timed out after 60s"}], "score": 0, "standard": "RUFF_CHECK", "advisory": True, @@ -189,8 +186,9 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict: ) return { "passed": False, - "checks": [{"name": "Ruff check", "passed": False, - "message": f"ruff JSON parse failed: {stderr_snippet}"}], + "checks": [ + {"name": "Ruff check", "passed": False, "message": f"ruff JSON parse failed: {stderr_snippet}"} + ], "score": 0, "standard": "RUFF_CHECK", "advisory": True, @@ -206,15 +204,17 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict: check_passed = True else: top = active[:5] - codes = ", ".join(f"{v.get('code', '?')} {Path(v.get('filename', '?')).name}:{v.get('location', {}).get('row', '?')}" for v in top) + codes = ", ".join( + f"{v.get('code', '?')} {Path(v.get('filename', '?')).name}:{v.get('location', {}).get('row', '?')}" + for v in top + ) suffix = f" (and {count - 5} more)" if count > 5 else "" message = f"{count} violation(s) — {codes}{suffix}" check_passed = False json_handler.log_operation( "check_completed", - {"branch": branch_path, "score": score, "standard": "ruff_check", - "violations": count, "advisory": True}, + {"branch": branch_path, "score": score, "standard": "ruff_check", "violations": count, "advisory": True}, ) return { diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check_content.py index 9a64b7cd..b2a8b432 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check_content.py @@ -77,20 +77,20 @@ def get_ruff_check_standards() -> str: "", "[bold cyan]BYPASS — standard:[/bold cyan]", " Add to [dim].seedgo/bypass.json[/dim] to skip the entire standard for a branch:", - " [dim]{\"standard\": \"ruff_check\", \"file\": \"src/aipass/\"}[/dim]", + ' [dim]{"standard": "ruff_check", "file": "src/aipass/"}[/dim]', "", "[bold cyan]BYPASS — ruff-specific:[/bold cyan]", " Add to [dim].seedgo/ruff_bypass.json[/dim] for fine-grained filtering.", " All fields are optional — omit to match any value.", "", " Skip all E501 in a file:", - " [dim]{\"file\": \"apps/handlers/long_file.py\", \"code\": \"E501\"}[/dim]", + ' [dim]{"file": "apps/handlers/long_file.py", "code": "E501"}[/dim]', "", " Skip a single violation at a specific line:", - " [dim]{\"file\": \"apps/modules/thing.py\", \"code\": \"F401\", \"line\": 42}[/dim]", + ' [dim]{"file": "apps/modules/thing.py", "code": "F401", "line": 42}[/dim]', "", " Skip all violations in a file:", - " [dim]{\"file\": \"apps/handlers/generated.py\"}[/dim]", + ' [dim]{"file": "apps/handlers/generated.py"}[/dim]', "", " File content: a JSON array of rule objects.", "", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/shebang_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/shebang_check.py index 973be474..56e6688b 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/shebang_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/shebang_check.py @@ -29,12 +29,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -62,49 +62,54 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ path = Path(module_path) - if is_bypassed(module_path, 'shebang', bypass_rules=bypass_rules): + if is_bypassed(module_path, "shebang", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'SHEBANG' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "SHEBANG", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'SHEBANG' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "SHEBANG", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: first_line = f.readline() except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'SHEBANG' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "SHEBANG", } # Check line 1 for shebang - if first_line.startswith('#!'): + if first_line.startswith("#!"): return { - 'passed': False, - 'checks': [{'name': 'No shebang line', 'passed': False, - 'message': 'Shebang lines are not needed in pip packages -- remove #!/... from line 1'}], - 'score': 0, - 'standard': 'SHEBANG' + "passed": False, + "checks": [ + { + "name": "No shebang line", + "passed": False, + "message": "Shebang lines are not needed in pip packages -- remove #!/... from line 1", + } + ], + "score": 0, + "standard": "SHEBANG", } score = 100 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "shebang"}) return { - 'passed': True, - 'checks': [{'name': 'No shebang line', 'passed': True, 'message': 'No shebang line found'}], - 'score': score, - 'standard': 'SHEBANG' + "passed": True, + "checks": [{"name": "No shebang line", "passed": True, "message": "No shebang line found"}], + "score": score, + "standard": "SHEBANG", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py index d0282355..92230cdf 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py @@ -31,9 +31,7 @@ from aipass.seedgo.apps.handlers.json import json_handler AUDIT_SCOPE = "all_files" # Logger attribute names that count as "logging present" -_LOGGING_ATTRS = frozenset({ - "error", "warning", "warn", "info", "debug", "exception", "critical" -}) +_LOGGING_ATTRS = frozenset({"error", "warning", "warn", "info", "debug", "exception", "critical"}) def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: @@ -41,12 +39,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -55,6 +53,7 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r # -- AST helpers (extracted from devpulse silent_catch_scanner_v2) --------- + def _has_logger_call(nodes: list[ast.stmt]) -> bool: """ Return True if any node in *nodes* (or its descendants) contains a @@ -105,43 +104,43 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # --- bypass ----------------------------------------------------------- - if is_bypassed(module_path, 'silent_catch', bypass_rules=bypass_rules): + if is_bypassed(module_path, "silent_catch", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'SILENT_CATCH' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "SILENT_CATCH", } # --- skip non-.py and __init__.py ------------------------------------- - if path.suffix != '.py' or path.name == '__init__.py': + if path.suffix != ".py" or path.name == "__init__.py": return { - 'passed': True, - 'checks': [{'name': 'Silent catch blocks', 'passed': True, 'message': 'File skipped (non-target)'}], - 'score': 100, - 'standard': 'SILENT_CATCH' + "passed": True, + "checks": [{"name": "Silent catch blocks", "passed": True, "message": "File skipped (non-target)"}], + "score": 100, + "standard": "SILENT_CATCH", } # --- file exists ------------------------------------------------------ if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'SILENT_CATCH' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "SILENT_CATCH", } # --- read file -------------------------------------------------------- try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: source = f.read() except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'SILENT_CATCH' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "SILENT_CATCH", } # --- parse AST -------------------------------------------------------- @@ -150,10 +149,10 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: except SyntaxError as e: logger.info("Skipped %s: SyntaxError during parse", path) return { - 'passed': False, - 'checks': [{'name': 'File parseable', 'passed': False, 'message': f'Syntax error: {e}'}], - 'score': 0, - 'standard': 'SILENT_CATCH' + "passed": False, + "checks": [{"name": "File parseable", "passed": False, "message": f"Syntax error: {e}"}], + "score": 0, + "standard": "SILENT_CATCH", } # --- walk AST for silent ExceptHandler nodes -------------------------- @@ -181,32 +180,27 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: violation_count = len(silent_lines) if violation_count == 0: - checks.append({ - 'name': 'Silent catch blocks', - 'passed': True, - 'message': 'No silent exception catches found' - }) + checks.append({"name": "Silent catch blocks", "passed": True, "message": "No silent exception catches found"}) else: first_three = silent_lines[:3] - line_preview = ', '.join(str(ln) for ln in first_three) - suffix = f' (and {violation_count - 3} more)' if violation_count > 3 else '' - checks.append({ - 'name': 'Silent catch blocks', - 'passed': False, - 'message': f'{violation_count} silent catch(es) on lines {line_preview}{suffix} -- add logger call or re-raise' - }) + line_preview = ", ".join(str(ln) for ln in first_three) + suffix = f" (and {violation_count - 3} more)" if violation_count > 3 else "" + checks.append( + { + "name": "Silent catch blocks", + "passed": False, + "message": f"{violation_count} silent catch(es) on lines {line_preview}{suffix} -- add logger call or re-raise", + } + ) # --- score ------------------------------------------------------------ - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks) * 100) if total_checks > 0 else 0 overall_passed = score >= 75 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "silent_catch"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'SILENT_CATCH' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "silent_catch"} + ) + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "SILENT_CATCH"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py index 85d0978a..7a6876a5 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py @@ -65,7 +65,7 @@ def get_silent_catch_standards() -> str: " [dim]try:[/dim]", " [dim] result = do_something()[/dim]", " [dim]except Exception as e:[/dim]", - " [dim] logger.error(f\"Operation failed: {{e}}\")[/dim]", + ' [dim] logger.error(f"Operation failed: {{e}}")[/dim]', "", " [green]Good -- re-raise:[/green]", " [dim]try:[/dim]", @@ -77,7 +77,7 @@ def get_silent_catch_standards() -> str: " [dim]try:[/dim]", " [dim] data = load_file(path)[/dim]", " [dim]except OSError as e:[/dim]", - " [dim] logger.warning(f\"Could not load: {{e}}\")[/dim]", + ' [dim] logger.warning(f"Could not load: {{e}}")[/dim]', " [dim] data = default_value[/dim]", "", "[yellow]SCOPE:[/yellow]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/stderr_routing_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/stderr_routing_check.py index 393062f5..96950529 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/stderr_routing_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/stderr_routing_check.py @@ -38,12 +38,12 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue return True @@ -60,42 +60,44 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: checks: List[Dict] = [] path = Path(module_path) - if is_bypassed(module_path, 'stderr_routing', bypass_rules=bypass_rules): + if is_bypassed(module_path, "stderr_routing", bypass_rules=bypass_rules): return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': 'Standard bypassed via .seedgo/bypass.json'}], - 'score': 100, - 'standard': 'STDERR_ROUTING' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "STDERR_ROUTING", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'STDERR_ROUTING' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "STDERR_ROUTING", } # CLI branch is exempt — it defines these functions - if '/cli/apps/' in module_path: + if "/cli/apps/" in module_path: return { - 'passed': True, - 'checks': [{'name': 'Stderr routing', 'passed': True, 'message': 'CLI branch exempt (defines display functions)'}], - 'score': 100, - 'standard': 'STDERR_ROUTING' + "passed": True, + "checks": [ + {"name": "Stderr routing", "passed": True, "message": "CLI branch exempt (defines display functions)"} + ], + "score": 100, + "standard": "STDERR_ROUTING", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'STDERR_ROUTING' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "STDERR_ROUTING", } filename = path.name @@ -103,62 +105,67 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # Check 1: console.print() with error-like red markup error_prints = _find_error_prints(lines, module_path, bypass_rules) if error_prints: - checks.append({ - 'name': 'Error output routing', - 'passed': False, - 'message': f'{filename}: {len(error_prints)} error print(s) on lines {error_prints[:5]} — use error() or fatal() instead' - }) + checks.append( + { + "name": "Error output routing", + "passed": False, + "message": f"{filename}: {len(error_prints)} error print(s) on lines {error_prints[:5]} — use error() or fatal() instead", + } + ) elif _has_any_output(content): - checks.append({ - 'name': 'Error output routing', - 'passed': True, - 'message': 'No error-like console.print() with red markup' - }) + checks.append( + {"name": "Error output routing", "passed": True, "message": "No error-like console.print() with red markup"} + ) # Check 2: console.print() with warning-like yellow markup warning_prints = _find_warning_prints(lines, module_path, bypass_rules) if warning_prints: - checks.append({ - 'name': 'Warning output routing', - 'passed': False, - 'message': f'{filename}: {len(warning_prints)} warning print(s) on lines {warning_prints[:5]} — use warning() instead' - }) + checks.append( + { + "name": "Warning output routing", + "passed": False, + "message": f"{filename}: {len(warning_prints)} warning print(s) on lines {warning_prints[:5]} — use warning() instead", + } + ) elif _has_any_output(content): - checks.append({ - 'name': 'Warning output routing', - 'passed': True, - 'message': 'No warning-like console.print() with yellow markup' - }) + checks.append( + { + "name": "Warning output routing", + "passed": True, + "message": "No warning-like console.print() with yellow markup", + } + ) # Check 3: Custom Console(stderr=True) — should import err_console custom_stderr = _find_custom_stderr_console(lines, module_path, bypass_rules) if custom_stderr: - checks.append({ - 'name': 'Stderr console creation', - 'passed': False, - 'message': f'{filename}: Custom Console(stderr=True) on lines {custom_stderr[:3]} — import err_console from aipass.cli.apps.modules' - }) + checks.append( + { + "name": "Stderr console creation", + "passed": False, + "message": f"{filename}: Custom Console(stderr=True) on lines {custom_stderr[:3]} — import err_console from aipass.cli.apps.modules", + } + ) # No checks applied = no output patterns = skip if not checks: return { - 'passed': True, - 'checks': [{'name': 'Stderr routing', 'passed': True, 'message': 'No error/warning output patterns (skipped)'}], - 'score': 100, - 'standard': 'STDERR_ROUTING' + "passed": True, + "checks": [ + {"name": "Stderr routing", "passed": True, "message": "No error/warning output patterns (skipped)"} + ], + "score": 100, + "standard": "STDERR_ROUTING", } - passed_checks = sum(1 for c in checks if c['passed']) + passed_checks = sum(1 for c in checks if c["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 - json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "stderr_routing"}) - return { - 'passed': score >= 75, - 'checks': checks, - 'score': score, - 'standard': 'STDERR_ROUTING' - } + json_handler.log_operation( + "check_completed", {"file": str(module_path), "score": score, "standard": "stderr_routing"} + ) + return {"passed": score >= 75, "checks": checks, "score": score, "standard": "STDERR_ROUTING"} def _is_markup_label(line: str, color: str, max_short_words: int = 2) -> bool: @@ -176,10 +183,7 @@ def _is_markup_label(line: str, color: str, max_short_words: int = 2) -> bool: - Yellow (warnings): 2 — real warnings like 'Template version mismatch' are 3+ words - Red (errors): 5 — red is commonly used for CLI feedback phrases """ - pattern = re.compile( - r'\[(?:bold\s+)?' + re.escape(color) + r'(?:\s+bold)?\](.*?)\[/', - re.IGNORECASE - ) + pattern = re.compile(r"\[(?:bold\s+)?" + re.escape(color) + r"(?:\s+bold)?\](.*?)\[/", re.IGNORECASE) match = pattern.search(line) if match: text = match.group(1).strip() @@ -189,7 +193,7 @@ def _is_markup_label(line: str, color: str, max_short_words: int = 2) -> bool: if word_count <= max_short_words: return True # Label ending with colon (e.g., 'COMMANDS:', 'Discovered Modules:') - if text.endswith(':') and word_count <= 3: + if text.endswith(":") and word_count <= 3: return True # ALL CAPS section header (e.g., 'ACTIONABLE ITEMS') if text.isupper() and word_count <= 3: @@ -199,7 +203,7 @@ def _is_markup_label(line: str, color: str, max_short_words: int = 2) -> bool: def _has_any_output(content: str) -> bool: """Check if file has any console output at all.""" - return 'console.print(' in content or 'err_console.print(' in content + return "console.print(" in content or "err_console.print(" in content def _in_skip_context(line: str, in_docstring: bool, in_main_block: bool) -> bool: @@ -207,7 +211,7 @@ def _in_skip_context(line: str, in_docstring: bool, in_main_block: bool) -> bool stripped = line.strip() if in_docstring or in_main_block: return True - if stripped.startswith('#'): + if stripped.startswith("#"): return True return False @@ -220,11 +224,13 @@ def _find_error_prints(lines: List[str], module_path: str, bypass_rules: list | main_block_indent = 0 # Patterns: [red], [bold red], [red bold] in console.print() - red_pattern = re.compile(r'console\.print\(.*\[(?:bold\s+)?red(?:\s+bold)?\]', re.IGNORECASE) + red_pattern = re.compile(r"console\.print\(.*\[(?:bold\s+)?red(?:\s+bold)?\]", re.IGNORECASE) # Also catch: console.print("Error: ...") or console.print("Failed to ...") # Only match when keyword appears at the START of the string content (not buried in help text) # "error" requires a colon (to distinguish "Error: ..." from "Error Registry ...") - error_msg_pattern = re.compile(r'''console\.print\(\s*f?["']\s*(?:error\s*:|(?:failed|fatal|cannot|unable|invalid)\b)''', re.IGNORECASE) + error_msg_pattern = re.compile( + r"""console\.print\(\s*f?["']\s*(?:error\s*:|(?:failed|fatal|cannot|unable|invalid)\b)""", re.IGNORECASE + ) for i, line in enumerate(lines, 1): stripped = line.strip() @@ -247,21 +253,21 @@ def _find_error_prints(lines: List[str], module_path: str, bypass_rules: list | continue # Skip if in a string context (the console.print itself is in a string) - if 'console.print(' not in stripped: + if "console.print(" not in stripped: continue # Check it's not inside a string literal - before = line.split('console.print(')[0] + before = line.split("console.print(")[0] if before.count("'") % 2 == 1 or before.count('"') % 2 == 1: continue # Skip bypassed lines - if is_bypassed(module_path, 'stderr_routing', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "stderr_routing", line=i, bypass_rules=bypass_rules): continue # Match red markup patterns (skip CLI feedback — short red phrases are UI styling) if red_pattern.search(stripped): - if not _is_markup_label(stripped, 'red', max_short_words=5): + if not _is_markup_label(stripped, "red", max_short_words=5): violations.append(i) elif error_msg_pattern.search(stripped): violations.append(i) @@ -277,9 +283,9 @@ def _find_warning_prints(lines: List[str], module_path: str, bypass_rules: list main_block_indent = 0 # Patterns: [yellow] in console.print() that look like warnings - yellow_pattern = re.compile(r'console\.print\(.*\[(?:bold\s+)?yellow(?:\s+bold)?\]', re.IGNORECASE) + yellow_pattern = re.compile(r"console\.print\(.*\[(?:bold\s+)?yellow(?:\s+bold)?\]", re.IGNORECASE) # Only match when keyword appears at the START of the string content (not buried in help text) - warning_msg_pattern = re.compile(r'''console\.print\(\s*f?["']\s*(?:⚠\s*)?warning\b''', re.IGNORECASE) + warning_msg_pattern = re.compile(r"""console\.print\(\s*f?["']\s*(?:⚠\s*)?warning\b""", re.IGNORECASE) for i, line in enumerate(lines, 1): stripped = line.strip() @@ -299,18 +305,18 @@ def _find_warning_prints(lines: List[str], module_path: str, bypass_rules: list if _in_skip_context(line, in_docstring, in_main_block): continue - if 'console.print(' not in stripped: + if "console.print(" not in stripped: continue - before = line.split('console.print(')[0] + before = line.split("console.print(")[0] if before.count("'") % 2 == 1 or before.count('"') % 2 == 1: continue - if is_bypassed(module_path, 'stderr_routing', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "stderr_routing", line=i, bypass_rules=bypass_rules): continue if yellow_pattern.search(stripped): - if not _is_markup_label(stripped, 'yellow'): + if not _is_markup_label(stripped, "yellow"): violations.append(i) elif warning_msg_pattern.search(stripped): violations.append(i) @@ -323,7 +329,7 @@ def _find_custom_stderr_console(lines: List[str], module_path: str, bypass_rules violations = [] in_docstring = False - stderr_pattern = re.compile(r'Console\s*\(.*stderr\s*=\s*True', re.IGNORECASE) + stderr_pattern = re.compile(r"Console\s*\(.*stderr\s*=\s*True", re.IGNORECASE) for i, line in enumerate(lines, 1): stripped = line.strip() @@ -332,10 +338,10 @@ def _find_custom_stderr_console(lines: List[str], module_path: str, bypass_rules if line.count(quote) % 2 == 1: in_docstring = not in_docstring - if in_docstring or stripped.startswith('#'): + if in_docstring or stripped.startswith("#"): continue - if is_bypassed(module_path, 'stderr_routing', line=i, bypass_rules=bypass_rules): + if is_bypassed(module_path, "stderr_routing", line=i, bypass_rules=bypass_rules): continue if stderr_pattern.search(stripped): diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py index 642184e8..6921ee7a 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py @@ -33,21 +33,32 @@ AUDIT_SCOPE = "branch_level" # -- Directories to skip when scanning for module coverage -------------------- SKIP_DIRS: set[str] = { - "__pycache__", ".archive", ".mypy_cache", ".ruff_cache", - ".pytest_cache", ".venv", "venv", "node_modules", ".git", - "site-packages", "logs", "tools", ".trinity", ".aipass", - ".ai_mail.local", ".spawn", "backups", "reports", "docs", + "__pycache__", + ".archive", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + ".venv", + "venv", + "node_modules", + ".git", + "site-packages", + "logs", + "tools", + ".trinity", + ".aipass", + ".ai_mail.local", + ".spawn", + "backups", + "reports", + "docs", ".sorting_unprocessed", } # -- Regex patterns for module coverage (from test_coverage_check.py) --------- RE_TEST_FUNC = re.compile(r"^\s*(?:async\s+)?def\s+(test_\w+)", re.MULTILINE) -RE_IMPORT_FROM = re.compile( - r"from\s+(?:aipass\.)?\w+\.apps\.(?:modules|handlers)[./]?([\w.]*)\s+import" -) -RE_IMPORT_DIRECT = re.compile( - r"import\s+(?:aipass\.)?\w+\.apps\.(?:modules|handlers)[./]?([\w.]*)" -) +RE_IMPORT_FROM = re.compile(r"from\s+(?:aipass\.)?\w+\.apps\.(?:modules|handlers)[./]?([\w.]*)\s+import") +RE_IMPORT_DIRECT = re.compile(r"import\s+(?:aipass\.)?\w+\.apps\.(?:modules|handlers)[./]?([\w.]*)") # -- Standard test categories and their detection patterns -------------------- STANDARD_CATEGORIES: dict[str, dict[str, list[str]]] = { @@ -163,6 +174,7 @@ TOTAL_ITEMS = _PATTERN_ITEMS + _MODULE_COVERAGE_ITEMS # BYPASS HELPER # ============================================= + def is_bypassed( file_path: str, standard: str, @@ -189,6 +201,7 @@ def is_bypassed( # FILE HELPERS # ============================================= + def _read_file_safe(path: Path) -> str: """Read a file, returning empty string on any error.""" try: @@ -282,11 +295,7 @@ def _collect_testable_modules(branch_path: Path) -> set[str]: if _should_skip_dir(item.name): continue if item.is_dir() and item.name != "__pycache__": - has_py = any( - f.suffix == ".py" and f.name != "__init__.py" - for f in item.iterdir() - if f.is_file() - ) + has_py = any(f.suffix == ".py" and f.name != "__init__.py" for f in item.iterdir() if f.is_file()) if has_py: modules.add(item.name) elif item.is_file() and item.suffix == ".py" and item.name != "__init__.py": @@ -318,8 +327,10 @@ def _find_all_test_files(branch_path: Path) -> list[Path]: # ANALYSIS # ============================================= + def _find_covering_file( - patterns: list[str], file_sources: list[tuple[str, str]], + patterns: list[str], + file_sources: list[tuple[str, str]], ) -> str | None: """Find the first file that contains any of the given patterns.""" for filename, source in file_sources: @@ -357,6 +368,7 @@ def _detect_all_coverage( # BRANCH-LEVEL CHECK # ============================================= + def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: """Run test quality analysis on a branch. @@ -408,11 +420,13 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: test_files = _find_all_test_files(bp) if not test_files: - checks.append({ - "name": "Test files", - "passed": False, - "message": "No test_*.py or conftest.py files found in tests/ directory", - }) + checks.append( + { + "name": "Test files", + "passed": False, + "message": "No test_*.py or conftest.py files found in tests/ directory", + } + ) json_handler.log_operation( "check_completed", @@ -432,11 +446,13 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: "standard": "TEST_QUALITY", } - checks.append({ - "name": "Test files", - "passed": True, - "message": f"Found {len(test_files)} test file(s) in tests/", - }) + checks.append( + { + "name": "Test files", + "passed": True, + "message": f"Found {len(test_files)} test file(s) in tests/", + } + ) # Phase 2: Read all test file sources file_sources: list[tuple[str, str]] = [] @@ -455,25 +471,24 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: cat_total = len(item_coverage) cat_covered = sum(1 for f in item_coverage.values() if f is not None) total_items_covered += cat_covered - missing_items = [ - item for item, f in item_coverage.items() if f is None - ] + missing_items = [item for item, f in item_coverage.items() if f is None] if cat_covered == cat_total: - checks.append({ - "name": category, - "passed": True, - "message": f"{category}: {cat_covered}/{cat_total} covered", - }) + checks.append( + { + "name": category, + "passed": True, + "message": f"{category}: {cat_covered}/{cat_total} covered", + } + ) else: - checks.append({ - "name": category, - "passed": False, - "message": ( - f"{category}: {cat_covered}/{cat_total} covered " - f"(missing: {', '.join(missing_items)})" - ), - }) + checks.append( + { + "name": category, + "passed": False, + "message": (f"{category}: {cat_covered}/{cat_total} covered (missing: {', '.join(missing_items)})"), + } + ) # Phase 4: Module coverage (category 11 — from test_coverage_check.py) # Uses broader file discovery + import-based module mapping @@ -533,20 +548,24 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: mc_msg = f"module_coverage: {mc_items_covered}/{_MODULE_COVERAGE_ITEMS} covered" if total_modules > 0: mc_msg += f" ({covered_count}/{total_modules} modules, {total_tests} tests)" - checks.append({ - "name": "module_coverage", - "passed": True, - "message": mc_msg, - }) + checks.append( + { + "name": "module_coverage", + "passed": True, + "message": mc_msg, + } + ) else: - checks.append({ - "name": "module_coverage", - "passed": False, - "message": ( - f"module_coverage: {mc_items_covered}/{_MODULE_COVERAGE_ITEMS} covered " - f"(missing: {', '.join(mc_details)})" - ), - }) + checks.append( + { + "name": "module_coverage", + "passed": False, + "message": ( + f"module_coverage: {mc_items_covered}/{_MODULE_COVERAGE_ITEMS} covered " + f"(missing: {', '.join(mc_details)})" + ), + } + ) # Score = total coverage percentage score = int((total_items_covered / TOTAL_ITEMS) * 100) @@ -559,24 +578,27 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: # Overall summary check if overall_passed: - checks.append({ - "name": "Overall coverage", - "passed": True, - "message": ( - f"{total_items_covered}/{TOTAL_ITEMS} items covered " - f"across {total_categories} categories ({score}%)" - ), - }) + checks.append( + { + "name": "Overall coverage", + "passed": True, + "message": ( + f"{total_items_covered}/{TOTAL_ITEMS} items covered across {total_categories} categories ({score}%)" + ), + } + ) else: - checks.append({ - "name": "Overall coverage", - "passed": False, - "message": ( - f"{total_items_covered}/{TOTAL_ITEMS} items covered " - f"across {total_categories} categories ({score}%) " - f"-- minimum 75% required" - ), - }) + checks.append( + { + "name": "Overall coverage", + "passed": False, + "message": ( + f"{total_items_covered}/{TOTAL_ITEMS} items covered " + f"across {total_categories} categories ({score}%) " + f"-- minimum 75% required" + ), + } + ) json_handler.log_operation( "check_completed", @@ -595,9 +617,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: "category_detail": { **{ cat: { - "covered": sum( - 1 for f in items.values() if f is not None - ), + "covered": sum(1 for f in items.values() if f is not None), "total": len(items), } for cat, items in all_coverage.items() diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/todo_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/todo_check.py index 0da3ae13..433af707 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/todo_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/todo_check.py @@ -175,11 +175,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: "message": "No TODO/FIXME/HACK/XXX comments found", } else: - breakdown = ", ".join( - f"{tag}: {count}" - for tag in _TAGS - if (count := tag_counts.get(tag, 0)) > 0 - ) + breakdown = ", ".join(f"{tag}: {count}" for tag in _TAGS if (count := tag_counts.get(tag, 0)) > 0) check_result = { "name": "TODO/FIXME comments", "passed": False, diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_check.py index 98060675..56f1c085 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_check.py @@ -34,15 +34,17 @@ AUDIT_SCOPE = "all_files" # Valid bypass categories for trigger standard BYPASS_CATEGORIES = { - 'handler_layer': 'Function in handlers/ layer (orchestrator fires instead)', - 'initialization': 'One-time setup/config creation', - 'internal_ops': 'Same-module internal operation', - 'high_frequency': 'Would create event spam', - 'utility': 'Helper called by event-firing function', + "handler_layer": "Function in handlers/ layer (orchestrator fires instead)", + "initialization": "One-time setup/config creation", + "internal_ops": "Same-module internal operation", + "high_frequency": "Would create event spam", + "utility": "Helper called by event-firing function", } -def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> tuple[bool, str | None, str | None]: +def is_bypassed( + file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None +) -> tuple[bool, str | None, str | None]: """Check if a violation should be bypassed Args: @@ -59,16 +61,16 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r if not bypass_rules: return False, None, None for rule in bypass_rules: - if rule.get('standard') and rule.get('standard') != standard: + if rule.get("standard") and rule.get("standard") != standard: continue - rule_file = rule.get('file', '') + rule_file = rule.get("file", "") if rule_file and rule_file not in file_path: continue - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None and line not in rule_lines: continue - category = rule.get('category') - reason = rule.get('reason') + category = rule.get("category") + reason = rule.get("reason") return True, category, reason return False, None, None @@ -79,12 +81,12 @@ def is_handler_layer(file_path: str) -> bool: Handler layer functions are typically called by modules which fire events. The handler itself shouldn't fire - that would be double-firing. """ - return '/handlers/' in file_path or '\\handlers\\' in file_path + return "/handlers/" in file_path or "\\handlers\\" in file_path def is_trigger_handler(file_path: str) -> bool: """Check if file is a trigger event handler""" - return 'trigger' in file_path and 'handlers/events' in file_path + return "trigger" in file_path and "handlers/events" in file_path def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: @@ -107,39 +109,39 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: path = Path(module_path) # Check if entire standard is bypassed - bypassed, category, reason = is_bypassed(module_path, 'trigger', bypass_rules=bypass_rules) + bypassed, category, reason = is_bypassed(module_path, "trigger", bypass_rules=bypass_rules) if bypassed: - bypass_msg = 'Standard bypassed via .seedgo/bypass.json' + bypass_msg = "Standard bypassed via .seedgo/bypass.json" if category: - bypass_msg += f' [category: {category}]' + bypass_msg += f" [category: {category}]" if reason: - bypass_msg += f' - {reason}' + bypass_msg += f" - {reason}" return { - 'passed': True, - 'checks': [{'name': 'Bypassed', 'passed': True, 'message': bypass_msg}], - 'score': 100, - 'standard': 'TRIGGER' + "passed": True, + "checks": [{"name": "Bypassed", "passed": True, "message": bypass_msg}], + "score": 100, + "standard": "TRIGGER", } if not path.exists(): return { - 'passed': False, - 'checks': [{'name': 'File exists', 'passed': False, 'message': f'File not found: {module_path}'}], - 'score': 0, - 'standard': 'TRIGGER' + "passed": False, + "checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}], + "score": 0, + "standard": "TRIGGER", } try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: logger.info("Cannot read %s: %s", path, e) return { - 'passed': False, - 'checks': [{'name': 'File readable', 'passed': False, 'message': f'Error reading file: {e}'}], - 'score': 0, - 'standard': 'TRIGGER' + "passed": False, + "checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}], + "score": 0, + "standard": "TRIGGER", } is_handler = is_trigger_handler(module_path) @@ -168,14 +170,16 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # Check 5: Detect event patterns that should use trigger.fire() but don't # Skip trigger branch itself (it's the event bus) # Skip handler layer files - they are called by modules which fire events (handler_layer bypass category) - if 'trigger/' not in module_path and 'trigger\\' not in module_path: + if "trigger/" not in module_path and "trigger\\" not in module_path: if is_handler_layer(module_path): # Handler layer auto-bypass - modules fire events, not handlers - checks.append({ - 'name': 'Missing trigger events', - 'passed': True, - 'message': 'Handler layer file - module orchestrator fires events (auto-bypass: handler_layer)' - }) + checks.append( + { + "name": "Missing trigger events", + "passed": True, + "message": "Handler layer file - module orchestrator fires events (auto-bypass: handler_layer)", + } + ) else: event_pattern_check = check_missing_trigger_events(content, lines, module_path) if event_pattern_check: @@ -184,24 +188,19 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # If no checks apply, file is compliant if not checks: return { - 'passed': True, - 'checks': [{'name': 'Trigger check', 'passed': True, 'message': 'No trigger patterns to check'}], - 'score': 100, - 'standard': 'TRIGGER' + "passed": True, + "checks": [{"name": "Trigger check", "passed": True, "message": "No trigger patterns to check"}], + "score": 100, + "standard": "TRIGGER", } - passed_checks = sum(1 for check in checks if check['passed']) + passed_checks = sum(1 for check in checks if check["passed"]) total_checks = len(checks) score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0 overall_passed = score >= 75 json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "trigger"}) - return { - 'passed': overall_passed, - 'checks': checks, - 'score': score, - 'standard': 'TRIGGER' - } + return {"passed": overall_passed, "checks": checks, "score": score, "standard": "TRIGGER"} def check_no_logger_imports(_content: str, lines: List[str], _module_path: str) -> Dict: @@ -215,29 +214,25 @@ def check_no_logger_imports(_content: str, lines: List[str], _module_path: str) for i, line in enumerate(lines, 1): stripped = line.strip() - if stripped.startswith('#'): + if stripped.startswith("#"): continue # Check for prax logger imports - if 'from prax' in stripped and 'logger' in stripped: + if "from prax" in stripped and "logger" in stripped: violations.append(i) - elif 'import prax' in stripped and 'logger' in stripped: + elif "import prax" in stripped and "logger" in stripped: violations.append(i) - elif 'system_logger' in stripped and 'import' in stripped: + elif "system_logger" in stripped and "import" in stripped: violations.append(i) if violations: return { - 'name': 'No logger imports', - 'passed': False, - 'message': f'Handler imports Prax logger (causes recursion) on lines {violations[:3]}' + "name": "No logger imports", + "passed": False, + "message": f"Handler imports Prax logger (causes recursion) on lines {violations[:3]}", } - return { - 'name': 'No logger imports', - 'passed': True, - 'message': 'Handler correctly has no Prax logger imports' - } + return {"name": "No logger imports", "passed": True, "message": "Handler correctly has no Prax logger imports"} def check_no_print_statements(_content: str, lines: List[str], _module_path: str) -> Dict: @@ -266,25 +261,21 @@ def check_no_print_statements(_content: str, lines: List[str], _module_path: str if in_main_block: continue - if stripped.startswith('#'): + if stripped.startswith("#"): continue # Check for print statements - if re.search(r'(? bool: @@ -295,9 +286,9 @@ def _is_trigger_branch(file_path: str) -> bool: import itself. """ # Normalise Windows separators for a single check - normalized = file_path.replace('\\', '/') + normalized = file_path.replace("\\", "/") # Match anything under the trigger branch's own tree - return '/trigger/apps/' in normalized + return "/trigger/apps/" in normalized def check_trigger_import_pattern(content: str, _lines: List[str], _module_path: str) -> Optional[Dict]: @@ -309,26 +300,26 @@ def check_trigger_import_pattern(content: str, _lines: List[str], _module_path: - from trigger.apps.modules.core import trigger """ # Only check files that use trigger - if 'trigger' not in content.lower(): + if "trigger" not in content.lower(): return None # The trigger branch IS the event bus — it cannot import itself if _is_trigger_branch(_module_path): return { - 'name': 'Trigger import pattern', - 'passed': True, - 'message': 'Trigger branch file — self-reference, import not required' + "name": "Trigger import pattern", + "passed": True, + "message": "Trigger branch file — self-reference, import not required", } # Check if trigger is imported has_trigger_import = False - has_trigger_fire = 'trigger.fire(' in content + has_trigger_fire = "trigger.fire(" in content # Valid import patterns (with optional aipass. prefix) valid_patterns = [ - r'from\s+(?:aipass\.)?trigger\s+import\s+trigger', - r'from\s+(?:aipass\.)?trigger\.apps\.modules\.core\s+import\s+trigger', - r'from\s+(?:aipass\.)?trigger\.apps\.modules\.core\s+import\s+trigger\s+as\s+\w+', + r"from\s+(?:aipass\.)?trigger\s+import\s+trigger", + r"from\s+(?:aipass\.)?trigger\.apps\.modules\.core\s+import\s+trigger", + r"from\s+(?:aipass\.)?trigger\.apps\.modules\.core\s+import\s+trigger\s+as\s+\w+", ] for pattern in valid_patterns: @@ -337,22 +328,18 @@ def check_trigger_import_pattern(content: str, _lines: List[str], _module_path: break # Also check lazy-load pattern - if '_trigger' in content and 'trigger.apps.modules.core' in content: + if "_trigger" in content and "trigger.apps.modules.core" in content: has_trigger_import = True if has_trigger_fire and not has_trigger_import: return { - 'name': 'Trigger import pattern', - 'passed': False, - 'message': 'Uses trigger.fire() but missing proper import' + "name": "Trigger import pattern", + "passed": False, + "message": "Uses trigger.fire() but missing proper import", } if has_trigger_import: - return { - 'name': 'Trigger import pattern', - 'passed': True, - 'message': 'Correct trigger import pattern' - } + return {"name": "Trigger import pattern", "passed": True, "message": "Correct trigger import pattern"} return None @@ -368,17 +355,17 @@ def check_handler_naming(_content: str, lines: List[str], _module_path: str) -> for i, line in enumerate(lines, 1): # Look for function definitions - match = re.match(r'^def\s+(\w+)\s*\(', line) + match = re.match(r"^def\s+(\w+)\s*\(", line) if match: func_name = match.group(1) # Skip private/internal functions - if func_name.startswith('_'): + if func_name.startswith("_"): continue # Check if it's a handler function - if 'handle' in func_name.lower(): + if "handle" in func_name.lower(): handler_functions.append(func_name) # Check naming pattern - if not func_name.startswith('handle_'): + if not func_name.startswith("handle_"): bad_handlers.append(func_name) if not handler_functions: @@ -386,15 +373,15 @@ def check_handler_naming(_content: str, lines: List[str], _module_path: str) -> if bad_handlers: return { - 'name': 'Handler naming', - 'passed': False, - 'message': f'Handler functions should be handle_{{event}}: {bad_handlers}' + "name": "Handler naming", + "passed": False, + "message": f"Handler functions should be handle_{{event}}: {bad_handlers}", } return { - 'name': 'Handler naming', - 'passed': True, - 'message': f'Handler functions correctly named: {handler_functions}' + "name": "Handler naming", + "passed": True, + "message": f"Handler functions correctly named: {handler_functions}", } @@ -411,7 +398,7 @@ def check_missing_trigger_events(content: str, lines: List[str], _module_path: s Returns violations with line numbers for easy navigation. """ violations = [] - has_trigger_fire = 'trigger.fire(' in content + has_trigger_fire = "trigger.fire(" in content def find_pattern_lines(pattern: str) -> List[int]: """Find all line numbers where pattern matches""" @@ -422,140 +409,140 @@ def check_missing_trigger_events(content: str, lines: List[str], _module_path: s return matched_lines # Pattern 1: watchdog FileSystemEventHandler without trigger - if 'FileSystemEventHandler' in content and not has_trigger_fire: - event_methods = ['on_created', 'on_deleted', 'on_modified', 'on_moved'] + if "FileSystemEventHandler" in content and not has_trigger_fire: + event_methods = ["on_created", "on_deleted", "on_modified", "on_moved"] for method in event_methods: - pattern = rf'def\s+{method}\s*\(' + pattern = rf"def\s+{method}\s*\(" matched = find_pattern_lines(pattern) if matched: - violations.append(f'FileSystemEventHandler.{method}() on lines {matched}') + violations.append(f"FileSystemEventHandler.{method}() on lines {matched}") # Pattern 2: Lifecycle functions - check BOTH modules AND handlers # These are significant state changes that other systems care about lifecycle_patterns = [ - (r'def\s+create_\w+\s*\(', 'create_*'), - (r'def\s+close_\w+\s*\(', 'close_*'), - (r'def\s+delete_\w+\s*\(', 'delete_*'), - (r'def\s+restore_\w+\s*\(', 'restore_*'), + (r"def\s+create_\w+\s*\(", "create_*"), + (r"def\s+close_\w+\s*\(", "close_*"), + (r"def\s+delete_\w+\s*\(", "delete_*"), + (r"def\s+restore_\w+\s*\(", "restore_*"), ] if not has_trigger_fire: for pattern, func_type in lifecycle_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_type} function on lines {matched}') + violations.append(f"{func_type} function on lines {matched}") # Pattern 3: Email/messaging patterns (common in ai_mail, other branches) messaging_patterns = [ - (r'def\s+deliver_\w+\s*\(', 'deliver_*'), - (r'def\s+send_(?!notification)\w+\s*\(', 'send_*'), # Exclude send_notification + (r"def\s+deliver_\w+\s*\(", "deliver_*"), + (r"def\s+send_(?!notification)\w+\s*\(", "send_*"), # Exclude send_notification ] if not has_trigger_fire: for pattern, func_type in messaging_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_type} function on lines {matched}') + violations.append(f"{func_type} function on lines {matched}") # Pattern 4: State change patterns (mark_as_*, archive_*) state_patterns = [ - (r'def\s+mark_as_\w+\s*\(', 'mark_as_*'), - (r'def\s+archive_\w+\s*\(', 'archive_*'), + (r"def\s+mark_as_\w+\s*\(", "mark_as_*"), + (r"def\s+archive_\w+\s*\(", "archive_*"), ] if not has_trigger_fire: for pattern, func_type in state_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_type} function on lines {matched}') + violations.append(f"{func_type} function on lines {matched}") # Pattern 5: Registry/JSON update patterns (significant state changes) # These modify shared state that other systems care about registry_patterns = [ - (r'def\s+save_registry\s*\(', 'save_registry'), - (r'def\s+add_registry_entry\s*\(', 'add_registry_entry'), - (r'def\s+remove_registry_entry\s*\(', 'remove_registry_entry'), - (r'def\s+sync_\w*registry\s*\(', 'sync_*registry'), - (r'def\s+update_registry\s*\(', 'update_registry'), - (r'def\s+synchronize_registry\s*\(', 'synchronize_registry'), - (r'def\s+ping_registry\s*\(', 'ping_registry'), + (r"def\s+save_registry\s*\(", "save_registry"), + (r"def\s+add_registry_entry\s*\(", "add_registry_entry"), + (r"def\s+remove_registry_entry\s*\(", "remove_registry_entry"), + (r"def\s+sync_\w*registry\s*\(", "sync_*registry"), + (r"def\s+update_registry\s*\(", "update_registry"), + (r"def\s+synchronize_registry\s*\(", "synchronize_registry"), + (r"def\s+ping_registry\s*\(", "ping_registry"), ] if not has_trigger_fire: for pattern, func_name in registry_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_name} on lines {matched}') + violations.append(f"{func_name} on lines {matched}") # Pattern 6: Central file operations (cross-branch shared state) central_patterns = [ - (r'def\s+update_central\s*\(', 'update_central'), - (r'def\s+write_central\w*\s*\(', 'write_central_*'), - (r'def\s+push_to_central\s*\(', 'push_to_central'), - (r'def\s+aggregate_central\s*\(', 'aggregate_central'), + (r"def\s+update_central\s*\(", "update_central"), + (r"def\s+write_central\w*\s*\(", "write_central_*"), + (r"def\s+push_to_central\s*\(", "push_to_central"), + (r"def\s+aggregate_central\s*\(", "aggregate_central"), ] if not has_trigger_fire: for pattern, func_name in central_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_name} on lines {matched}') + violations.append(f"{func_name} on lines {matched}") # Pattern 7: Auto-repair and recovery operations repair_patterns = [ - (r'def\s+_?auto_close_\w+\s*\(', 'auto_close_*'), - (r'def\s+recover_\w+\s*\(', 'recover_*'), - (r'def\s+_?heal_\w+\s*\(', 'heal_*'), + (r"def\s+_?auto_close_\w+\s*\(", "auto_close_*"), + (r"def\s+recover_\w+\s*\(", "recover_*"), + (r"def\s+_?heal_\w+\s*\(", "heal_*"), ] if not has_trigger_fire: for pattern, func_name in repair_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_name} on lines {matched}') + violations.append(f"{func_name} on lines {matched}") # Pattern 8: Cleanup and backup operations (state deletion/preservation) cleanup_patterns = [ - (r'def\s+cleanup_\w+\s*\(', 'cleanup_*'), - (r'def\s+backup_\w+\s*\(', 'backup_*'), + (r"def\s+cleanup_\w+\s*\(", "cleanup_*"), + (r"def\s+backup_\w+\s*\(", "backup_*"), ] if not has_trigger_fire: for pattern, func_name in cleanup_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_name} on lines {matched}') + violations.append(f"{func_name} on lines {matched}") # Pattern 9: System lifecycle (initialize/shutdown entire systems) lifecycle_system_patterns = [ - (r'def\s+initialize_\w+_system\s*\(', 'initialize_*_system'), - (r'def\s+shutdown_\w+_system\s*\(', 'shutdown_*_system'), + (r"def\s+initialize_\w+_system\s*\(", "initialize_*_system"), + (r"def\s+shutdown_\w+_system\s*\(", "shutdown_*_system"), ] if not has_trigger_fire: for pattern, func_name in lifecycle_system_patterns: matched = find_pattern_lines(pattern) if matched: - violations.append(f'{func_name} on lines {matched}') + violations.append(f"{func_name} on lines {matched}") # Pattern 10: Inline filesystem operations (method calls, not function defs) # These directly modify filesystem state - file deletions and moves if not has_trigger_fire: # .unlink() - file deletion - unlink_lines = find_pattern_lines(r'\.\s*unlink\s*\(') + unlink_lines = find_pattern_lines(r"\.\s*unlink\s*\(") if unlink_lines: - violations.append(f'.unlink() file deletion on lines {unlink_lines}') + violations.append(f".unlink() file deletion on lines {unlink_lines}") # .rename() - file move/rename - rename_lines = find_pattern_lines(r'\.\s*rename\s*\(') + rename_lines = find_pattern_lines(r"\.\s*rename\s*\(") if rename_lines: - violations.append(f'.rename() file move on lines {rename_lines}') + violations.append(f".rename() file move on lines {rename_lines}") if not violations: return None return { - 'name': 'Missing trigger events', - 'passed': False, - 'message': f'Missing trigger.fire(): {"; ".join(violations)}' + "name": "Missing trigger events", + "passed": False, + "message": f"Missing trigger.fire(): {'; '.join(violations)}", } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_content.py index f28b5832..a83725ca 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/trigger_content.py @@ -13,9 +13,9 @@ Provides formatted Trigger event bus standards content. Module orchestrates, handler implements. """ - from aipass.seedgo.apps.handlers.json import json_handler + def get_trigger_standards() -> str: """Return formatted trigger standards content with Rich markup diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py index 8cfeb700..31a1ab9f 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py @@ -36,11 +36,28 @@ AUDIT_SCOPE = "branch_level" # -- Directories to skip when collecting source files ------------------------- SKIP_DIRS = { - "__pycache__", ".archive", "logs", "tests", - "json_templates", "tools", ".trinity", ".aipass", ".ai_mail.local", - ".venv", "venv", "node_modules", ".git", "site-packages", - ".mypy_cache", ".ruff_cache", ".pytest_cache", ".spawn", - "backups", "reports", "docs", ".sorting_unprocessed", + "__pycache__", + ".archive", + "logs", + "tests", + "json_templates", + "tools", + ".trinity", + ".aipass", + ".ai_mail.local", + ".venv", + "venv", + "node_modules", + ".git", + "site-packages", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + ".spawn", + "backups", + "reports", + "docs", + ".sorting_unprocessed", } # -- Function names excluded from analysis ------------------------------------ @@ -60,6 +77,7 @@ _MAIN_BLOCK_RE = re.compile( # -- Bypass helper ------------------------------------------------------------ + def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_rules: list | None = None) -> bool: """Check if a violation should be bypassed.""" if not bypass_rules: @@ -79,6 +97,7 @@ def is_bypassed(file_path: str, standard: str, line: int | None = None, bypass_r # -- File collection ---------------------------------------------------------- + def _should_skip(path: Path) -> bool: """Return True if any path component is in the skip set.""" return any(part in SKIP_DIRS for part in path.parts) @@ -98,6 +117,7 @@ def _collect_python_files(branch_path: Path) -> list[Path]: # -- Corpus preparation ------------------------------------------------------ + def _strip_non_code(source: str) -> str: """ Remove string literals, comments, and __main__ blocks from source. @@ -113,7 +133,7 @@ def _strip_non_code(source: str) -> str: logger.info("Tokenizer failed, falling back to raw corpus: %s", exc) return _MAIN_BLOCK_RE.sub("", source) - lines = source.split('\n') + lines = source.split("\n") chars = [list(line) for line in lines] for tok in tokens: @@ -127,14 +147,15 @@ def _strip_non_code(source: str) -> str: col_start = sc if row == sr else 0 col_end = ec if row == er else len(chars[row]) for col in range(col_start, min(col_end, len(chars[row]))): - chars[row][col] = ' ' + chars[row][col] = " " - result = '\n'.join(''.join(line) for line in chars) + result = "\n".join("".join(line) for line in chars) return _MAIN_BLOCK_RE.sub("", result) # -- AST function extraction -------------------------------------------------- + def _is_excluded(name: str) -> bool: """Return True if this function name should never be flagged.""" if name.startswith("__") and name.endswith("__"): @@ -174,6 +195,7 @@ def _extract_functions(py_file: Path) -> list[tuple[str, int]]: # -- Reference counting ------------------------------------------------------- + def _count_references_in_corpus(func_name: str, corpus: str) -> int: """ Count how many times func_name appears as a word-bounded identifier @@ -198,6 +220,7 @@ def _count_def_lines(func_name: str, corpus: str) -> int: # -- Branch-level check (audit pipeline entry) -------------------------------- + def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: """ Check a branch for unused function definitions. @@ -306,11 +329,13 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: except ValueError: logger.info("File %s not relative to branch, using full path", py_file) rel_path = py_file - unused_functions.append({ - "name": func_name, - "file": str(rel_path), - "line": lineno, - }) + unused_functions.append( + { + "name": func_name, + "file": str(rel_path), + "line": lineno, + } + ) # Score: clean_functions / total_functions * 100 clean_count = total_functions - len(unused_functions) @@ -320,10 +345,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: # Build check entry if unused_functions: # Build a summary of unused functions (cap at 15 for readability) - details = [ - f" {uf['name']} ({uf['file']}:{uf['line']})" - for uf in unused_functions[:15] - ] + details = [f" {uf['name']} ({uf['file']}:{uf['line']})" for uf in unused_functions[:15]] if len(unused_functions) > 15: details.append(f" ... and {len(unused_functions) - 15} more") detail_text = "\n".join(details) @@ -333,8 +355,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: "name": "Unused functions", "passed": passed, "message": ( - f"{len(unused_functions)} unused out of {total_functions} " - f"functions ({score}% clean)\n{detail_text}" + f"{len(unused_functions)} unused out of {total_functions} functions ({score}% clean)\n{detail_text}" ), "unused": unused_functions, } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py index 611b2dfe..8ae82c64 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py @@ -39,7 +39,7 @@ def get_unused_function_standards() -> str: " Reads all files and strips non-code content:", " - Triple-quoted strings (docstrings, multiline literals)", " - Comment lines", - " - [dim]if __name__ == \"__main__\":[/dim] blocks", + ' - [dim]if __name__ == "__main__":[/dim] blocks', "", " [yellow]Phase 3 -- Extract functions:[/yellow]", " AST-parses each file to find [dim]def[/dim] and [dim]async def[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/audit/audit_display.py b/src/aipass/seedgo/apps/handlers/audit/audit_display.py index 8c07de87..59341708 100644 --- a/src/aipass/seedgo/apps/handlers/audit/audit_display.py +++ b/src/aipass/seedgo/apps/handlers/audit/audit_display.py @@ -37,6 +37,7 @@ from aipass.seedgo.apps.handlers.json import json_handler # INTROSPECTION # ============================================================================= + def print_introspection() -> None: """Display module info and connected handlers.""" console.print() @@ -68,9 +69,10 @@ def print_introspection() -> None: # PRIVATE HELPERS — Generic violation rendering # ============================================================================= + def _format_standard_name(name: str) -> str: """Convert 'DEEP_NESTING' or 'deep_nesting' to 'Deep Nesting'.""" - return name.replace('_', ' ').title() + return name.replace("_", " ").title() def _render_violations(standard_name: str, violations: list, console_obj) -> None: @@ -84,13 +86,13 @@ def _render_violations(standard_name: str, violations: list, console_obj) -> Non shown = violations[:5] for violation in shown: - file_path = violation.get('path', violation.get('file', '')) - score = violation.get('score', 0) + file_path = violation.get("path", violation.get("file", "")) + score = violation.get("score", 0) console_obj.print(f" [red]✗[/red] [magenta]{file_path}[/magenta] [dim](score: {score}%)[/dim]") - for issue in violation.get('issues', []): + for issue in violation.get("issues", []): console_obj.print(f" [dim]• {issue}[/dim]") # Fallback: if no 'issues' key but 'message' exists, show that - if not violation.get('issues') and violation.get('message'): + if not violation.get("issues") and violation.get("message"): console_obj.print(f" [dim]• {violation['message']}[/dim]") if len(violations) > 5: @@ -99,10 +101,10 @@ def _render_violations(standard_name: str, violations: list, console_obj) -> Non def _render_architecture_violations(audit_result: dict, console_obj) -> None: """Special renderer for architecture standard — reads from results['checks'].""" - results = audit_result.get('results', {}) - arch_result = results.get('architecture', {}) - checks = arch_result.get('checks', []) - failed_checks = [c for c in checks if not c.get('passed', False)] + results = audit_result.get("results", {}) + arch_result = results.get("architecture", {}) + checks = arch_result.get("checks", []) + failed_checks = [c for c in checks if not c.get("passed", False)] if not failed_checks: return @@ -111,14 +113,14 @@ def _render_architecture_violations(audit_result: dict, console_obj) -> None: console_obj.print(f" [bold red]ARCHITECTURE VIOLATIONS ({len(failed_checks)} missing):[/bold red]") # Group by type for clarity (match both "Dir:" and "Directory:" prefixes) - missing_dirs = [c for c in failed_checks if 'Dir:' in c.get('name', '') or 'Directory:' in c.get('name', '')] - missing_files = [c for c in failed_checks if 'File:' in c.get('name', '')] + missing_dirs = [c for c in failed_checks if "Dir:" in c.get("name", "") or "Directory:" in c.get("name", "")] + missing_files = [c for c in failed_checks if "File:" in c.get("name", "")] other_failures = [c for c in failed_checks if c not in missing_dirs and c not in missing_files] if missing_dirs: console_obj.print(f" [dim]Missing directories ({len(missing_dirs)}):[/dim]") for check in missing_dirs[:5]: - name = check.get('name', '').replace('Dir: ', '').replace('Directory: ', '') + name = check.get("name", "").replace("Dir: ", "").replace("Directory: ", "") console_obj.print(f" [red]✗[/red] {name}") if len(missing_dirs) > 5: console_obj.print(f" [dim]... and {len(missing_dirs) - 5} more[/dim]") @@ -126,7 +128,7 @@ def _render_architecture_violations(audit_result: dict, console_obj) -> None: if missing_files: console_obj.print(f" [dim]Missing files ({len(missing_files)}):[/dim]") for check in missing_files[:5]: - name = check.get('name', '').replace('File: ', '') + name = check.get("name", "").replace("File: ", "") console_obj.print(f" [red]✗[/red] {name}") if len(missing_files) > 5: console_obj.print(f" [dim]... and {len(missing_files) - 5} more[/dim]") @@ -138,18 +140,18 @@ def _render_architecture_violations(audit_result: dict, console_obj) -> None: def _render_type_errors(audit_result: dict, console_obj) -> None: """Special renderer for type errors (pyright diagnostics) — different structure.""" - type_errors = audit_result.get('type_errors', 0) - type_error_files = audit_result.get('type_error_files', []) - files_checked = audit_result.get('files_checked', 0) + type_errors = audit_result.get("type_errors", 0) + type_error_files = audit_result.get("type_error_files", []) + files_checked = audit_result.get("files_checked", 0) if type_errors > 0: console_obj.print() console_obj.print(f" [bold red]TYPE ERRORS ({type_errors} errors):[/bold red]") for file_result in type_error_files[:10]: # Top 10 files - if file_result.get('errors', 0) > 0: + if file_result.get("errors", 0) > 0: console_obj.print(f" [red]✗[/red] {file_result['file']} [dim]({file_result['errors']} errors)[/dim]") - for diag in file_result.get('diagnostics', [])[:3]: # Top 3 per file - msg = diag.get('message', '')[:60] + for diag in file_result.get("diagnostics", [])[:3]: # Top 3 per file + msg = diag.get("message", "")[:60] console_obj.print(f" [dim]L{diag.get('line', '?')}: {msg}[/dim]") elif files_checked > 0: console_obj.print(" [green]✓[/green] No type errors") @@ -157,21 +159,23 @@ def _render_type_errors(audit_result: dict, console_obj) -> None: def _render_test_map(audit_result: dict, console_obj) -> None: """Show custom function test coverage summary (informational, not scored).""" - test_map = audit_result.get('test_map') + test_map = audit_result.get("test_map") if not test_map: return - total = test_map.get('total_functions', 0) + total = test_map.get("total_functions", 0) if total == 0: return - tested = test_map.get('tested_functions', 0) - branch_name = test_map.get('branch', '') - console_obj.print(f" [dim]Custom Test Opportunities: {total} public functions, {tested} tested." - f" Run: drone @seedgo test_map @{branch_name}[/dim]") + tested = test_map.get("tested_functions", 0) + branch_name = test_map.get("branch", "") + console_obj.print( + f" [dim]Custom Test Opportunities: {total} public functions, {tested} tested." + f" Run: drone @seedgo test_map @{branch_name}[/dim]" + ) def _render_deprecated_patterns(audit_result: dict, console_obj) -> None: """Special renderer for deprecated patterns — different structure.""" - deprecated_patterns = audit_result.get('deprecated_patterns', []) + deprecated_patterns = audit_result.get("deprecated_patterns", []) if not deprecated_patterns: return @@ -187,13 +191,16 @@ def _render_deprecated_patterns(audit_result: dict, console_obj) -> None: # PUBLIC API # ============================================================================= -def print_branch_summary(audit_result: Dict, system_averages: Dict[str, int] | None = None, overall_system_avg: int = 0): + +def print_branch_summary( + audit_result: Dict, system_averages: Dict[str, int] | None = None, overall_system_avg: int = 0 +): """Print summary for a single branch - always shows full details (audit = comprehensive)""" - json_handler.log_operation("audit_display_rendered", {"branch": audit_result['branch']['name']}) - branch = audit_result['branch'] - scores = audit_result['scores'] - avg = audit_result['average'] - files_checked = audit_result.get('files_checked', 0) + json_handler.log_operation("audit_display_rendered", {"branch": audit_result["branch"]["name"]}) + branch = audit_result["branch"] + scores = audit_result["scores"] + avg = audit_result["average"] + files_checked = audit_result.get("files_checked", 0) # Branch header - always show files checked console.print() @@ -222,12 +229,12 @@ def print_branch_summary(audit_result: Dict, system_averages: Dict[str, int] | N # Display violation details for any standard with violations rendered_standards = set() - for standard_name, score in sorted(audit_result.get('scores', {}).items()): + for standard_name, score in sorted(audit_result.get("scores", {}).items()): if score >= 100: continue - violations_key = f'{standard_name}_violations' + violations_key = f"{standard_name}_violations" violations = audit_result.get(violations_key, []) - if standard_name == 'architecture': + if standard_name == "architecture": _render_architecture_violations(audit_result, console) rendered_standards.add(standard_name) elif violations: @@ -235,8 +242,8 @@ def print_branch_summary(audit_result: Dict, system_averages: Dict[str, int] | N rendered_standards.add(standard_name) else: # Branch-level checkers: no violations list, read from results[standard]['checks'] - result_data = audit_result.get('results', {}).get(standard_name, {}) - failed_checks = [c for c in result_data.get('checks', []) if not c.get('passed', True)] + result_data = audit_result.get("results", {}).get(standard_name, {}) + failed_checks = [c for c in result_data.get("checks", []) if not c.get("passed", True)] if failed_checks: formatted = _format_standard_name(standard_name) console.print(f" [red]└─ {formatted} issues:[/red]") @@ -246,9 +253,9 @@ def print_branch_summary(audit_result: Dict, system_averages: Dict[str, int] | N # Catch any violation lists not represented in scores (defensive) for key in audit_result: - if not key.endswith('_violations'): + if not key.endswith("_violations"): continue - standard_name = key.removesuffix('_violations') + standard_name = key.removesuffix("_violations") if standard_name in rendered_standards: continue violations = audit_result[key] @@ -268,15 +275,15 @@ def print_branch_summary(audit_result: Dict, system_averages: Dict[str, int] | N def print_system_summary(audit_results: List[Dict]): """Print system-wide summary with standard averages""" total_branches = len(audit_results) - avg_compliance = int(sum(r['average'] for r in audit_results) / total_branches) if total_branches else 0 + avg_compliance = int(sum(r["average"] for r in audit_results) / total_branches) if total_branches else 0 - excellent = sum(1 for r in audit_results if r['average'] >= 90) - good = sum(1 for r in audit_results if 75 <= r['average'] < 90) - needs_work = sum(1 for r in audit_results if r['average'] < 75) + excellent = sum(1 for r in audit_results if r["average"] >= 90) + good = sum(1 for r in audit_results if 75 <= r["average"] < 90) + needs_work = sum(1 for r in audit_results if r["average"] < 75) # Calculate total type errors - total_type_errors = sum(r.get('type_errors', 0) for r in audit_results) - branches_with_type_errors = sum(1 for r in audit_results if r.get('type_errors', 0) > 0) + total_type_errors = sum(r.get("type_errors", 0) for r in audit_results) + branches_with_type_errors = sum(1 for r in audit_results if r.get("type_errors", 0) > 0) console.print() console.print("─" * 70) @@ -295,13 +302,10 @@ def print_system_summary(audit_results: List[Dict]): # Calculate standard averages standard_scores = defaultdict(list) for result in audit_results: - for standard, score in result['scores'].items(): + for standard, score in result["scores"].items(): standard_scores[standard].append(score) - standard_averages = { - standard: int(sum(scores) / len(scores)) - for standard, scores in standard_scores.items() - } + standard_averages = {standard: int(sum(scores) / len(scores)) for standard, scores in standard_scores.items()} # Print standard averages section console.print("[bold]STANDARD AVERAGES:[/bold]") @@ -329,7 +333,7 @@ def print_system_summary(audit_results: List[Dict]): if top_issues: console.print("[bold]TOP IMPROVEMENT AREAS:[/bold]") for i, (standard, avg_score) in enumerate(top_issues, 1): - branches_failing = sum(1 for r in audit_results if r['scores'].get(standard, 100) < 75) + branches_failing = sum(1 for r in audit_results if r["scores"].get(standard, 100) < 75) console.print(f" {i}. {standard.title():15} (avg: {avg_score}%, {branches_failing} branches <75%)") console.print("─" * 70) @@ -349,7 +353,7 @@ def print_bypass_audit(bypass_results: List[Dict]): # Group by branch by_branch = defaultdict(list) for result in bypass_results: - by_branch[result['branch']].append(result) + by_branch[result["branch"]].append(result) removable_count = 0 total_count = len(bypass_results) @@ -358,18 +362,18 @@ def print_bypass_audit(bypass_results: List[Dict]): console.print(f"[bold cyan]{branch_name}[/bold cyan] ({len(results)} bypass{'es' if len(results) > 1 else ''})") for r in results: - file_name = r['file'] - standard = r['standard'] - reason = r['reason'] - status = r['status'] + file_name = r["file"] + standard = r["standard"] + reason = r["reason"] + status = r["status"] - if status == 'file_missing': + if status == "file_missing": console.print(f" [red]✗[/red] {file_name} [{standard}]") error("FILE MISSING - bypass can be removed") removable_count += 1 - elif status == 'checked': - score = r['current_score'] - would_pass = r['would_pass'] + elif status == "checked": + score = r["current_score"] + would_pass = r["would_pass"] if would_pass: console.print(f" [green]✓[/green] {file_name} [{standard}] → {score}%") @@ -379,9 +383,9 @@ def print_bypass_audit(bypass_results: List[Dict]): else: console.print(f" [yellow]⚠[/yellow] {file_name} [{standard}] → {score}%") console.print(f" [dim]Reason: {reason}[/dim]") - for v in r.get('violations', [])[:3]: + for v in r.get("violations", [])[:3]: console.print(f" [dim]• {v}[/dim]") - elif status == 'error': + elif status == "error": console.print(f" [red]✗[/red] {file_name} [{standard}]") console.print(f" [red]Error: {r.get('error', 'Unknown')}[/red]") else: @@ -394,7 +398,9 @@ def print_bypass_audit(bypass_results: List[Dict]): console.print("─" * 70) console.print("[bold]BYPASS SUMMARY:[/bold]") console.print(f" Total bypasses: {total_count}") - console.print(f" Can be removed: {removable_count} [green]{'← clean these up!' if removable_count > 0 else ''}[/green]") + console.print( + f" Can be removed: {removable_count} [green]{'← clean these up!' if removable_count > 0 else ''}[/green]" + ) console.print(f" Still needed: {total_count - removable_count}") console.print("─" * 70) console.print() diff --git a/src/aipass/seedgo/apps/handlers/audit/branch_audit.py b/src/aipass/seedgo/apps/handlers/audit/branch_audit.py index 2e1d53ed..8f0850ba 100644 --- a/src/aipass/seedgo/apps/handlers/audit/branch_audit.py +++ b/src/aipass/seedgo/apps/handlers/audit/branch_audit.py @@ -15,6 +15,7 @@ from aipass.seedgo.apps.handlers.bypass import ignore_handler from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.test_map.function_scanner import scan_branch + def discover_checkers(pack_path: Path | None = None) -> Dict[str, Any]: """Auto-discover all *_check.py modules from a pack directory. @@ -39,14 +40,19 @@ def discover_checkers(pack_path: Path | None = None) -> Dict[str, Any]: checkers[name] = mod return checkers + def _collect_py_files(branch_path: Path) -> List[Dict[str, str]]: """Collect auditable .py files from apps/, respecting ignore patterns.""" apps_dir = branch_path / "apps" if not apps_dir.exists(): return [] ign = ignore_handler.get_audit_ignore_patterns() - return [{"file": str(f), "name": f.name} for f in apps_dir.rglob("*.py") - if f.name != "__init__.py" and not any(p in str(f).lower() for p in ign)] + return [ + {"file": str(f), "name": f.name} + for f in apps_dir.rglob("*.py") + if f.name != "__init__.py" and not any(p in str(f).lower() for p in ign) + ] + def _extract_branch_level_violations(result: dict) -> list: """Extract per-file violations from a branch-level checker result. @@ -88,8 +94,7 @@ def _run_all_files(checker, name: str, files: List[Dict], bypass_rules: list) -> logger.info("Checker %s failed on %s", name, fi["name"]) continue score, checks = r.get("score", 0), r.get("checks", []) - if checks and not any(w in c.get("message", "").lower() for c in checks - for w in ("skipped", "not applicable")): + if checks and not any(w in c.get("message", "").lower() for c in checks for w in ("skipped", "not applicable")): scores.append(score) # Collect violations from ANY file with failing checks, regardless of # overall pass/fail. The old gate (not r["passed"]) hid violations @@ -97,8 +102,7 @@ def _run_all_files(checker, name: str, files: List[Dict], bypass_rules: list) -> failed = [c for c in checks if not c.get("passed", False)] if failed: msgs = [c.get("message", "Unknown") for c in failed] - v = {"file": fi["name"], "path": fi["file"], "score": score, "issues": msgs, - "message": "; ".join(msgs)} + v = {"file": fi["name"], "path": fi["file"], "score": score, "issues": msgs, "message": "; ".join(msgs)} violations.append(v) return violations, scores @@ -163,7 +167,12 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N for vi in v: all_failed.extend({"name": name, "passed": False, "message": iss} for iss in vi.get("issues", [])) if all_failed: - results[name] = {"passed": avg_score >= 75, "checks": all_failed, "score": avg_score, "standard": name.upper()} + results[name] = { + "passed": avg_score >= 75, + "checks": all_failed, + "score": avg_score, + "standard": name.upper(), + } # Dynamic post-checks: call check_branch_post() on any checker that implements it for name, checker in checkers.items(): @@ -182,8 +191,15 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N # Deprecated DOCUMENTS/ directory check deprecated = [] if (branch_path / "DOCUMENTS").is_dir(): - deprecated.append({"type": "directory", "old": "DOCUMENTS/", "new": "docs/", - "path": str(branch_path / "DOCUMENTS"), "message": "Rename DOCUMENTS/ to docs/"}) + deprecated.append( + { + "type": "directory", + "old": "DOCUMENTS/", + "new": "docs/", + "path": str(branch_path / "DOCUMENTS"), + "message": "Rename DOCUMENTS/ to docs/", + } + ) # Custom function coverage scan (informational, not scored) try: @@ -193,10 +209,17 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N test_map_result = None diag_result = results.get("diagnostics", {}) - output = {"branch": branch, "results": results, "scores": scores, "average": avg, - "deprecated_patterns": deprecated, "files_checked": len(all_files), - "type_errors": diag_result.get("total_errors", 0), "type_error_files": diag_result.get("results", []), - "test_map": test_map_result} + output = { + "branch": branch, + "results": results, + "scores": scores, + "average": avg, + "deprecated_patterns": deprecated, + "files_checked": len(all_files), + "type_errors": diag_result.get("total_errors", 0), + "type_error_files": diag_result.get("results", []), + "test_map": test_map_result, + } for name in checkers: output[f"{name}_violations"] = all_violations.get(name, []) return output diff --git a/src/aipass/seedgo/apps/handlers/audit/discovery.py b/src/aipass/seedgo/apps/handlers/audit/discovery.py index 7d46fe0d..1bc3bd31 100644 --- a/src/aipass/seedgo/apps/handlers/audit/discovery.py +++ b/src/aipass/seedgo/apps/handlers/audit/discovery.py @@ -31,6 +31,7 @@ from aipass.seedgo.apps.handlers.json import json_handler # PRIVATE BRANCH DETECTION # ============================================================================= + def _is_branch_private(branch_name: str) -> bool: """Check if branch is in the private registry.""" registry_path = _find_registry() @@ -38,7 +39,7 @@ def _is_branch_private(branch_name: str) -> bool: if not priv_path or not priv_path.exists(): return False try: - with open(priv_path, 'r', encoding='utf-8') as f: + with open(priv_path, "r", encoding="utf-8") as f: registry = json.load(f) for branch in registry.get("branches", []): if branch.get("name", "").upper() == branch_name.upper(): @@ -52,6 +53,7 @@ def _is_branch_private(branch_name: str) -> bool: # PUBLIC API # ============================================================================= + def _find_registry() -> Path: """ Find *_REGISTRY.json by walking up from CWD first, then from __file__. @@ -91,19 +93,19 @@ def discover_branches(include_private: bool = False) -> List[Dict[str, str]]: return branches try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry_data = json.load(f) registry_dir = registry_path.parent - raw_branches = registry_data.get('branches', []) + raw_branches = registry_data.get("branches", []) # Handle both list format and dict format (keyed by name) if isinstance(raw_branches, dict): raw_branches = list(raw_branches.values()) for branch in raw_branches: - branch_name = branch.get('name', '') - raw_path = branch.get('path', '') + branch_name = branch.get("name", "") + raw_path = branch.get("path", "") branch_path = Path(raw_path) # Resolve relative paths against registry location @@ -123,17 +125,13 @@ def discover_branches(include_private: bool = False) -> List[Dict[str, str]]: entry_file = branch_entry if entry_file: - branches.append({ - 'name': branch_name, - 'path': str(branch_path), - 'entry_file': str(entry_file) - }) + branches.append({"name": branch_name, "path": str(branch_path), "entry_file": str(entry_file)}) if not include_private: - branches = [b for b in branches if not _is_branch_private(b['name'])] + branches = [b for b in branches if not _is_branch_private(b["name"])] json_handler.log_operation("branches_discovered", {"count": len(branches)}) - return sorted(branches, key=lambda x: x['name']) + return sorted(branches, key=lambda x: x["name"]) except (json.JSONDecodeError, IOError): logger.info("Cannot read registry for branch discovery") @@ -156,8 +154,8 @@ def check_internal_access(branch_name: str) -> bool: _branch_path = None _priv_branches = discover_branches(include_private=True) for _b in _priv_branches: - if _b['name'].upper() == branch_name.upper(): - _branch_path = Path(_b['path']) + if _b["name"].upper() == branch_name.upper(): + _branch_path = Path(_b["path"]) break if _branch_path is None: diff --git a/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py b/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py index 70ed9747..67ea9e2b 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py +++ b/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py @@ -29,11 +29,7 @@ from aipass.seedgo.apps.handlers.json import json_handler # ============================================================================= BYPASS_TEMPLATE = { - "metadata": { - "version": "1.0.0", - "created": "", - "description": "Standards bypass configuration for this branch" - }, + "metadata": {"version": "1.0.0", "created": "", "description": "Standards bypass configuration for this branch"}, "bypass": [], "notes": { "usage": "Add entries to 'bypass' list to exclude specific violations", @@ -42,16 +38,16 @@ BYPASS_TEMPLATE = { "standard": "cli", "lines": [146, 177], "pattern": "if __name__ == '__main__'", - "reason": "Circular dependency - logger cannot import CLI" + "reason": "Circular dependency - logger cannot import CLI", }, "fields": { "file": "Relative path from branch root (required)", "standard": "Standard name: cli, imports, naming, etc. (required)", "lines": "Optional - specific line numbers to bypass", "pattern": "Optional - pattern to match (e.g. 'if __name__')", - "reason": "Required - why this bypass exists" - } - } + "reason": "Required - why this bypass exists", + }, + }, } @@ -59,6 +55,7 @@ BYPASS_TEMPLATE = { # REGISTRY DISCOVERY # ============================================================================= + def _find_registry() -> Path: """Find AIPASS_REGISTRY.json by walking up from this file's location.""" current = Path(__file__).resolve().parent @@ -76,6 +73,7 @@ REGISTRY_PATH = _find_registry() # PUBLIC API # ============================================================================= + def get_branch_from_path(file_path: str) -> Optional[Dict[str, Any]]: """ Detect which branch a file belongs to using AIPASS_REGISTRY. @@ -91,19 +89,17 @@ def get_branch_from_path(file_path: str) -> Optional[Dict[str, Any]]: logger.warning("[bypass_handler] AIPASS_REGISTRY.json not found") return None - with open(REGISTRY_PATH, 'r', encoding='utf-8') as f: + with open(REGISTRY_PATH, "r", encoding="utf-8") as f: registry = json.load(f) file_path = str(Path(file_path).resolve()) # Sort branches by path length (longest first) to match most specific - branches = sorted(registry.get('branches', []), - key=lambda b: len(b.get('path', '')), - reverse=True) + branches = sorted(registry.get("branches", []), key=lambda b: len(b.get("path", "")), reverse=True) for branch in branches: - branch_path = branch.get('path', '') - if file_path.startswith(branch_path + '/') or file_path == branch_path: + branch_path = branch.get("path", "") + if file_path.startswith(branch_path + "/") or file_path == branch_path: return branch return None @@ -134,7 +130,7 @@ def ensure_seedgo_config(branch_path: str) -> Path: template = BYPASS_TEMPLATE.copy() template["metadata"]["created"] = datetime.now().isoformat() - with open(bypass_file, 'w', encoding='utf-8') as f: + with open(bypass_file, "w", encoding="utf-8") as f: json.dump(template, f, indent=2) logger.info(f"[bypass_handler] Created {bypass_file}") @@ -159,9 +155,9 @@ def load_bypass_rules(branch_path: str) -> List[Dict[str, Any]]: try: if bypass_file.exists(): - with open(bypass_file, 'r', encoding='utf-8') as f: + with open(bypass_file, "r", encoding="utf-8") as f: config = json.load(f) - rules = config.get('bypass', []) + rules = config.get("bypass", []) json_handler.log_operation("bypass_rules_loaded", {"branch": branch_path, "count": len(rules)}) return rules except Exception as e: @@ -170,8 +166,7 @@ def load_bypass_rules(branch_path: str) -> List[Dict[str, Any]]: return [] -def is_bypassed(file_path: str, branch_path: str, standard: str, - line: Optional[int], bypass_rules: List[Dict]) -> bool: +def is_bypassed(file_path: str, branch_path: str, standard: str, line: Optional[int], bypass_rules: List[Dict]) -> bool: """ Check if a specific violation should be bypassed. @@ -194,8 +189,8 @@ def is_bypassed(file_path: str, branch_path: str, standard: str, for rule in bypass_rules: # Check if rule matches this file and standard - rule_file = rule.get('file', '') - rule_standard = rule.get('standard', '') + rule_file = rule.get("file", "") + rule_standard = rule.get("standard", "") if rule_file and rule_file != rel_path: continue @@ -203,7 +198,7 @@ def is_bypassed(file_path: str, branch_path: str, standard: str, continue # Check line-specific bypass - rule_lines = rule.get('lines', []) + rule_lines = rule.get("lines", []) if rule_lines and line is not None: if line in rule_lines: return True @@ -212,5 +207,3 @@ def is_bypassed(file_path: str, branch_path: str, standard: str, return True return False - - diff --git a/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py b/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py index 02bef8cf..9594c2aa 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py +++ b/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py @@ -28,9 +28,9 @@ from aipass.seedgo.apps.handlers.json import json_handler # Template files that exist in spawn template but aren't required in branches # Used by architecture_check.py when checking template baseline TEMPLATE_IGNORE_PATTERNS = [ - '.gitkeep', # Git placeholder files - not actual requirements - 'notepad.md', # Optional scratch file - '.gitignore', # Optional - branches inherit from root + ".gitkeep", # Git placeholder files - not actual requirements + "notepad.md", # Optional scratch file + ".gitignore", # Optional - branches inherit from root ] # ============================================= @@ -40,15 +40,15 @@ TEMPLATE_IGNORE_PATTERNS = [ # Patterns for files/directories to skip during audit # Used by standards_audit.py AUDIT_IGNORE_PATTERNS = [ - '__pycache__', - '/.archive/', # Temp archive directories - '/.backup/', # Temp backup directories - '/backups/', # Actual backup storage (backup/backups/) - '/artifacts/', # Build artifacts - '.temp', # Temp files - '.old', # Old files - '/deprecated/', # Deprecated code - '/test/' # Test directories + "__pycache__", + "/.archive/", # Temp archive directories + "/.backup/", # Temp backup directories + "/backups/", # Actual backup storage (backup/backups/) + "/artifacts/", # Build artifacts + ".temp", # Temp files + ".old", # Old files + "/deprecated/", # Deprecated code + "/test/", # Test directories ] # ============================================= @@ -57,15 +57,13 @@ AUDIT_IGNORE_PATTERNS = [ # Patterns that have been removed from the system # Used by standards_verify.py to detect leftover usage -DEPRECATED_PATTERNS = { - "--verbose": "removed from audit (v0.4.0)", - "--full": "removed from audit (v0.4.0)" -} +DEPRECATED_PATTERNS = {"--verbose": "removed from audit (v0.4.0)", "--full": "removed from audit (v0.4.0)"} # ============================================= # HELPER FUNCTIONS # ============================================= + def get_template_ignore_patterns() -> List[str]: """Return list of template files to skip in architecture baseline check diff --git a/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py b/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py index 55eceb69..4b98c7e0 100644 --- a/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py +++ b/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py @@ -45,6 +45,7 @@ HANDLERS_DIR = DIAGNOSTICS_DIR.parent # FILE / DIRECTORY HELPERS (used by runners) # ============================================= + def should_ignore_file(file_path: str, ignore_patterns: List[str]) -> bool: """Check if file should be ignored based on audit patterns""" for pattern in ignore_patterns: @@ -72,29 +73,20 @@ def check_file(file_path: str) -> Dict: if not path.exists(): return { - 'file': str(file_path), - 'errors': 0, - 'warnings': 0, - 'diagnostics': [], - 'error': f'File not found: {file_path}' + "file": str(file_path), + "errors": 0, + "warnings": 0, + "diagnostics": [], + "error": f"File not found: {file_path}", } - if not path.suffix == '.py': - return { - 'file': str(file_path), - 'errors': 0, - 'warnings': 0, - 'diagnostics': [], - 'skipped': 'Not a Python file' - } + if not path.suffix == ".py": + return {"file": str(file_path), "errors": 0, "warnings": 0, "diagnostics": [], "skipped": "Not a Python file"} try: # Run pyright with JSON output result = subprocess.run( - ['python3', '-m', 'pyright', '--outputjson', str(path)], - capture_output=True, - text=True, - timeout=30 + ["python3", "-m", "pyright", "--outputjson", str(path)], capture_output=True, text=True, timeout=30 ) # Parse JSON output @@ -103,56 +95,41 @@ def check_file(file_path: str) -> Dict: except json.JSONDecodeError: logger.info("Failed to parse pyright JSON output for %s", file_path) return { - 'file': str(file_path), - 'errors': 0, - 'warnings': 0, - 'diagnostics': [], - 'error': f'Failed to parse pyright output: {result.stderr or result.stdout}' + "file": str(file_path), + "errors": 0, + "warnings": 0, + "diagnostics": [], + "error": f"Failed to parse pyright output: {result.stderr or result.stdout}", } diagnostics = [] errors = 0 warnings = 0 - for diag in output.get('generalDiagnostics', []): - severity = diag.get('severity', 'error') - if severity == 'error': + for diag in output.get("generalDiagnostics", []): + severity = diag.get("severity", "error") + if severity == "error": errors += 1 - elif severity == 'warning': + elif severity == "warning": warnings += 1 - diagnostics.append({ - 'line': diag.get('range', {}).get('start', {}).get('line', 0) + 1, - 'severity': severity, - 'message': diag.get('message', 'Unknown error'), - 'rule': diag.get('rule', '') - }) + diagnostics.append( + { + "line": diag.get("range", {}).get("start", {}).get("line", 0) + 1, + "severity": severity, + "message": diag.get("message", "Unknown error"), + "rule": diag.get("rule", ""), + } + ) - return { - 'file': str(file_path), - 'errors': errors, - 'warnings': warnings, - 'diagnostics': diagnostics - } + return {"file": str(file_path), "errors": errors, "warnings": warnings, "diagnostics": diagnostics} except subprocess.TimeoutExpired: logger.info("Pyright timed out for %s", file_path) - return { - 'file': str(file_path), - 'errors': 0, - 'warnings': 0, - 'diagnostics': [], - 'error': 'Pyright timed out' - } + return {"file": str(file_path), "errors": 0, "warnings": 0, "diagnostics": [], "error": "Pyright timed out"} except Exception as e: logger.info("Pyright check failed for %s: %s", file_path, e) - return { - 'file': str(file_path), - 'errors': 0, - 'warnings': 0, - 'diagnostics': [], - 'error': str(e) - } + return {"file": str(file_path), "errors": 0, "warnings": 0, "diagnostics": [], "error": str(e)} def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: @@ -176,21 +153,21 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: if not path.exists(): return { - 'total_files': 0, - 'files_with_errors': 0, - 'total_errors': 0, - 'total_warnings': 0, - 'results': [], - 'error': f'Directory not found: {directory}' + "total_files": 0, + "files_with_errors": 0, + "total_errors": 0, + "total_warnings": 0, + "results": [], + "error": f"Directory not found: {directory}", } # Run pyright on entire directory for efficiency try: result = subprocess.run( - ['python3', '-m', 'pyright', '--outputjson', str(path)], + ["python3", "-m", "pyright", "--outputjson", str(path)], capture_output=True, text=True, - timeout=300 # 5 minutes for full directory + timeout=300, # 5 minutes for full directory ) try: @@ -198,12 +175,12 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: except json.JSONDecodeError: logger.info("Failed to parse pyright JSON output for directory %s", directory) return { - 'total_files': 0, - 'files_with_errors': 0, - 'total_errors': 0, - 'total_warnings': 0, - 'results': [], - 'error': 'Failed to parse pyright output' + "total_files": 0, + "files_with_errors": 0, + "total_errors": 0, + "total_warnings": 0, + "results": [], + "error": "Failed to parse pyright output", } # Get ignore patterns @@ -212,65 +189,62 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: # Group diagnostics by file (filtering ignored files) file_diagnostics = {} - for diag in output.get('generalDiagnostics', []): - file_path = diag.get('file', 'unknown') + for diag in output.get("generalDiagnostics", []): + file_path = diag.get("file", "unknown") # Skip files matching ignore patterns if should_ignore_file(file_path, ignore_patterns): continue if file_path not in file_diagnostics: - file_diagnostics[file_path] = { - 'file': file_path, - 'errors': 0, - 'warnings': 0, - 'diagnostics': [] + file_diagnostics[file_path] = {"file": file_path, "errors": 0, "warnings": 0, "diagnostics": []} + + severity = diag.get("severity", "error") + if severity == "error": + file_diagnostics[file_path]["errors"] += 1 + elif severity == "warning": + file_diagnostics[file_path]["warnings"] += 1 + + file_diagnostics[file_path]["diagnostics"].append( + { + "line": diag.get("range", {}).get("start", {}).get("line", 0) + 1, + "severity": severity, + "message": diag.get("message", "Unknown error"), + "rule": diag.get("rule", ""), } - - severity = diag.get('severity', 'error') - if severity == 'error': - file_diagnostics[file_path]['errors'] += 1 - elif severity == 'warning': - file_diagnostics[file_path]['warnings'] += 1 - - file_diagnostics[file_path]['diagnostics'].append({ - 'line': diag.get('range', {}).get('start', {}).get('line', 0) + 1, - 'severity': severity, - 'message': diag.get('message', 'Unknown error'), - 'rule': diag.get('rule', '') - }) + ) results = list(file_diagnostics.values()) - total_errors = sum(r['errors'] for r in results) - total_warnings = sum(r['warnings'] for r in results) - files_with_errors = len([r for r in results if r['errors'] > 0]) + total_errors = sum(r["errors"] for r in results) + total_warnings = sum(r["warnings"] for r in results) + files_with_errors = len([r for r in results if r["errors"] > 0]) return { - 'total_files': output.get('summary', {}).get('filesAnalyzed', 0), - 'files_with_errors': files_with_errors, - 'total_errors': total_errors, - 'total_warnings': total_warnings, - 'results': sorted(results, key=lambda x: x['errors'], reverse=True) + "total_files": output.get("summary", {}).get("filesAnalyzed", 0), + "files_with_errors": files_with_errors, + "total_errors": total_errors, + "total_warnings": total_warnings, + "results": sorted(results, key=lambda x: x["errors"], reverse=True), } except subprocess.TimeoutExpired: logger.info("Pyright timed out for directory %s", directory) return { - 'total_files': 0, - 'files_with_errors': 0, - 'total_errors': 0, - 'total_warnings': 0, - 'results': [], - 'error': 'Pyright timed out (directory too large?)' + "total_files": 0, + "files_with_errors": 0, + "total_errors": 0, + "total_warnings": 0, + "results": [], + "error": "Pyright timed out (directory too large?)", } except Exception as e: logger.info("Pyright directory check failed for %s: %s", directory, e) return { - 'total_files': 0, - 'files_with_errors': 0, - 'total_errors': 0, - 'total_warnings': 0, - 'results': [], - 'error': str(e) + "total_files": 0, + "files_with_errors": 0, + "total_errors": 0, + "total_warnings": 0, + "results": [], + "error": str(e), } @@ -278,6 +252,7 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: # RUNNER DISCOVERY & DISPATCH # ============================================= + def _discover_pack_configs() -> List[Dict]: """ Find diagnostics.json files in handlers/*_standards/ directories. @@ -296,21 +271,17 @@ def _discover_pack_configs() -> List[Dict]: for subdir in sorted(HANDLERS_DIR.iterdir()): if not subdir.is_dir(): continue - if not subdir.name.endswith('_standards'): + if not subdir.name.endswith("_standards"): continue - config_file = subdir / 'diagnostics.json' + config_file = subdir / "diagnostics.json" if not config_file.exists(): continue try: - with open(config_file, 'r', encoding='utf-8') as f: + with open(config_file, "r", encoding="utf-8") as f: config = json.load(f) - configs.append({ - 'pack_name': subdir.name, - 'pack_path': subdir, - 'config': config - }) + configs.append({"pack_name": subdir.name, "pack_path": subdir, "config": config}) except (json.JSONDecodeError, IOError): logger.info("Skipped malformed diagnostics config: %s", config_file) continue @@ -332,12 +303,12 @@ def _get_enabled_runners_from_config(config: Dict) -> List[str]: List of enabled runner names (e.g. ["python"]) """ enabled = [] - runners = config.get('runners', {}) + runners = config.get("runners", {}) for name, value in runners.items(): if isinstance(value, bool) and value: enabled.append(name) - elif isinstance(value, dict) and value.get('enabled', False): + elif isinstance(value, dict) and value.get("enabled", False): enabled.append(name) return enabled @@ -376,7 +347,7 @@ def _run_runner(runner_name: str, branch_path: str, bypass_rules: Optional[list] # Check if file has actual content (not just whitespace/comments) try: - content = runner_path.read_text(encoding='utf-8').strip() + content = runner_path.read_text(encoding="utf-8").strip() if not content: continue except IOError: @@ -392,7 +363,7 @@ def _run_runner(runner_name: str, branch_path: str, bypass_rules: Optional[list] continue # Call check_branch if it exists - check_fn = getattr(runner_module, 'check_branch', None) + check_fn = getattr(runner_module, "check_branch", None) if check_fn is None: continue @@ -409,6 +380,7 @@ def _run_runner(runner_name: str, branch_path: str, bypass_rules: Optional[list] # BRANCH-LEVEL CHECK (audit pipeline entry) # ============================================= + def check_branch(branch_path: str, bypass_rules: Optional[list] = None) -> Dict: """ Run diagnostics on a branch by discovering pack configs and dispatching runners. @@ -430,13 +402,13 @@ def check_branch(branch_path: str, bypass_rules: Optional[list] = None) -> Dict: if not apps_path.exists(): return { - 'passed': True, - 'score': 100, - 'total_files': 0, - 'total_errors': 0, - 'checks': [], - 'standard': 'DIAGNOSTICS', - 'error': f'No apps/ directory found in {branch_path}' + "passed": True, + "score": 100, + "total_files": 0, + "total_errors": 0, + "checks": [], + "standard": "DIAGNOSTICS", + "error": f"No apps/ directory found in {branch_path}", } # Step 1: Discover pack configs @@ -445,7 +417,7 @@ def check_branch(branch_path: str, bypass_rules: Optional[list] = None) -> Dict: # Step 2: Collect enabled runners across all packs all_runners = [] for pack_info in pack_configs: - runners = _get_enabled_runners_from_config(pack_info['config']) + runners = _get_enabled_runners_from_config(pack_info["config"]) for runner in runners: if runner not in all_runners: all_runners.append(runner) @@ -467,53 +439,55 @@ def check_branch(branch_path: str, bypass_rules: Optional[list] = None) -> Dict: if runner_result is not None: runner_executed = True - total_errors += runner_result.get('total_errors', 0) - total_warnings += runner_result.get('total_warnings', 0) - total_files += runner_result.get('total_files', 0) - merged_checks.extend(runner_result.get('checks', [])) - all_results.extend(runner_result.get('results', [])) + total_errors += runner_result.get("total_errors", 0) + total_warnings += runner_result.get("total_warnings", 0) + total_files += runner_result.get("total_files", 0) + merged_checks.extend(runner_result.get("checks", [])) + all_results.extend(runner_result.get("results", [])) # If runner doesn't exist or is empty, skip gracefully # Step 5: If no runner executed, fall back to direct pyright check if not runner_executed: result = check_directory(str(apps_path)) - total_errors = result.get('total_errors', 0) - total_warnings = result.get('total_warnings', 0) - total_files = result.get('total_files', 0) - all_results = result.get('results', []) + total_errors = result.get("total_errors", 0) + total_warnings = result.get("total_warnings", 0) + total_files = result.get("total_files", 0) + all_results = result.get("results", []) # Build checks from file results for file_result in all_results: - if file_result['errors'] > 0: - merged_checks.append({ - 'name': f"Type errors in {Path(file_result['file']).name}", - 'passed': False, - 'message': f"{file_result['errors']} errors" - }) + if file_result["errors"] > 0: + merged_checks.append( + { + "name": f"Type errors in {Path(file_result['file']).name}", + "passed": False, + "message": f"{file_result['errors']} errors", + } + ) # Default passing check if nothing failed if not merged_checks: - merged_checks.append({ - 'name': 'Type check', - 'passed': True, - 'message': f"No type errors ({total_files} files analyzed)" - }) + merged_checks.append( + {"name": "Type check", "passed": True, "message": f"No type errors ({total_files} files analyzed)"} + ) # Calculate score score = 100 if total_errors == 0 else max(0, 100 - (total_errors * 5)) - json_handler.log_operation("diagnostics_run", {"branch": branch_path, "total_errors": total_errors, "runners": len(all_runners)}) + json_handler.log_operation( + "diagnostics_run", {"branch": branch_path, "total_errors": total_errors, "runners": len(all_runners)} + ) return { - 'passed': total_errors == 0, - 'score': score, - 'total_files': total_files, - 'total_errors': total_errors, - 'total_warnings': total_warnings, - 'files_with_errors': len([r for r in all_results if r.get('errors', 0) > 0]), - 'checks': merged_checks, - 'results': all_results, - 'standard': 'DIAGNOSTICS' + "passed": total_errors == 0, + "score": score, + "total_files": total_files, + "total_errors": total_errors, + "total_warnings": total_warnings, + "files_with_errors": len([r for r in all_results if r.get("errors", 0) > 0]), + "checks": merged_checks, + "results": all_results, + "standard": "DIAGNOSTICS", } @@ -522,7 +496,7 @@ def format_summary(results: Dict) -> str: Note: Used by tests/test_diagnostics.py — not called in production audit pipeline. """ - if 'error' in results and results['error']: + if "error" in results and results["error"]: return f"Error: {results['error']}" lines = [] @@ -531,10 +505,10 @@ def format_summary(results: Dict) -> str: lines.append(f"Total errors: {results['total_errors']}") lines.append(f"Total warnings: {results['total_warnings']}") - return '\n'.join(lines) + return "\n".join(lines) -if __name__ == '__main__': +if __name__ == "__main__": # CLI usage: drone @seedgo diagnostics [file_or_directory] if len(sys.argv) < 2: console.print("[yellow]Usage:[/yellow] drone @seedgo diagnostics ") @@ -554,18 +528,18 @@ if __name__ == '__main__': console.print(f" Files analyzed: {result['total_files']}") console.print(f" Files with errors: {result['files_with_errors']}") - if result['total_errors'] > 0: + if result["total_errors"] > 0: console.print(f" [red]Total errors: {result['total_errors']}[/red]") else: console.print(" [green]Total errors: 0[/green]") - if result['total_warnings'] > 0: + if result["total_warnings"] > 0: console.print(f" [yellow]Total warnings: {result['total_warnings']}[/yellow]") # File details - for file_result in result.get('results', [])[:20]: # Top 20 - if file_result['errors'] > 0: + for file_result in result.get("results", [])[:20]: # Top 20 + if file_result["errors"] > 0: console.print() console.print(f"[red]\u2717[/red] {file_result['file']} [dim]({file_result['errors']} errors)[/dim]") - for diag in file_result['diagnostics'][:5]: # Top 5 per file + for diag in file_result["diagnostics"][:5]: # Top 5 per file console.print(f" [dim]Line {diag['line']}:[/dim] {diag['message']}") diff --git a/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py b/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py index 497b6855..21554e37 100644 --- a/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py +++ b/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py @@ -44,27 +44,24 @@ def discover_branches() -> List[Dict]: return branches try: - with open(registry_path, 'r', encoding='utf-8') as f: + with open(registry_path, "r", encoding="utf-8") as f: registry_data = json.load(f) registry_dir = registry_path.parent - for branch in registry_data.get('branches', []): - branch_name = branch.get('name', '') - raw_path = branch.get('path', '') + for branch in registry_data.get("branches", []): + branch_name = branch.get("name", "") + raw_path = branch.get("path", "") branch_path = Path(raw_path) if not branch_path.is_absolute(): branch_path = (registry_dir / branch_path).resolve() if branch_path.exists(): - branches.append({ - 'name': branch_name, - 'path': str(branch_path) - }) + branches.append({"name": branch_name, "path": str(branch_path)}) json_handler.log_operation("diagnostics_discovered", {"count": len(branches)}) - return sorted(branches, key=lambda x: x['name']) + return sorted(branches, key=lambda x: x["name"]) except (json.JSONDecodeError, IOError) as e: logger.warning(f"Error discovering branches: {e}") diff --git a/src/aipass/seedgo/apps/handlers/json/json_handler.py b/src/aipass/seedgo/apps/handlers/json/json_handler.py index 4d615a21..7f1cb1c0 100755 --- a/src/aipass/seedgo/apps/handlers/json/json_handler.py +++ b/src/aipass/seedgo/apps/handlers/json/json_handler.py @@ -14,7 +14,7 @@ import inspect from aipass.prax import logger -_BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> {branch}/ +_BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> {branch}/ _BRANCH_NAME = _BRANCH_ROOT.name JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" @@ -35,7 +35,7 @@ def _get_caller_module_name() -> str: module_name = caller_path.stem # Validate module name - if module_name and not module_name.startswith('_'): + if module_name and not module_name.startswith("_"): return module_name # Fallback @@ -80,16 +80,16 @@ def validate_json_structure(data: Any, json_type: str) -> bool: return False required = ["module_name", "version", "config"] return all(key in data for key in required) - + elif json_type == "data": if not isinstance(data, dict): return False required = ["created", "last_updated"] return all(key in data for key in required) - + elif json_type == "log": return isinstance(data, list) - + return False @@ -107,7 +107,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: if json_path.exists(): try: - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if validate_json_structure(data, json_type): @@ -118,7 +118,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: template = _create_default(json_type, module_name) - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(template, f, indent=2, ensure_ascii=False) return True @@ -130,7 +130,7 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: json_path = get_json_path(module_name, json_type) - with open(json_path, 'r', encoding='utf-8') as f: + with open(json_path, "r", encoding="utf-8") as f: return json.load(f) @@ -144,7 +144,7 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: if json_type == "data" and isinstance(data, dict): data["last_updated"] = datetime.now().date().isoformat() - with open(json_path, 'w', encoding='utf-8') as f: + with open(json_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return True @@ -191,10 +191,7 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam log = [] # Create new entry - entry = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } + entry = {"timestamp": datetime.now().isoformat(), "operation": operation} if data: entry["data"] = data # type: ignore[assignment] @@ -215,16 +212,16 @@ def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> b Note: Public API — used in self-test block below. Not called in production code path. """ ensure_module_jsons(module_name) - + data = load_json(module_name, "data") if data is None: return False - + if counter_name not in data: data[counter_name] = 0 - + data[counter_name] += amount - + return save_json(module_name, "data", data) @@ -234,14 +231,14 @@ def update_data_metrics(module_name: str, **metrics) -> bool: Note: Public API — used in self-test block below. Not called in production code path. """ ensure_module_jsons(module_name) - + data = load_json(module_name, "data") if data is None: return False - + for key, value in metrics.items(): data[key] = value - + return save_json(module_name, "data", data) @@ -252,10 +249,7 @@ if __name__ == "__main__": console = Console() console.print() - console.print(Panel.fit( - "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", - border_style="bright_blue" - )) + console.print(Panel.fit("[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", border_style="bright_blue")) console.print() console.print(f"[yellow]TESTING:[/yellow] Creating {_BRANCH_NAME} JSONs...") console.print(f"[dim]JSON_DIR: {JSON_DIR}[/dim]") diff --git a/src/aipass/seedgo/apps/handlers/readme/readme_generator.py b/src/aipass/seedgo/apps/handlers/readme/readme_generator.py index 2b96204c..4c364d3e 100644 --- a/src/aipass/seedgo/apps/handlers/readme/readme_generator.py +++ b/src/aipass/seedgo/apps/handlers/readme/readme_generator.py @@ -34,22 +34,30 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler TREE_EXCLUDE = { - '__pycache__', '.gitkeep', '.git', 'node_modules', - '.pytest_cache', '.mypy_cache', + "__pycache__", + ".gitkeep", + ".git", + "node_modules", + ".pytest_cache", + ".mypy_cache", } # Directories to show as name-only (no children) - data/log dirs TREE_COLLAPSE = { - 'ai_mail.local', 'logs', 'htmlcov', 'commands', - 'ai_mail_archive', 'artifacts', + "ai_mail.local", + "logs", + "htmlcov", + "commands", + "ai_mail_archive", + "artifacts", } # Directories whose children are files only (no recursion into subdirs) # e.g., *_json dirs with config/data/log triplets -TREE_SHALLOW_PATTERN = re.compile(r'.*_json$') +TREE_SHALLOW_PATTERN = re.compile(r".*_json$") # Hidden directory prefixes to skip -HIDDEN_PREFIX = '.' +HIDDEN_PREFIX = "." # Auto-section markers for README updates MARKER_PREFIX = "