feat(hooks): tiered prompt injection — Tier 0 kernel + Tier 1 navmap by cadence (FPLAN-0284 P2-P4, DPLAN-0214)
Replaces the single 8k always-injected global prompt with cadence-tiered injection: - Tier 0 (.aipass/tier0_kernel.md, ~2k) injects EVERY turn — identity grounding, the drone --help reflex, disaster-preventer rules. Folds DPLAN-0213 C1 (faithful-reporting) + C2 (no-gold-plating sub-agent brief). - Tier 1 (.aipass/tier1_navmap.md, ~7.7k) injects every 5th turn + session-start + post-compaction — full agent roster, framework, conventions, plus a new Terminology section migrated from the S211 backup. - Old global_prompt loader retired (disabled in hooks.json, removed from cadence wiring); aipass_global_prompt.md kept as a reference snapshot. Engine (built by @hooks): per-loader period in cadence.py should_fire() (back-compatible: unset period falls back to global); new tier0_kernel + navmap handlers; bypass.json extended to cover the two new dynamically-dispatched handlers. 614/614 tests pass, seedgo @hooks 100%. Live-verified: tier0 fires every turn, navmap on turn 0/5/10, turn counter advances once per turn (not per loader), no double-injection. Machine-local wiring (cadence_config.json, ~/.claude/settings.json bridge) updated on this host; fresh-clone seeding of those is a tracked follow-up. PROMPT_STYLE.md reference updated to point at the tier files as canonical examples.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
*
|
||||
!aipass_global_prompt.md
|
||||
!tier0_kernel.md
|
||||
!tier1_navmap.md
|
||||
!hooks.json
|
||||
!.gitignore
|
||||
!project_CLAUDE.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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# AIPass — Global Prompt
|
||||
<!-- .aipass/aipass_global_prompt.md — injected via hook, cadence-throttled. Size cap: keep under 8,000 characters — the harness truncates hook output near 10k and the tail silently never arrives. Detail belongs in `drone @agent --help`, not here. Format: .aipass/PROMPT_STYLE.md -->
|
||||
# AIPass — Global Prompt (SUPERSEDED)
|
||||
<!-- SUPERSEDED 2026-06-18 (FPLAN-0284): no longer injected. Split into the tiered prompts — .aipass/tier0_kernel.md (every turn) + .aipass/tier1_navmap.md (periodic). Kept as a reference/rollback snapshot; the `global_prompt` loader is disabled in .aipass/hooks.json. To roll back, re-enable that loader + its settings.json bridge entry. -->
|
||||
<!-- .aipass/aipass_global_prompt.md — Size cap: keep under 8,000 characters — the harness truncates hook output near 10k and the tail silently never arrives. Detail belongs in `drone @agent --help`, not here. Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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 -->
|
||||
|
||||
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 <command>` — 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.
|
||||
@@ -0,0 +1,105 @@
|
||||
# AIPass — Navigation map
|
||||
|
||||
<!-- .aipass/tier1_navmap.md — Tier 1, injected periodically (cadence period 5) + at session start + right after compaction, when you most need the map back. The kernel (.aipass/tier0_kernel.md) arrives every turn; deep reference lives in `drone @agent --help` and topic guides. Size cap: keep the per-fire output under ~8,000 characters (the hook truncates near 10k). Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
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/<name>/`. 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 `<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/<name>` · mail address `@<name>`.
|
||||
|
||||
```
|
||||
src/aipass/<name>/
|
||||
├── .trinity/ # identity & memory (passport, local, observations)
|
||||
├── .aipass/ # branch prompt
|
||||
├── .ai_mail.local/ # mailbox
|
||||
├── apps/
|
||||
│ ├── <name>.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 <id> → reply <id> "msg"
|
||||
drone @flow create . "Subject" [dplan] # new plan (default FPLAN)
|
||||
drone @seedgo audit aipass @branch # standards audit (drop @branch = all)
|
||||
drone @seedgo checklist <file|dir> # 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 <path> "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.<agent>.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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<filename>."""
|
||||
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}
|
||||
@@ -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/<filename>."""
|
||||
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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user