From 5fd8c6a015a6a1319d35a58d51658ded20be46e2 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 7 Jul 2026 20:02:45 -0700 Subject: [PATCH] cadence fresh-context reset + aipass misroute guidance: SessionStart wiring (handler/config/setup.sh), loaders period-5, kernel+navmap aipass-exception, guide-not-crash (drone/aipass) --- .aipass/hooks.json | 8 + .aipass/project_hooks.json | 8 + .aipass/tier0_kernel.md | 4 +- .aipass/tier1_navmap.md | 2 +- .claude/commands/prep.md | 2 +- CHANGELOG.md | 26 +++ setup.sh | 4 + src/aipass/aipass/apps/aipass.py | 8 + src/aipass/aipass/tests/test_aipass_main.py | 32 +++ src/aipass/drone/apps/drone.py | 13 ++ src/aipass/drone/tests/test_cli_routing.py | 64 ++++++ src/aipass/hooks/.seedgo/bypass.json | 35 +++ src/aipass/hooks/README.md | 3 +- .../apps/handlers/lifecycle/session_start.py | 41 ++++ src/aipass/hooks/apps/modules/cadence.py | 2 +- src/aipass/hooks/tests/test_cadence.py | 6 +- src/aipass/hooks/tests/test_session_start.py | 216 ++++++++++++++++++ 17 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 src/aipass/hooks/apps/handlers/lifecycle/session_start.py create mode 100644 src/aipass/hooks/tests/test_session_start.py diff --git a/.aipass/hooks.json b/.aipass/hooks.json index 0e84c6df..f6f3fe27 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -138,5 +138,13 @@ "matcher": "", "timeout": 120 } + }, + + "SessionStart": { + "cadence_reset": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle", + "matcher": "" + } } } diff --git a/.aipass/project_hooks.json b/.aipass/project_hooks.json index 2b6fb54e..7743c610 100644 --- a/.aipass/project_hooks.json +++ b/.aipass/project_hooks.json @@ -92,6 +92,14 @@ } }, + "SessionStart": { + "cadence_reset": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle", + "matcher": "" + } + }, + "PreCompact": { "pre_compact": { "enabled": true, diff --git a/.aipass/tier0_kernel.md b/.aipass/tier0_kernel.md index 79fd173e..32c7ae49 100644 --- a/.aipass/tier0_kernel.md +++ b/.aipass/tier0_kernel.md @@ -1,6 +1,6 @@ # 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`. @@ -13,6 +13,8 @@ You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your - `drone @agent` — bare → the agent's live self-map. - `drone systems` — list every agent. +`aipass` is the one exception — the user's own front-door CLI and concierge (onboarding, `doctor`, OS/system help). Run `aipass` / `aipass --help` directly, **never `drone @aipass`** (drone can't resolve it). Serves humans, not agents. + 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 diff --git a/.aipass/tier1_navmap.md b/.aipass/tier1_navmap.md index 3da3426b..ae0c557d 100644 --- a/.aipass/tier1_navmap.md +++ b/.aipass/tier1_navmap.md @@ -41,7 +41,7 @@ src/aipass// - @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. + - @aipass — the user's front-door concierge and its OWN CLI, NOT drone-routed: run `aipass` / `aipass --help` directly, never `drone @aipass` (drone can't resolve it). The human's best friend — onboarding (`aipass init`/`install`), `doctor` health, help chat, and OS/system questions ("why's my wifi dropping", "why's CC hogging CPU", "what is drone", "how do I make a project"). Serves humans, not agents — 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. diff --git a/.claude/commands/prep.md b/.claude/commands/prep.md index 69a71810..4f576fcb 100644 --- a/.claude/commands/prep.md +++ b/.claude/commands/prep.md @@ -44,7 +44,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for - Update their execution logs, status, decision logs with current state - If a plan was completed, note it (but don't close — the user does that) -## 3. Git State +## 3. Git State (Devpulse only) - Run `git status` — report uncommitted changes - If there's a logical commit waiting, suggest it (don't commit without asking) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca50f028..385135b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ PyPI version — not the changelog header. --- +## [2026-07-07] + +### Added + +- **Fresh-context grounding: cadence reset on new chat / clear / compact.** + Both prompt loaders (tier0 kernel + navmap) now run at period 5, and a new + `SessionStart` hook resets the cadence counter on `startup`/`clear` (skips + `resume` — restored context already carries grounding; `compact` was already + reset via PreCompact). Net effect: the first message of every fresh context + gets full grounding, then every 5th turn after. Wired end-to-end: handler + (`session_start.py`), project config (`.aipass/hooks.json` + the + `project_hooks.json` template for external projects), and `setup.sh` seeds + the provider `SessionStart` entry for new installs. (built by @hooks + + @devpulse) + +### Fixed + +- **`aipass` ≠ drone-routed — misroutes now guide instead of crash.** `aipass` + is the user's front-door CLI, deliberately not resolvable by drone. But + `drone aipass` misdirected, `drone @aipass` crashed with a traceback, and + `aipass @drone` dead-ended. All three now print clear guidance (what aipass + is, what drone is, how to reach each). Kernel + navmap prompts updated so + agents know the exception. (built by @drone + @aipass) + +--- + ## [2026-07-06] ### Fixed diff --git a/setup.sh b/setup.sh index a4a90d93..a654af34 100755 --- a/setup.sh +++ b/setup.sh @@ -662,6 +662,7 @@ else: # UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging # PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries # PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries +# SessionStart: cadence reset on startup/clear (handler skips resume itself) aipass_hooks = { "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:tier0_kernel"}]}, @@ -696,6 +697,9 @@ aipass_hooks = { {"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]}, {"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]}, ], + "SessionStart": [ + {"hooks": [{"type": "command", "command": f"{bridge} SessionStart:cadence_reset", "timeout": 30}]}, + ], } # Merge, don't replace (DPLAN-0234 Strand C): refresh every AIPass bridge entry diff --git a/src/aipass/aipass/apps/aipass.py b/src/aipass/aipass/apps/aipass.py index 1c4a5daf..36664c53 100644 --- a/src/aipass/aipass/apps/aipass.py +++ b/src/aipass/aipass/apps/aipass.py @@ -106,6 +106,14 @@ def main(): if route_command(command, remaining, modules): return 0 + if command.startswith("@"): + print(f"{command} is a drone routing target, not an aipass command.") + print("aipass is your front-door CLI; drone is the agent router — two separate tools.") + print() + print(f" Reach an agent: drone {command} ... · drone systems") + print(" aipass commands: aipass --help") + return 1 + print(f"Unknown command: {command}") return 1 diff --git a/src/aipass/aipass/tests/test_aipass_main.py b/src/aipass/aipass/tests/test_aipass_main.py index 9f15c655..fa5473bf 100644 --- a/src/aipass/aipass/tests/test_aipass_main.py +++ b/src/aipass/aipass/tests/test_aipass_main.py @@ -252,6 +252,38 @@ class TestMain: assert result == 0 mod.handle_command.assert_called_once_with("doctor", []) + def test_at_prefix_shows_drone_guidance(self) -> None: + """@drone prints guidance pointing to drone, not 'Unknown command'.""" + with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]): + with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]): + with patch("builtins.print") as mock_print: + result = main() + assert result == 1 + printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0]) + assert "@drone" in printed + assert "drone routing target" in printed + assert "Unknown command" not in printed + + def test_at_prefix_uses_actual_name(self) -> None: + """@memory prints guidance with the actual @name the user typed.""" + with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]): + with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]): + with patch("builtins.print") as mock_print: + result = main() + assert result == 1 + printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0]) + assert "@memory" in printed + assert "drone @memory" in printed + + def test_plain_bad_command_still_unknown(self) -> None: + """Non-@ bad command still prints 'Unknown command', not drone guidance.""" + with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]): + with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]): + with patch("builtins.print") as mock_print: + result = main() + assert result == 1 + mock_print.assert_called_with("Unknown command: frobnicate") + def test_command_with_remaining_args(self) -> None: """Remaining args are passed to route_command.""" mod = MagicMock() diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 02d364f2..82c52c3e 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -568,6 +568,19 @@ def main() -> int: if command == "rm": return _handle_rm(args[1:]) + # aipass is a user-facing CLI, not a drone-routable branch + if command.lstrip("@") == "aipass": + err_console.print( + "aipass isn't reachable through drone — it's your own front-door CLI," + " the AIPass concierge (onboarding, doctor, help, OS/system questions)." + " drone routes the agent citizens (@git, @devpulse, @memory...);" + " aipass is separate and serves you directly.\n" + "\n" + " Use aipass: aipass · aipass --help\n" + " See agents: drone systems" + ) + return 1 + # @target — route to branch or module if command.startswith("@"): return _handle_target(args) diff --git a/src/aipass/drone/tests/test_cli_routing.py b/src/aipass/drone/tests/test_cli_routing.py index 1f95aaea..872f437d 100644 --- a/src/aipass/drone/tests/test_cli_routing.py +++ b/src/aipass/drone/tests/test_cli_routing.py @@ -839,3 +839,67 @@ class TestCliEntryPoint: ): cli_main() assert exc_info.value.code == 0 + + +# =========================================================================== +# aipass intercept — drone aipass / drone @aipass +# =========================================================================== + + +class TestAipassIntercept: + """'aipass' is a user CLI, not a drone-routable branch.""" + + def test_bare_aipass_shows_guidance(self, capsys: pytest.CaptureFixture[str]) -> None: + """'drone aipass' prints guidance to stderr.""" + from aipass.drone.apps.drone import main + + with patch("sys.argv", ["drone", "aipass"]): + result = main() + assert result == 1 + captured = capsys.readouterr() + assert "aipass isn't reachable through drone" in captured.err + assert "aipass --help" in captured.err + + def test_at_aipass_shows_guidance(self, capsys: pytest.CaptureFixture[str]) -> None: + """'drone @aipass' prints guidance to stderr.""" + from aipass.drone.apps.drone import main + + with patch("sys.argv", ["drone", "@aipass"]): + result = main() + assert result == 1 + captured = capsys.readouterr() + assert "aipass isn't reachable through drone" in captured.err + assert "drone systems" in captured.err + + def test_bare_aipass_no_traceback(self, capsys: pytest.CaptureFixture[str]) -> None: + """No python traceback leaks on 'drone aipass'.""" + from aipass.drone.apps.drone import main + + with patch("sys.argv", ["drone", "aipass"]): + result = main() + assert result == 1 + captured = capsys.readouterr() + assert "Traceback" not in captured.err + assert "ModuleNotFoundError" not in captured.err + + def test_at_aipass_no_at_misdirect(self, capsys: pytest.CaptureFixture[str]) -> None: + """No 'use @aipass' misdirect on 'drone @aipass'.""" + from aipass.drone.apps.drone import main + + with patch("sys.argv", ["drone", "@aipass"]): + result = main() + assert result == 1 + captured = capsys.readouterr() + assert "Use '@aipass'" not in captured.err + + def test_real_branch_still_routes(self) -> None: + """Real branches still route normally after aipass intercept.""" + from aipass.drone.apps.drone import main + + with ( + patch("sys.argv", ["drone", "@git", "status"]), + patch(f"{_DRONE}.is_module", return_value=True), + patch(f"{_DRONE}.route_module_command", return_value={"stdout": "ok", "stderr": "", "exit_code": 0}), + ): + result = main() + assert result == 0 diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 0535f491..4374e276 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -241,6 +241,21 @@ "standard": "json_structure", "reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler." }, + { + "file": "apps/handlers/lifecycle/session_start.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset." + }, + { + "file": "apps/handlers/lifecycle/session_start.py", + "standard": "unused_function", + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in SessionStart.cadence_reset." + }, + { + "file": "apps/handlers/lifecycle/session_start.py", + "standard": "json_structure", + "reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler." + }, { "file": "apps/modules/cadence.py", "standard": "dead_code", @@ -1068,6 +1083,26 @@ "file": "tests/test_session_boot.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_session_start.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_session_start.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_session_start.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_session_start.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." } ], "notes": { diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index c3bf993e..dc153e09 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -73,7 +73,8 @@ src/aipass/hooks/ │ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) │ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch │ │ │ ├── compact.py # Pre-compact memory archival -│ │ │ └── rollover.py # Pre-compact memory rollover +│ │ │ ├── rollover.py # Pre-compact memory rollover +│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart) │ │ └── notification/ # Sound/alert hooks │ │ ├── announce.py # Announcement tone on notification │ │ ├── email.py # Inbox check on prompt diff --git a/src/aipass/hooks/apps/handlers/lifecycle/session_start.py b/src/aipass/hooks/apps/handlers/lifecycle/session_start.py new file mode 100644 index 00000000..d8bb21a3 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/lifecycle/session_start.py @@ -0,0 +1,41 @@ +# =================== AIPass ==================== +# Name: session_start.py +# Version: 1.0.0 +# Description: Resets cadence counter on new chat / clear (SessionStart) +# Branch: hooks +# Layer: apps/handlers/lifecycle +# Created: 2026-07-07 +# Modified: 2026-07-07 +# ============================================= + +"""Resets cadence counter on SessionStart so loaders re-fire at turn 0. + +Fires on source=startup (new chat) and source=clear (/clear). +Skips source=resume — restored context already carries grounding. +source=compact is already handled by PreCompact; a duplicate reset is +harmless (idempotent), so we allow it rather than adding a fragile gate. +""" + +import importlib + +from aipass.prax.apps.modules.logger import system_logger as logger + +_SKIP_SOURCES = frozenset({"resume"}) + + +def handle(hook_data: dict) -> dict: + """Reset cadence counter unless this is a resume.""" + source = hook_data.get("source", "") + + if source in _SKIP_SOURCES: + logger.info("[HOOKS] session_start: skipped cadence reset (source=%s)", source) + return {"stdout": "", "exit_code": 0} + + try: + cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + cadence.reset_counter(hook_data=hook_data) + logger.info("[HOOKS] session_start: cadence reset (source=%s)", source) + except Exception as exc: + logger.info("[HOOKS] session_start: cadence reset failed: %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 5084e3ee..31c9cc96 100644 --- a/src/aipass/hooks/apps/modules/cadence.py +++ b/src/aipass/hooks/apps/modules/cadence.py @@ -45,7 +45,7 @@ DEFAULTS = { "enabled": True, "period": 5, "loaders": { - "tier0": {"period": 1}, + "tier0": {"period": 5, "offset": 0}, "navmap": {"period": 5, "offset": 0}, "branch": {"offset": 0}, }, diff --git a/src/aipass/hooks/tests/test_cadence.py b/src/aipass/hooks/tests/test_cadence.py index 1a92bce1..3b1e4afd 100644 --- a/src/aipass/hooks/tests/test_cadence.py +++ b/src/aipass/hooks/tests/test_cadence.py @@ -332,7 +332,7 @@ class TestConfig: with patch(f"{MODULE}._CONFIG_PATH", tmp_path / "nonexistent.json"): config = _load_config() - assert config["loaders"]["tier0"]["period"] == 1 + assert config["loaders"]["tier0"]["period"] == 5 assert config["loaders"]["navmap"]["period"] == 5 assert config["loaders"]["navmap"]["offset"] == 0 @@ -709,7 +709,9 @@ class TestPostCompactDeterminism: def test_compact_handler_calls_reset_with_hook_data(self): from aipass.hooks.apps.handlers.lifecycle.compact import handle - hook_data = {"cwd": "/tmp/fake", "session_id": "test-123"} + import tempfile + + hook_data = {"cwd": tempfile.gettempdir() + "/fake", "session_id": "test-123"} with ( patch("importlib.import_module") as mock_import, diff --git a/src/aipass/hooks/tests/test_session_start.py b/src/aipass/hooks/tests/test_session_start.py new file mode 100644 index 00000000..67d4b485 --- /dev/null +++ b/src/aipass/hooks/tests/test_session_start.py @@ -0,0 +1,216 @@ +# =================== AIPass ==================== +# Name: test_session_start.py +# Version: 1.0.0 +# Description: Tests for SessionStart cadence reset handler +# Branch: hooks +# Created: 2026-07-07 +# Modified: 2026-07-07 +# ============================================= + +"""Tests for apps/handlers/lifecycle/session_start.py.""" + +import json +import os +from unittest.mock import patch + +CADENCE_MODULE = "aipass.hooks.apps.modules.cadence" + + +def _reset_cadence_globals(): + import aipass.hooks.apps.modules.cadence as mod + + mod._turn = None + mod._config = None + + +def _write_state(tmp_path, turn, session="test-session"): + import time + + state_file = tmp_path / f"aipass-cadence-{session}.json" + state_file.write_text(json.dumps({"turn": turn, "token": -1})) + old = time.time() - 10 + os.utime(state_file, (old, old)) + return state_file + + +class TestSessionStartHandler: + def setup_method(self): + _reset_cadence_globals() + + def test_startup_resets_cadence(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + state_file = _write_state(tmp_path, turn=7) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + result = handle({"source": "startup", "session_id": "test-session"}) + + assert result["exit_code"] == 0 + data = json.loads(state_file.read_text()) + assert data["turn"] == -1 + + def test_clear_resets_cadence(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + state_file = _write_state(tmp_path, turn=3) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + result = handle({"source": "clear", "session_id": "test-session"}) + + assert result["exit_code"] == 0 + data = json.loads(state_file.read_text()) + assert data["turn"] == -1 + + def test_resume_skips_reset(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + state_file = _write_state(tmp_path, turn=7) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + result = handle({"source": "resume", "session_id": "test-session"}) + + assert result["exit_code"] == 0 + data = json.loads(state_file.read_text()) + assert data["turn"] == 7 + + def test_compact_source_resets(self, tmp_path): + """source=compact is idempotent with PreCompact — allowed.""" + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + state_file = _write_state(tmp_path, turn=5) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + result = handle({"source": "compact", "session_id": "test-session"}) + + assert result["exit_code"] == 0 + data = json.loads(state_file.read_text()) + assert data["turn"] == -1 + + def test_empty_source_resets(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + state_file = _write_state(tmp_path, turn=4) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + result = handle({"session_id": "test-session"}) + + assert result["exit_code"] == 0 + data = json.loads(state_file.read_text()) + assert data["turn"] == -1 + + def test_no_stdout_output(self): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + with patch("importlib.import_module"): + result = handle({"source": "startup"}) + + assert result["stdout"] == "" + + def test_cadence_import_failure_does_not_crash(self): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + + with patch("importlib.import_module", side_effect=ImportError("boom")): + result = handle({"source": "startup"}) + + assert result["exit_code"] == 0 + + +class TestSessionStartCadenceIntegration: + """End-to-end: SessionStart reset -> next turn fires all loaders.""" + + def setup_method(self): + _reset_cadence_globals() + + def test_clear_then_all_loaders_fire(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + 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": 5, "offset": 0}, + "navmap": {"period": 5, "offset": 0}, + "branch": {"offset": 0}, + }, + } + ) + ) + + _write_state(tmp_path, turn=3) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{CADENCE_MODULE}._CONFIG_PATH", config), + ): + handle({"source": "clear", "session_id": "test-session"}) + + _reset_cadence_globals() + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{CADENCE_MODULE}._CONFIG_PATH", config), + ): + assert should_fire("tier0") is True + _reset_cadence_globals() + assert should_fire("navmap") is True + _reset_cadence_globals() + assert should_fire("branch") is True + + def test_resume_does_not_reset_counter_continues(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.session_start import handle + 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": 5, "offset": 0}, + "navmap": {"period": 5, "offset": 0}, + }, + } + ) + ) + + _write_state(tmp_path, turn=2) + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{CADENCE_MODULE}._CONFIG_PATH", config), + ): + handle({"source": "resume", "session_id": "test-session"}) + + _reset_cadence_globals() + + with ( + patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{CADENCE_MODULE}._CONFIG_PATH", config), + ): + assert should_fire("tier0") is False + _reset_cadence_globals() + assert should_fire("navmap") is False