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)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user