From 73baa0005fbb3532c6d6c008d9643f63cd4a3896 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 21 Jul 2026 18:04:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20sound=20layer=20across=20the=20h?= =?UTF-8?q?ook=20fleet=20+=20temporal=20grounding=20handler=20=E2=80=94=20?= =?UTF-8?q?sounds=20mirror=20the=20log=20(2465=20green,=20audit=20100,=20c?= =?UTF-8?q?ompass=20#157);=20new=20prompt/temporal.py=20injects=20live=20l?= =?UTF-8?q?ocal=20date/time=20every=20turn,=20host-tz,=20wired=20both=20wi?= =?UTF-8?q?res=20(hooks.json=20+=20provider=20manifest).=20Built=20by=20@h?= =?UTF-8?q?ooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .aipass/hooks.json | 5 + .claude/provider_manifest.json | 1 + CHANGELOG.md | 8 + .../hooks/.aipass/aipass_local_prompt.md | 7 +- src/aipass/hooks/.seedgo/bypass.json | 18 ++- src/aipass/hooks/README.md | 5 +- .../apps/handlers/lifecycle/auto_process.py | 2 +- .../handlers/lifecycle/pre_compact_prep.py | 2 +- .../apps/handlers/lifecycle/session_start.py | 3 +- .../notification/telegram_response.py | 2 +- .../apps/handlers/prompt/compass_recall.py | 2 +- .../apps/handlers/prompt/context_gauge.py | 2 + .../apps/handlers/prompt/feedback_pulse.py | 1 + .../hooks/apps/handlers/prompt/temporal.py | 51 +++++++ .../apps/handlers/security/presence_gate.py | 2 +- src/aipass/hooks/tests/test_auto_process.py | 2 + src/aipass/hooks/tests/test_compass_recall.py | 1 + src/aipass/hooks/tests/test_context_gauge.py | 2 + src/aipass/hooks/tests/test_feedback_pulse.py | 19 +++ .../hooks/tests/test_pre_compact_prep.py | 1 + src/aipass/hooks/tests/test_presence_gate.py | 1 + src/aipass/hooks/tests/test_session_start.py | 1 + .../hooks/tests/test_telegram_response.py | 10 +- src/aipass/hooks/tests/test_temporal.py | 144 ++++++++++++++++++ 24 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 src/aipass/hooks/apps/handlers/prompt/temporal.py create mode 100644 src/aipass/hooks/tests/test_temporal.py diff --git a/.aipass/hooks.json b/.aipass/hooks.json index ac219000..2b05404a 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -61,6 +61,11 @@ "matcher": "", "timeout": 30 }, + "temporal": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.temporal.handle", + "matcher": "" + }, "user_message_relay": { "enabled": true, "handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle", diff --git a/.claude/provider_manifest.json b/.claude/provider_manifest.json index f6cfa660..d5f31e1f 100644 --- a/.claude/provider_manifest.json +++ b/.claude/provider_manifest.json @@ -15,6 +15,7 @@ {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"}, diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6f0876..57a0ee6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ PyPI version — not the changelog header. ## [2026-07-21] +**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror +the log across the hook fleet (prompt, lifecycle, notification, security +handlers) — audible liveness for the whole layer, verified live (2465 green, +audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler +injecting one line of local date/time/weekday/part-of-day every turn — live +clock each fire, host timezone via `astimezone()` (clones see their own local +time). Wired on both wires (`.aipass/hooks.json` + provider manifest). + **feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an existing `projects/` directory into a full AIPass project (registry, resident agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md index c28cd235..c1114f91 100644 --- a/src/aipass/hooks/.aipass/aipass_local_prompt.md +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -9,7 +9,7 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl ## What I Do - Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything -- Maintain 26 native handlers across 4 categories (prompt, security, lifecycle, notification) +- Maintain 27 native handlers across 4 categories (prompt, security, lifecycle, notification) - Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned) - Per-project config -- `.aipass/hooks.json` controls what fires per project - Log everything -- prax integration + JSONL diagnostics for every hook execution @@ -49,6 +49,7 @@ apps/ compass_recall.py # Governance recall injection feedback_pulse.py # 10-turn cadence feedback nudge (disabled default) context_gauge.py # Live transcript-fill nudge toward /prep + temporal.py # Weekday/date/time/tz/part-of-day, every turn persistent_alert.py # Advisory banners for .aipass/alerts.json security/ # Enforcement hooks presence_gate.py # Session presence gate (UserPromptSubmit + Stop release) @@ -77,14 +78,14 @@ apps/ diagnostics.py # JSONL diagnostics config logs/ engine.jsonl # JSONL diagnostics (every hook execution) -tests/ # 42 test files, 1206 tests +tests/ # 43 test files, 1249 tests ``` ## Handler Categories | Category | Count | Handlers | |----------|-------|----------| -| prompt | 8 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, persistent_alert | +| prompt | 9 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, temporal, persistent_alert | | security | 6 | presence_gate, edit_gate, git_gate, rm_gate, registry_gate, subagent_gate | | lifecycle | 7 | auto_fix, auto_watchdog, auto_process, compact, rollover, pre_compact_prep, session_start | | notification | 5 | announce, email, stop_sound, tool_sound, telegram_response | diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index c5c1b7e8..1b9f4ad0 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -131,6 +131,11 @@ "standard": "json_structure", "reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text." }, + { + "file": "apps/handlers/prompt/temporal.py", + "standard": "json_structure", + "reason": "No JSON operations - reads the system clock and outputs text. Wired in UserPromptSubmit.temporal (FPLAN-0348)." + }, { "file": "apps/handlers/security/edit_gate.py", "standard": "dead_code", @@ -1263,11 +1268,22 @@ "file": "apps/handlers/prompt/context_gauge.py", "standard": "json_structure", "reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence." + }, + { + "file": "apps/handlers/lifecycle/pre_compact_prep.py", + "standard": "handlers", + "reason": "Line 75: lazy cross-branch import of aipass.flow.apps.handlers.plan.get_open_plans — needed for the open-plan count in the AUTO-COMPACT SNAPSHOT. No module-layer equivalent exists to switch to: flow's own modules (close_plan.py) import this same handler directly rather than re-exporting it, and list_plans.py's module is a CLI display orchestrator, not a programmatic data source. Read-only cross-branch primitive read, same authorized pattern as ai_mail's dispatch_monitor.py broker-client import. Call is wrapped in try/except and degrades to None on any failure — never corrupts the snapshot." + }, + { + "file": "apps/handlers/prompt/compass_recall.py", + "standard": "handlers", + "reason": "Lines 49, 75, 84: lazy cross-branch imports of aipass.memory.apps.modules.governance and aipass.devpulse.apps.modules.compass. Surfacing other branches' rated decisions at prompt time IS this handler's job — not orchestration bleed but the designed integration point, same authorized cross-branch primitive pattern as ai_mail's dispatch_monitor.py. Whole handler wrapped in try/except and never blocks the prompt on failure." } ], "notes": { "removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", "dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.", - "dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses." + "dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses.", + "dplan_0253_audit_catch_2026-07-21": "Seedgo's handlers checker went AST-based and caught two lazy cross-branch imports missed by the old text-matching version: pre_compact_prep.py (flow.get_open_plans) and compass_recall.py (memory.governance, devpulse.compass). Both bypassed rather than restructured — no clean module-layer alternative exists for the former, and the latter's whole purpose is surfacing cross-branch data at prompt time. Same authorized-primitive precedent as ai_mail dispatch_monitor.py." } } diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index 981bb4d6..30d7c939 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -88,6 +88,7 @@ src/aipass/hooks/ │ │ │ ├── identity.py # Injects passport identity block │ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable) │ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window) +│ │ │ ├── temporal.py # Injects weekday/date/time/tz/part-of-day, every turn │ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json │ │ ├── security/ # Enforcement hooks │ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics) @@ -114,7 +115,7 @@ src/aipass/hooks/ │ └── diagnostics.py # JSONL logging for hook execution ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -└── tests/ # 1206 tests across 42 test files +└── tests/ # 1249 tests across 43 test files ``` ## How It Works @@ -135,7 +136,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im | Event | Hooks | Description | |---|---|---| -| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + auto-process + TG mirror | +| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, temporal, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + temporal + auto-process + TG mirror | | PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound | | PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog | | SubagentStop | subagent_gate | Seedgo validation | diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py index b8ca8da2..6d901631 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py @@ -67,7 +67,7 @@ def handle(hook_data: dict) -> dict: logger.info("[HOOKS] auto_process: no-op (nothing to process)") _mark_session_ran() - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "auto process"} except Exception as exc: logger.error("[HOOKS] auto_process: error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py b/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py index c5829725..ba238ba3 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py @@ -239,7 +239,7 @@ def handle(hook_data: dict) -> dict: stamped = _stamp_session_entry(branch_dir, snapshot, cap) logger.info("[HOOKS] pre_compact_prep: snapshot stamped=%s branch=%s", stamped, branch_dir.name) - return {"stdout": snapshot, "exit_code": 0} + return {"stdout": snapshot, "exit_code": 0, "sound": "pre compact prep"} except Exception as exc: logger.info("[HOOKS] pre_compact_prep: unexpected error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/session_start.py b/src/aipass/hooks/apps/handlers/lifecycle/session_start.py index d8bb21a3..dd2398c1 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/session_start.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/session_start.py @@ -37,5 +37,6 @@ def handle(hook_data: dict) -> dict: 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} - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "cadence reset"} diff --git a/src/aipass/hooks/apps/handlers/notification/telegram_response.py b/src/aipass/hooks/apps/handlers/notification/telegram_response.py index 14a730ad..8ae50ac0 100644 --- a/src/aipass/hooks/apps/handlers/notification/telegram_response.py +++ b/src/aipass/hooks/apps/handlers/notification/telegram_response.py @@ -599,7 +599,7 @@ def handle(hook_data: dict) -> dict: else: logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry") - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "telegram response"} def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None: diff --git a/src/aipass/hooks/apps/handlers/prompt/compass_recall.py b/src/aipass/hooks/apps/handlers/prompt/compass_recall.py index 2fc17eb6..84b23aa5 100644 --- a/src/aipass/hooks/apps/handlers/prompt/compass_recall.py +++ b/src/aipass/hooks/apps/handlers/prompt/compass_recall.py @@ -110,7 +110,7 @@ def handle(hook_data: dict) -> dict: mark_surfaced([c["id"] for c in approved]) json_handler.log_operation("compass_recall", {"count": len(approved)}) - return {"stdout": "\n".join(lines), "exit_code": 0} + return {"stdout": "\n".join(lines), "exit_code": 0, "sound": "compass recall"} except Exception as exc: logger.info("[HOOKS] compass_recall_unreachable: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/prompt/context_gauge.py b/src/aipass/hooks/apps/handlers/prompt/context_gauge.py index b0bdfdbb..6ace33ca 100644 --- a/src/aipass/hooks/apps/handlers/prompt/context_gauge.py +++ b/src/aipass/hooks/apps/handlers/prompt/context_gauge.py @@ -84,6 +84,7 @@ def handle(hook_data: dict) -> dict: "AND wrap up the current work item. Auto-compact is imminent." ), "exit_code": 0, + "sound": "context gauge", } if pct >= _NUDGE_THRESHOLD_PCT and not _already_fired(session_id, "80"): @@ -95,6 +96,7 @@ def handle(hook_data: dict) -> dict: "before auto-compact takes the choice away." ), "exit_code": 0, + "sound": "context gauge", } return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py b/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py index 170f81fa..031123f1 100644 --- a/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py +++ b/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py @@ -87,6 +87,7 @@ def handle(hook_data: dict) -> dict: cwd = hook_data.get("cwd", "") if _is_disabled(cwd or None): + logger.info("[HOOKS] feedback_pulse: skipped (disabled via .aipass/feedback_off sentinel)") return {"stdout": "", "exit_code": 0} json_handler.log_operation("feedback_pulse", {"turn": turn}) diff --git a/src/aipass/hooks/apps/handlers/prompt/temporal.py b/src/aipass/hooks/apps/handlers/prompt/temporal.py new file mode 100644 index 00000000..d1af2328 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/temporal.py @@ -0,0 +1,51 @@ +# =================== AIPass ==================== +# Name: temporal.py +# Version: 1.0.0 +# Description: Every-turn local date/time/weekday/part-of-day injection (UserPromptSubmit) +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-07-21 +# Modified: 2026-07-21 +# ============================================= + +"""Tiny always-on temporal grounding line — no cadence gating, fires every +turn. Reads the live clock each fire (no caching) and injects one short line: +weekday, date, 24h time, tz abbreviation, part of day. Timezone comes from +the host system (astimezone()) — never hardcoded, so clones running outside +Vancouver still show their own local time. Keep it to one line — every-turn +cost matters.""" + +from datetime import datetime + +from aipass.prax.apps.modules.logger import system_logger as logger + + +def _part_of_day(hour: int) -> str: + """Bucket an hour (0-23) into morning/afternoon/evening/night.""" + if 5 <= hour < 12: + return "morning" + if 12 <= hour < 17: + return "afternoon" + if 17 <= hour < 22: + return "evening" + return "night" + + +def _format_line(now: datetime) -> str: + weekday = now.strftime("%a") + date = now.strftime("%Y-%m-%d") + time_str = now.strftime("%H:%M") + tz = now.strftime("%Z") + part = _part_of_day(now.hour) + clock = f"{weekday} {date} {time_str} {tz}" if tz else f"{weekday} {date} {time_str}" + return f"Temporal: {clock} ({part})" + + +def handle(hook_data: dict) -> dict: + """Inject the current local temporal line — every turn, no cadence.""" + try: + line = _format_line(datetime.now().astimezone()) + return {"stdout": line, "exit_code": 0, "sound": "temporal"} + except Exception as exc: + logger.info("[HOOKS] temporal: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/security/presence_gate.py b/src/aipass/hooks/apps/handlers/security/presence_gate.py index d077ed62..8597415d 100644 --- a/src/aipass/hooks/apps/handlers/security/presence_gate.py +++ b/src/aipass/hooks/apps/handlers/security/presence_gate.py @@ -131,7 +131,7 @@ def handle(hook_data: dict) -> dict: if _OBSERVE_ONLY: logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason) - return _ALLOW + return {**_ALLOW, "sound": "presence gate"} logger.warning("[presence_gate] BLOCKED: %s", reason) return { diff --git a/src/aipass/hooks/tests/test_auto_process.py b/src/aipass/hooks/tests/test_auto_process.py index ddb0d8b1..09a43999 100644 --- a/src/aipass/hooks/tests/test_auto_process.py +++ b/src/aipass/hooks/tests/test_auto_process.py @@ -43,6 +43,7 @@ class TestAutoProcessHandler: assert result["exit_code"] == 0 assert result["stdout"] == "" + assert result["sound"] == "auto process" def test_calls_memory_auto_process_module(self): from aipass.hooks.apps.handlers.lifecycle.auto_process import handle @@ -168,6 +169,7 @@ class TestAutoProcessHandler: result = handle({"tool_name": "Bash", "cwd": "/tmp"}) assert result["exit_code"] == 0 + assert result["sound"] == "auto process" class TestSessionGuard: diff --git a/src/aipass/hooks/tests/test_compass_recall.py b/src/aipass/hooks/tests/test_compass_recall.py index d9b4b35d..92f4a9c5 100644 --- a/src/aipass/hooks/tests/test_compass_recall.py +++ b/src/aipass/hooks/tests/test_compass_recall.py @@ -99,6 +99,7 @@ class TestCompassRecallHandler: assert result["exit_code"] == 0 assert "[GOOD] #56:" in result["stdout"] assert "Never hardcode config in prompts" in result["stdout"] + assert result["sound"] == "compass recall" mock_mark.assert_called_once_with([56]) def test_formats_bad_rating(self, tmp_path): diff --git a/src/aipass/hooks/tests/test_context_gauge.py b/src/aipass/hooks/tests/test_context_gauge.py index 7a018cad..6424673e 100644 --- a/src/aipass/hooks/tests/test_context_gauge.py +++ b/src/aipass/hooks/tests/test_context_gauge.py @@ -63,6 +63,7 @@ class TestContextGaugeHandle: assert "CONTEXT GAUGE" in result["stdout"] assert "run /prep NOW" in result["stdout"] assert "wrap up the current work item" not in result["stdout"] + assert result["sound"] == "context gauge" def test_fires_escalate_at_95_percent_of_trigger(self, tmp_path, monkeypatch): from aipass.hooks.apps.handlers.prompt.context_gauge import handle @@ -77,6 +78,7 @@ class TestContextGaugeHandle: assert result["exit_code"] == 0 assert "CONTEXT GAUGE" in result["stdout"] assert "wrap up the current work item" in result["stdout"] + assert result["sound"] == "context gauge" def test_fires_once_per_threshold_per_session(self, tmp_path, monkeypatch): from aipass.hooks.apps.handlers.prompt.context_gauge import handle diff --git a/src/aipass/hooks/tests/test_feedback_pulse.py b/src/aipass/hooks/tests/test_feedback_pulse.py index 9de105cb..cfc2b4a7 100644 --- a/src/aipass/hooks/tests/test_feedback_pulse.py +++ b/src/aipass/hooks/tests/test_feedback_pulse.py @@ -107,6 +107,25 @@ class TestFeedbackPulseHandler: assert result["stdout"] == "" + def test_disabled_logs_distinguishing_line(self, tmp_path, caplog): + import logging + + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled", + return_value=True, + ), + caplog.at_level(logging.INFO), + ): + for i in range(11): + self._handler()({"session_id": "test-disabled-log"}) + + assert "skipped (disabled via .aipass/feedback_off sentinel)" in caplog.text + def test_output_is_one_line(self, tmp_path): result = {"stdout": "", "exit_code": 0} with ( diff --git a/src/aipass/hooks/tests/test_pre_compact_prep.py b/src/aipass/hooks/tests/test_pre_compact_prep.py index 79d76f4d..67652903 100644 --- a/src/aipass/hooks/tests/test_pre_compact_prep.py +++ b/src/aipass/hooks/tests/test_pre_compact_prep.py @@ -40,6 +40,7 @@ class TestHandle: assert result["exit_code"] == 0 assert "AUTO-COMPACT SNAPSHOT" in result["stdout"] + assert result["sound"] == "pre compact prep" data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8")) assert len(data["sessions"]) == 2 diff --git a/src/aipass/hooks/tests/test_presence_gate.py b/src/aipass/hooks/tests/test_presence_gate.py index 86d18d5a..a4b670e1 100644 --- a/src/aipass/hooks/tests/test_presence_gate.py +++ b/src/aipass/hooks/tests/test_presence_gate.py @@ -170,6 +170,7 @@ class TestHandle: ): result = presence_gate.handle({}) assert result["exit_code"] == 0 + assert result["sound"] == "presence gate" def test_gate_error_allows(self): with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): diff --git a/src/aipass/hooks/tests/test_session_start.py b/src/aipass/hooks/tests/test_session_start.py index 67d4b485..e4191c02 100644 --- a/src/aipass/hooks/tests/test_session_start.py +++ b/src/aipass/hooks/tests/test_session_start.py @@ -49,6 +49,7 @@ class TestSessionStartHandler: result = handle({"source": "startup", "session_id": "test-session"}) assert result["exit_code"] == 0 + assert result["sound"] == "cadence reset" data = json.loads(state_file.read_text()) assert data["turn"] == -1 diff --git a/src/aipass/hooks/tests/test_telegram_response.py b/src/aipass/hooks/tests/test_telegram_response.py index f1eabf78..37b35973 100644 --- a/src/aipass/hooks/tests/test_telegram_response.py +++ b/src/aipass/hooks/tests/test_telegram_response.py @@ -857,7 +857,7 @@ class TestHandleIntegration: } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert pending_file.exists() updated = json.loads(pending_file.read_text(encoding="utf-8")) assert updated["delivered"] is True @@ -902,7 +902,7 @@ class TestHandleIntegration: } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert pending_file.exists() updated = json.loads(pending_file.read_text(encoding="utf-8")) assert "delivered" not in updated @@ -996,7 +996,7 @@ class TestHandleIntegration: } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert extract_call_count >= 2 assert pending_file.exists() updated = json.loads(pending_file.read_text(encoding="utf-8")) @@ -1035,7 +1035,7 @@ class TestHandleIntegration: } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert not pending_file.exists() def test_already_delivered_skips_fallback(self, tmp_path): @@ -1989,7 +1989,7 @@ class TestHandleMirrorIntegration: } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert len(sent_texts) == 1 assert "You: What is this?" in sent_texts[0] assert "This is the answer." in sent_texts[0] diff --git a/src/aipass/hooks/tests/test_temporal.py b/src/aipass/hooks/tests/test_temporal.py new file mode 100644 index 00000000..4b588e80 --- /dev/null +++ b/src/aipass/hooks/tests/test_temporal.py @@ -0,0 +1,144 @@ +# =================== AIPass ==================== +# Name: test_temporal.py +# Version: 1.0.0 +# Description: Tests for temporal prompt handler +# Branch: hooks +# Created: 2026-07-21 +# Modified: 2026-07-21 +# ============================================= + +"""Tests for handlers/prompt/temporal.py.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +_PDT = timezone(timedelta(hours=-7), "PDT") +_UTC = timezone(timedelta(hours=0), "UTC") + + +class TestPartOfDay: + def test_morning_lower_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(5) == "morning" + + def test_morning_upper_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(11) == "morning" + + def test_afternoon_lower_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(12) == "afternoon" + + def test_afternoon_upper_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(16) == "afternoon" + + def test_evening_lower_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(17) == "evening" + + def test_evening_upper_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(21) == "evening" + + def test_night_wraps_midnight_upper(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(22) == "night" + + def test_night_wraps_midnight_lower(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(0) == "night" + + def test_night_upper_bound(self): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(4) == "night" + + @pytest.mark.parametrize("hour", range(24)) + def test_every_hour_has_exactly_one_bucket(self, hour): + from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day + + assert _part_of_day(hour) in {"morning", "afternoon", "evening", "night"} + + +class TestFormatLine: + def test_shape_with_tz(self): + from aipass.hooks.apps.handlers.prompt.temporal import _format_line + + line = _format_line(datetime(2026, 7, 21, 9, 42, tzinfo=_PDT)) + assert line == "Temporal: Tue 2026-07-21 09:42 PDT (morning)" + + def test_afternoon_example_different_tz(self): + from aipass.hooks.apps.handlers.prompt.temporal import _format_line + + line = _format_line(datetime(2026, 7, 21, 14, 5, tzinfo=_UTC)) + assert line == "Temporal: Tue 2026-07-21 14:05 UTC (afternoon)" + + def test_night_example_after_midnight(self): + from aipass.hooks.apps.handlers.prompt.temporal import _format_line + + line = _format_line(datetime(2026, 7, 22, 0, 30, tzinfo=_PDT)) + assert line == "Temporal: Wed 2026-07-22 00:30 PDT (night)" + + def test_naive_datetime_omits_tz(self): + from aipass.hooks.apps.handlers.prompt.temporal import _format_line + + line = _format_line(datetime(2026, 7, 21, 9, 42)) + assert line == "Temporal: Tue 2026-07-21 09:42 (morning)" + + def test_is_a_single_line(self): + from aipass.hooks.apps.handlers.prompt.temporal import _format_line + + line = _format_line(datetime(2026, 7, 21, 9, 42, tzinfo=_PDT)) + assert "\n" not in line + + +class TestHandle: + def test_injects_temporal_line_every_call(self): + from aipass.hooks.apps.handlers.prompt.temporal import handle + + result = handle({}) + assert result["exit_code"] == 0 + assert result["stdout"].startswith("Temporal: ") + assert result["sound"] == "temporal" + + def test_uses_host_local_tz_not_a_hardcoded_one(self): + # Test host's own zone — never assume PDT/UTC/anything specific, + # a clone running in another zone must see its own local time. + from aipass.hooks.apps.handlers.prompt.temporal import handle + + expected_tz = datetime.now().astimezone().strftime("%Z") + result = handle({}) + if expected_tz: + assert expected_tz in result["stdout"] + + def test_no_cadence_gating_fires_on_repeated_calls(self): + from aipass.hooks.apps.handlers.prompt.temporal import handle + + first = handle({"session_id": "s1"}) + second = handle({"session_id": "s1"}) + assert first["stdout"] != "" + assert second["stdout"] != "" + + def test_never_raises_on_unexpected_error(self, monkeypatch): + from aipass.hooks.apps.handlers.prompt import temporal + + def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(temporal, "_format_line", _boom) + result = temporal.handle({"session_id": "s-err"}) + assert result == {"stdout": "", "exit_code": 0} + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])