feat(hooks): sound layer across the hook fleet + temporal grounding handler — sounds mirror the log (2465 green, audit 100, compass #157); new prompt/temporal.py injects live local date/time every turn, host-tz, wired both wires (hooks.json + provider manifest). Built by @hooks

This commit is contained in:
AIOSAI
2026-07-21 18:04:08 -07:00
parent c71f389409
commit 73baa0005f
24 changed files with 275 additions and 17 deletions
+5
View File
@@ -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",
+1
View File
@@ -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"},
+8
View File
@@ -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
@@ -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 |
+17 -1
View File
@@ -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."
}
}
+3 -2
View File
@@ -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 |
@@ -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)
@@ -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)
@@ -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"}
@@ -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:
@@ -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)
@@ -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}
@@ -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})
@@ -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}
@@ -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 {
@@ -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:
@@ -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):
@@ -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
@@ -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 (
@@ -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
@@ -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):
@@ -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
@@ -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]
+144
View File
@@ -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"])