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:
AIOSAI
2026-07-07 20:02:45 -07:00
parent 47b5b2d871
commit 5fd8c6a015
17 changed files with 467 additions and 7 deletions
+8
View File
@@ -138,5 +138,13 @@
"matcher": "",
"timeout": 120
}
},
"SessionStart": {
"cadence_reset": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
"matcher": ""
}
}
}
+8
View File
@@ -92,6 +92,14 @@
}
},
"SessionStart": {
"cadence_reset": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
"matcher": ""
}
},
"PreCompact": {
"pre_compact": {
"enabled": true,
+3 -1
View File
@@ -1,6 +1,6 @@
# AIPass — Kernel
<!-- .aipass/tier0_kernel.md — Tier 0, injected EVERY turn (cadence period 1). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
<!-- .aipass/tier0_kernel.md — Tier 0, injected every 5 turns (cadence period 5) + on every fresh context (new chat / clear / after compact). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
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
+1 -1
View File
@@ -41,7 +41,7 @@ src/aipass/<name>/
- @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.
+1 -1
View File
@@ -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)
+26
View File
@@ -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
+4
View File
@@ -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
+8
View File
@@ -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()
+13
View File
@@ -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
+35
View File
@@ -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": {
+2 -1
View File
@@ -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}
+1 -1
View File
@@ -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},
},
+4 -2
View File
@@ -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