diff --git a/.aipass/.gitignore b/.aipass/.gitignore index 9999acfb..150f0d08 100644 --- a/.aipass/.gitignore +++ b/.aipass/.gitignore @@ -1,5 +1,7 @@ * !aipass_global_prompt.md +!tier0_kernel.md +!tier1_navmap.md !hooks.json !.gitignore !project_CLAUDE.md diff --git a/.aipass/PROMPT_STYLE.md b/.aipass/PROMPT_STYLE.md index 763d3a0d..05d9185d 100644 --- a/.aipass/PROMPT_STYLE.md +++ b/.aipass/PROMPT_STYLE.md @@ -46,6 +46,7 @@ These are not currently enforced by seedgo — per @seedgo's Track 5 recommendat # Reference files - - `.aipass/aipass_global_prompt.md` — canonical example of the format + - `.aipass/tier0_kernel.md` + `.aipass/tier1_navmap.md` — the live injected prompts (Tier 0 every turn, Tier 1 periodic); canonical examples of the format + - `.aipass/aipass_global_prompt.md` — superseded by the tiers (FPLAN-0284), kept as a reference snapshot - Branch `.aipass/aipass_local_prompt.md` files — should follow the same rules - This file — reference for authoring new prompts or auditing existing ones diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index f1cf2e7f..61d10711 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -1,5 +1,6 @@ -# AIPass — Global Prompt - +# AIPass — Global Prompt (SUPERSEDED) + + Persistent Agent Workspace. AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`. diff --git a/.aipass/hooks.json b/.aipass/hooks.json index 5d2b8c6a..6c8cc353 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -19,10 +19,21 @@ "matcher": "" }, "global_prompt": { - "enabled": true, + "enabled": false, + "_retired": "Superseded by tier0_kernel + navmap (FPLAN-0284, 2026-06-18). Re-enable here to roll back to the single 8k global prompt.", "handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle", "matcher": "" }, + "tier0_kernel": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.tier0_kernel.handle", + "matcher": "" + }, + "navmap": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.navmap.handle", + "matcher": "" + }, "auto_process": { "enabled": true, "handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle", diff --git a/.aipass/tier0_kernel.md b/.aipass/tier0_kernel.md new file mode 100644 index 00000000..79fd173e --- /dev/null +++ b/.aipass/tier0_kernel.md @@ -0,0 +1,25 @@ +# AIPass — Kernel + + + +You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your branch is your home and address. CWD is your identity: always know which branch you're standing in. The system runs on `drone`. + +# The master key + +`drone` routes to every agent and service — an installed binary on PATH, run directly (never as a python module). Before using any agent's services, run `drone @agent --help`. This kernel says what exists; `--help` says how. Don't guess syntax — fetch it. Doubly so right after a compaction. + + - `drone @agent ` — route a command. + - `drone @agent --help` — the full reference (source of truth for usage). + - `drone @agent` — bare → the agent's live self-map. + - `drone systems` — list every agent. + +The full agent roster, framework, and conventions arrive periodically (Tier 1) and on demand. Unsure of anything? Fetch it: `drone @agent --help` / the agent's `README.md` / `drone @memory search "query"`. + +# Don't get lost + + - Git is drone-only — raw `git`/`gh` write is blocked. `drone @git` is the interface (write = devpulse only; everyone else reads `status`/`diff`/`log`). + - No cross-branch file edits. Issue in another agent's code → mail the owner. + - Never delete files. Rename `name(disabled).py` or move to a sibling `.archive/`. + - Fail to errors, never fall back silently. + - Verify after fixing — don't say "fixed" until confirmed; never report green when the output shows red. + - Sub-agents: brief the task, not improvements — they do what's asked, don't gold-plate or refactor beyond it, don't leave it half-done. diff --git a/.aipass/tier1_navmap.md b/.aipass/tier1_navmap.md new file mode 100644 index 00000000..ab898dd7 --- /dev/null +++ b/.aipass/tier1_navmap.md @@ -0,0 +1,105 @@ +# AIPass — Navigation map + + + +AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`. + +# Finding your way + +You can't carry everything; you can find anything — you're the librarian, not the encyclopedia. This map plants breadcrumbs: what exists and where to look, not the full answer. A breadcrumb is the trigger to fetch the answer, not the answer. Cheapest, highest-signal sources first: + + - bare `drone @agent` — introspection: the agent's live self-map of modules and commands. + - `drone @agent --help` — the full curated reference. Source of truth for usage. + - the agent's `README.md` — best quick overview of its domain and shape. + +# Terminology + + - Branch — directory `src/aipass//`. Your home, your address. Drone routes to branches. + - Agent (citizen) — persistent identity in a branch: passport (`.trinity/`), memories, mailbox. Addressable as `@name`. You belong, you persist. + - Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen. + - Registry — machine-managed catalogs (`registry.json`, flow/spawn registries). Never hand-edit — owners manage them. + - Settings — provider `~/.claude/settings.json` (machine-wide, personal, don't touch) · project `/.claude/settings.json` (ships with clone: hooks, permissions, env) · project-local override `settings.local.json`. + +# The framework + +Every branch is built the same. All agents live at `src/aipass/` · mail address `@`. + +``` +src/aipass// +├── .trinity/ # identity & memory (passport, local, observations) +├── .aipass/ # branch prompt +├── .ai_mail.local/ # mailbox +├── apps/ +│ ├── .py # entry point +│ ├── modules/ # business logic +│ └── handlers/ # implementation details +├── logs/ # prax log output +└── README.md +``` + +# The agents + + - @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`). + - @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write. + - @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes. + - @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close. + - @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand. + - @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building. + - @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards. Logs are the first diagnostic tool. + - @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions. Nothing is lost — it moves deeper. + - @spawn — branch lifecycle. Creates, updates, syncs, retires agents — scaffolding, passports, registry, templates. + - @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, per-project config, sound. + - @trigger — event handling. Pub/sub event bus, error detection (medic), log watching, error registry. Detects and dispatches — owners fix. + - @api — external API gateway. Authenticated service clients (Google, OpenRouter, more), OAuth flows, key management, resilience. + - @cli — display formatting with Rich. Shared rendering for terminal output. + - @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill). + - @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires. + - @commons — the social space. Where branches post, comment, vote, and gather as a community. + - @backup — local-first backups. Project-owned snapshots and restore for any directory; no external service. + +# Daily commands + +``` +drone @ai_mail dispatch @target "Subject" "Body" # send + wake +drone @ai_mail inbox # check mail → view → reply "msg" +drone @flow create . "Subject" [dplan] # new plan (default FPLAN) +drone @seedgo audit aipass @branch # standards audit (drop @branch = all) +drone @seedgo checklist # quick standards check +drone @git status / diff / log # read-only git awareness +drone @memory search "query" # recall archived context +``` + +Always reply to dispatches — reply auto-closes. No silent completions. + +# Plans — flow + +Plans carry context so you don't have to. Create only via `drone @flow create "Subject" [type]` — never by hand (manual files break the registry). + + - DPLAN — dev plan. Thinking, brainstorming, architecture. Before building. + - FPLAN — flow plan, the default. Building and executing. `master` template = multi-phase, spawns sub-FPLANs. + - PPLAN — playbook. A throwaway run stamped from a reusable SOP template. Operating the system, not changing it. + - More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live. + +# Sub-agents + + - Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories and plans, quick one-liners. + - One clear task per agent. Brief with full context — they know nothing of your conversation. + - No git, no memory, no dispatch. They build and report; you decide and act. + - Sub-agent = local disposable worker. Dispatch (`@ai_mail`) = wake a citizen with memory and identity. Branch-expert work → dispatch; else → sub-agent. + - Models, good practice: opus for build and analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents. + +# Memory — .trinity/ + +Your continuity across sessions. Save proactively — after milestones, decisions, topic switches. + + - `passport.json` — identity. Update only when identity genuinely evolves. + - `local.json` — session log, key learnings, todos. + - `observations.json` — what you learn about the user. + - Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold. + +# House rules + + - Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`. + - No bare imports — always `from aipass..apps...`. + - Registries are machine-managed (spawn, flow) — never hand-edit them. + - State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 18eddfb9..d0d5cb32 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -86,6 +86,36 @@ "standard": "json_structure", "reason": "No JSON operations \u2014 reads markdown file and outputs text." }, + { + "file": "apps/handlers/prompt/tier0_kernel.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.tier0_kernel.handle' - not statically imported by design. Wired in UserPromptSubmit.tier0_kernel (FPLAN-0284). Same pattern as global_loader.py." + }, + { + "file": "apps/handlers/prompt/tier0_kernel.py", + "standard": "unused_function", + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.tier0_kernel (FPLAN-0284)." + }, + { + "file": "apps/handlers/prompt/tier0_kernel.py", + "standard": "json_structure", + "reason": "No JSON operations - reads markdown file (.aipass/tier0_kernel.md) and outputs text." + }, + { + "file": "apps/handlers/prompt/navmap.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.navmap.handle' - not statically imported by design. Wired in UserPromptSubmit.navmap (FPLAN-0284). Same pattern as global_loader.py." + }, + { + "file": "apps/handlers/prompt/navmap.py", + "standard": "unused_function", + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.navmap (FPLAN-0284)." + }, + { + "file": "apps/handlers/prompt/navmap.py", + "standard": "json_structure", + "reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text." + }, { "file": "apps/handlers/security/edit_gate.py", "standard": "dead_code", diff --git a/src/aipass/hooks/apps/handlers/prompt/navmap.py b/src/aipass/hooks/apps/handlers/prompt/navmap.py new file mode 100644 index 00000000..2daacde0 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/navmap.py @@ -0,0 +1,59 @@ +# =================== AIPass ==================== +# Name: navmap.py +# Version: 1.0.0 +# Description: Tier 1 navigation map — periodic prompt injection (UserPromptSubmit) +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-06-18 +# Modified: 2026-06-18 +# ============================================= + +"""Loads .aipass/tier1_navmap.md — richer navigation map injected periodically.""" + +import os +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + + +def _find_project_file(filename: str) -> Path | None: + """Walk up from CWD to find the nearest .aipass/.""" + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + candidate = parent / ".aipass" / filename + if candidate.is_file(): + return candidate + if parent == parent.parent: + break + return None + + +def handle(hook_data: dict) -> dict: + """Load tier1 navmap — periodic (cadence period 5) + turn 0 + post-compaction.""" + try: + import importlib + + cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + if not cadence.should_fire("navmap", hook_data): + return {"stdout": "", "exit_code": 0} + except Exception as exc: + logger.info("[HOOKS] navmap: cadence check failed, firing anyway: %s", exc) + + try: + aipass_home = os.environ.get("AIPASS_HOME", "") + cwd = str(Path.cwd()) + + if aipass_home and cwd.startswith(aipass_home): + prompt_file = Path(aipass_home) / ".aipass" / "tier1_navmap.md" + else: + prompt_file = _find_project_file("tier1_navmap.md") + + if not prompt_file or not prompt_file.exists(): + return {"stdout": "", "exit_code": 0} + + content = prompt_file.read_text(encoding="utf-8") + return {"stdout": content, "exit_code": 0, "sound": "navmap"} + + except Exception as exc: + logger.info("[HOOKS] navmap: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/prompt/tier0_kernel.py b/src/aipass/hooks/apps/handlers/prompt/tier0_kernel.py new file mode 100644 index 00000000..0e75fc9f --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/tier0_kernel.py @@ -0,0 +1,59 @@ +# =================== AIPass ==================== +# Name: tier0_kernel.py +# Version: 1.0.0 +# Description: Tier 0 kernel — always-on minimal prompt injection (UserPromptSubmit) +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-06-18 +# Modified: 2026-06-18 +# ============================================= + +"""Loads .aipass/tier0_kernel.md — tiny always-on identity + reflex block.""" + +import os +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + + +def _find_project_file(filename: str) -> Path | None: + """Walk up from CWD to find the nearest .aipass/.""" + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + candidate = parent / ".aipass" / filename + if candidate.is_file(): + return candidate + if parent == parent.parent: + break + return None + + +def handle(hook_data: dict) -> dict: + """Load tier0 kernel — every turn (cadence period 1).""" + try: + import importlib + + cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + if not cadence.should_fire("tier0", hook_data): + return {"stdout": "", "exit_code": 0} + except Exception as exc: + logger.info("[HOOKS] tier0_kernel: cadence check failed, firing anyway: %s", exc) + + try: + aipass_home = os.environ.get("AIPASS_HOME", "") + cwd = str(Path.cwd()) + + if aipass_home and cwd.startswith(aipass_home): + prompt_file = Path(aipass_home) / ".aipass" / "tier0_kernel.md" + else: + prompt_file = _find_project_file("tier0_kernel.md") + + if not prompt_file or not prompt_file.exists(): + return {"stdout": "", "exit_code": 0} + + content = prompt_file.read_text(encoding="utf-8") + return {"stdout": content, "exit_code": 0, "sound": "tier0 kernel"} + + except Exception as exc: + logger.info("[HOOKS] tier0_kernel: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/modules/cadence.py b/src/aipass/hooks/apps/modules/cadence.py index a656045e..6d610b44 100644 --- a/src/aipass/hooks/apps/modules/cadence.py +++ b/src/aipass/hooks/apps/modules/cadence.py @@ -196,13 +196,15 @@ def should_fire(loader_name: str, hook_data: dict | None = None) -> bool: if not config.get("enabled", True): return True - period = config.get("period", 5) - if period <= 0: - return True + global_period = config.get("period", 5) loader_config = config.get("loaders", {}).get(loader_name, {}) + period = loader_config.get("period", global_period) offset = loader_config.get("offset", 0) + if period <= 0: + return True + turn = _load_and_increment(hook_data or {}) fired = turn == 0 or (turn % period) == offset @@ -256,10 +258,12 @@ def print_introspection() -> None: config = _load_config() CONSOLE.print("[bold cyan]cadence[/bold cyan] Module") CONSOLE.print(f" Enabled: {config.get('enabled', True)}") - CONSOLE.print(f" Period: {config.get('period', 5)} turns") + global_period = config.get("period", 5) + CONSOLE.print(f" Period: {global_period} turns (global default)") loaders = config.get("loaders", {}) for name, lcfg in loaders.items(): - CONSOLE.print(f" Loader '{name}': offset={lcfg.get('offset', 0)}") + lp = lcfg.get("period", global_period) + CONSOLE.print(f" Loader '{name}': period={lp} offset={lcfg.get('offset', 0)}") path = _state_path() if path and path.exists(): try: diff --git a/src/aipass/hooks/tests/test_cadence.py b/src/aipass/hooks/tests/test_cadence.py index 016214ef..96f3a0cc 100644 --- a/src/aipass/hooks/tests/test_cadence.py +++ b/src/aipass/hooks/tests/test_cadence.py @@ -489,3 +489,181 @@ class TestLoaderCadenceGuard: assert result["stdout"] == "" assert result["exit_code"] == 0 assert "sound" not in result + + +class TestPerLoaderPeriod: + def setup_method(self): + _reset_module_globals() + + def test_loader_period_overrides_global(self, tmp_path): + """A loader with period:1 fires every turn, even when global period is 5.""" + from aipass.hooks.apps.modules.cadence import should_fire + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"tier0": {"period": 1}, "global": {"offset": 0}}, + } + ) + ) + + _write_state(tmp_path, turn=2) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("tier0") is True + _reset_module_globals() + assert should_fire("global") is False + + def test_loader_without_period_uses_global(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"global": {"offset": 0}}, + } + ) + ) + + _write_state(tmp_path, turn=2) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("global") is False + + def test_tier0_period_1_fires_every_turn(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"tier0": {"period": 1}}, + } + ) + ) + + for turn_val in range(1, 8): + _reset_module_globals() + _write_state(tmp_path, turn=turn_val - 1) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("tier0") is True, f"tier0 should fire on turn {turn_val}" + + def test_navmap_period_5_skips_non_fire_turns(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"navmap": {"period": 5, "offset": 0}}, + } + ) + ) + + _write_state(tmp_path, turn=2) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("navmap") is False + + def test_navmap_fires_on_turn_0(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"navmap": {"period": 5, "offset": 0}}, + } + ) + ) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("navmap") is True + + def test_navmap_fires_after_reset_counter(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire, reset_counter + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"navmap": {"period": 5, "offset": 0}}, + } + ) + ) + + _write_state(tmp_path, turn=7) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + reset_counter() + + _reset_module_globals() + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("navmap") is True + + def test_per_loader_period_zero_always_fires(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + config = tmp_path / "cadence.json" + config.write_text( + json.dumps( + { + "enabled": True, + "period": 5, + "loaders": {"always": {"period": 0}}, + } + ) + ) + + _write_state(tmp_path, turn=2) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("always") is True diff --git a/src/aipass/hooks/tests/test_navmap.py b/src/aipass/hooks/tests/test_navmap.py new file mode 100644 index 00000000..2fe05d38 --- /dev/null +++ b/src/aipass/hooks/tests/test_navmap.py @@ -0,0 +1,127 @@ +# =================== AIPass ==================== +# Name: test_navmap.py +# Version: 1.0.0 +# Description: Tests for navmap prompt handler +# Branch: hooks +# Created: 2026-06-18 +# Modified: 2026-06-18 +# ============================================= + +"""Tests for handlers/prompt/navmap.py.""" + +from unittest.mock import patch, MagicMock + + +def _mock_cadence(fires: bool = True): + """Return a mock cadence module with configurable should_fire.""" + mock = MagicMock() + mock.should_fire.return_value = fires + return mock + + +class TestNavmapHandler: + def test_loads_navmap(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + prompt = aipass_dir / "tier1_navmap.md" + prompt.write_text("# Navigation Map\nAgents and services", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert "Navigation Map" in result["stdout"] + assert "Agents and services" in result["stdout"] + assert result["sound"] == "navmap" + + def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + monkeypatch.chdir(tmp_path) + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + assert "sound" not in result + + def test_empty_hook_data(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier1_navmap.md").write_text("navmap content", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "navmap content" + + def test_skips_on_cadence_skip(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier1_navmap.md").write_text("content", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(False)): + result = handle({}) + + assert result["stdout"] == "" + assert result["exit_code"] == 0 + assert "sound" not in result + + def test_fires_anyway_on_cadence_error(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier1_navmap.md").write_text("navmap content", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", side_effect=ImportError("no cadence")): + result = handle({}) + + assert result["exit_code"] == 0 + assert "navmap content" in result["stdout"] + + def test_external_project_gets_own_file(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + project = tmp_path / "my-project" + project.mkdir() + aipass_dir = project / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier1_navmap.md").write_text("# Project Navmap", encoding="utf-8") + monkeypatch.chdir(project) + + with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert "Project Navmap" in result["stdout"] + assert result["sound"] == "navmap" + + def test_cadence_called_with_navmap_name(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.navmap import handle + + monkeypatch.chdir(tmp_path) + mock = _mock_cadence(False) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=mock): + handle({"some": "data"}) + + mock.should_fire.assert_called_once_with("navmap", {"some": "data"}) diff --git a/src/aipass/hooks/tests/test_tier0_kernel.py b/src/aipass/hooks/tests/test_tier0_kernel.py new file mode 100644 index 00000000..cdd4324b --- /dev/null +++ b/src/aipass/hooks/tests/test_tier0_kernel.py @@ -0,0 +1,127 @@ +# =================== AIPass ==================== +# Name: test_tier0_kernel.py +# Version: 1.0.0 +# Description: Tests for tier0_kernel prompt handler +# Branch: hooks +# Created: 2026-06-18 +# Modified: 2026-06-18 +# ============================================= + +"""Tests for handlers/prompt/tier0_kernel.py.""" + +from unittest.mock import patch, MagicMock + + +def _mock_cadence(fires: bool = True): + """Return a mock cadence module with configurable should_fire.""" + mock = MagicMock() + mock.should_fire.return_value = fires + return mock + + +class TestTier0KernelHandler: + def test_loads_tier0_kernel(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + prompt = aipass_dir / "tier0_kernel.md" + prompt.write_text("# Tier 0 Kernel\nAlways on", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert "Tier 0 Kernel" in result["stdout"] + assert "Always on" in result["stdout"] + assert result["sound"] == "tier0 kernel" + + def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + monkeypatch.chdir(tmp_path) + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + assert "sound" not in result + + def test_empty_hook_data(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier0_kernel.md").write_text("content", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "content" + + def test_skips_on_cadence_skip(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier0_kernel.md").write_text("content", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence(False)): + result = handle({}) + + assert result["stdout"] == "" + assert result["exit_code"] == 0 + assert "sound" not in result + + def test_fires_anyway_on_cadence_error(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier0_kernel.md").write_text("kernel content", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", side_effect=ImportError("no cadence")): + result = handle({}) + + assert result["exit_code"] == 0 + assert "kernel content" in result["stdout"] + + def test_external_project_gets_own_file(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + project = tmp_path / "my-project" + project.mkdir() + aipass_dir = project / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "tier0_kernel.md").write_text("# Project Kernel", encoding="utf-8") + monkeypatch.chdir(project) + + with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}): + with patch("importlib.import_module", return_value=_mock_cadence(True)): + result = handle({}) + + assert result["exit_code"] == 0 + assert "Project Kernel" in result["stdout"] + assert result["sound"] == "tier0 kernel" + + def test_cadence_called_with_tier0_name(self, tmp_path, monkeypatch): + from aipass.hooks.apps.handlers.prompt.tier0_kernel import handle + + monkeypatch.chdir(tmp_path) + mock = _mock_cadence(False) + + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=mock): + handle({"some": "data"}) + + mock.should_fire.assert_called_once_with("tier0", {"some": "data"})