diff --git a/AGENTS.md b/AGENTS.md index 329a71c..c5768d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,9 +131,44 @@ CODEC has a background process (`codec-observer` PM2 service, `codec_observer.py Implementation: `codec_observer.py` (RingBuffer + poll + injection helper + run_daemon), wired into `codec_dashboard.py:chat_completion` and `codec_voice.py:generate_response`. Debug PWA endpoint at `GET /api/observer/buffer?debug=1` returns metadata-only summary (raw entries never exposed even to authed callers; emits `observer_buffer_inspected` per call). +### Trigger System (Phase 2 Step 6) + +CODEC skills can declaratively auto-fire on observer signals. A skill adds a `SKILL_OBSERVATION_TRIGGER` dict alongside its existing `SKILL_TRIGGERS` list: + +```python +SKILL_OBSERVATION_TRIGGER = { + "type": "window_title_match", # or clipboard_pattern / file_change / time / compound + "pattern": r"Stripe — Dashboard", + "cooldown_seconds": 600, # min seconds between fires (RAM-only state) + "require_confirmation": True, # PWA approval gate before fire + "destructive": False, # if True, routes through Step 3 §1.7 strict-consent +} +``` + +After every `codec_observer.poll()`, `codec_triggers.evaluate(snapshot)` walks the registered triggers, matches each against the snapshot, and dispatches matches that pass cooldown + consent gates through the existing `codec_dispatch.run_skill` chokepoint (which Step 2's `run_with_hooks` already wraps — every fire is observable by plugins). + +**5 trigger types**: `window_title_match` (regex on active title), `clipboard_pattern` (regex on clipboard preview), `file_change` (glob over recent_files), `time` (cron-like "M H D Mo W", ≥1min granularity), `compound` (recursive AND/OR). + +**Cooldown**: per-trigger last-fired timestamp in RAM (process restart resets all). Trigger key = `:` — editing a pattern resets cooldown via key change. + +**Per-trigger kill switch**: persistent at `~/.codec/triggers_killed.json`. Toggled via PWA `POST /api/triggers/{key}/kill`. Killed triggers are skipped silently (no `trigger_blocked` audit emit, to avoid spam from popular killed patterns). + +**Global kill switch**: `TRIGGERS_ENABLED=false` env var on `codec-observer` skips evaluation entirely. + +**Step 6 ships ZERO triggers** — only the plumbing. Skills opt in one-by-one. Same trust model as plugins (user-curated local Python). At merge time, `evaluate()` iterates over zero registered triggers and exits in <1ms. + +**3 audit events**: `trigger_evaluated` (info, on match), `trigger_fired` (info, on dispatch), `trigger_blocked` (warning, with `block_reason`). + +**PWA endpoints**: +- `GET /api/triggers` — list all registered triggers + state +- `GET /api/triggers/{key}` — detail with cooldown_remaining +- `POST /api/triggers/{key}/kill` — toggle kill state + +Implementation: `codec_triggers.py` (Trigger dataclass, validation, matchers, dispatch), `codec_skill_registry.py` extension (AST-extracts `SKILL_OBSERVATION_TRIGGER`), `codec_observer.py` integration (calls `evaluate()` after each poll, try/except so failures never break polling), `routes/triggers.py` (PWA endpoints). + ### Other known gaps (tracked for Phase 2 follow-on) - No formal teammate / sub-agent recursion — Crew is the only multi-agent primitive -- Step 6 (Triggers) and Step 7 (Shift Report Crew) — Phase 2 Steps still pending +- Step 7 (Shift Report Crew) — final Phase 2 step still pending ## 4. Skill system @@ -262,6 +297,17 @@ Four new event names exported from `codec_audit.py` for the Continuous Observati `PHASE2_STEP5_EVENTS` frozenset exposed for analyzer breakdown. `observation_tick` is METADATA-ONLY by design — no titles, no OCR text, no clipboard content, no file paths leak to `~/.codec/audit.log`. +### Phase 2 Step 6 audit events (Trigger System) +Three new event names. `trigger_evaluated` fires only when a pattern matches (pre-cooldown, pre-consent — silent on no-match to avoid audit spam). `trigger_fired` is the actual dispatch. `trigger_blocked` fires for any non-firing reason except `killed` (silent). All inherit the wrapping observer poll's `correlation_id`. + +| Event | Source | level | extra fields | +|---|---|---|---| +| `trigger_evaluated` | `codec-triggers` | info | `trigger_key`, `skill_name`, `trigger_type`, `match_summary` | +| `trigger_fired` | `codec-triggers` | info | `trigger_key`, `skill_name`, `trigger_type`, `dispatch_correlation_id` | +| `trigger_blocked` | `codec-triggers` | warning | `trigger_key`, `skill_name`, `trigger_type`, `block_reason` (`cooldown` \| `user_skipped` \| `confirmation_timeout` \| `ambiguous_consent`). NOTE: `killed` reason is intentionally NOT emitted to keep audit clean. | + +`PHASE2_STEP6_EVENTS` frozenset exposed. + ### Notifications (`~/.codec/notifications.json`) Four sources can produce notifications: scheduler (crew completion), heartbeat (threshold alert), autopilot (ambient trigger), and Phase 1 Step 3's AskUserQuestion (`type="question"`). All write through `routes/_shared.py:51-127` except AskUserQuestion which writes via `codec_ask_user._write_question_notification`. @@ -402,7 +448,10 @@ These zones break running infrastructure if changed without coordination. NEVER - `~/.codec/config.json:ask_user.{timeout_seconds, consent_strict_max_attempts}` and `:stuck.{window, repeat_threshold, escalation_action}` and `:step_budget.{chat, voice}` — Phase 1 Step 3 tunables. Bumping `step_budget.chat` to 8 or 10 is the documented "tune up before tuning out" pressure-relief valve, but don't touch the others without referencing the design doc rationale (§1.2 Q1, §1.7, §2.3, §3.2). - `~/.codec/observation_summaries/` (Phase 2 Step 5) — populated only by `codec_observer.persist_for_shift_report()`. Do not add files manually; the Step 7 shift-report assembly relies on the time-stamped naming convention. Safe to delete the whole directory if you want to wipe the persisted history. - `OBSERVER_ENABLED` env var (Phase 2 Step 5, default `true`). Setting `false` disables both the polling loop AND the prompt injection. No separate injection kill switch — the buffer is always populated when enabled, only injection is gated. -- `~/.codec/config.json:observer.{...}` — Phase 2 Step 5 tunables (cadence_active_s, cadence_idle_s, idle_threshold_s, buffer_depth_min, ocr_timeout_ms, ocr_retry_timeout_ms, reset_on_long_idle, reset_idle_threshold_s, summary_max_tokens, poll_slow_threshold_ms, stop_nouns). Don't tune the cadences below 30s without considering OCR cost. +- `~/.codec/config.json:observer.{...}` — Phase 2 Step 5 tunables (cadence_active_s, cadence_idle_s, idle_threshold_s, buffer_depth_min, ocr_enabled, ocr_timeout_ms, ocr_retry_timeout_ms, reset_on_long_idle, reset_idle_threshold_s, summary_max_tokens, poll_slow_threshold_ms, stop_nouns). Don't tune the cadences below 30s without considering OCR cost. `ocr_enabled: false` is the recommended baseline if Screen Recording permissions aren't granted to the PM2 child process — bypasses screencapture entirely (see incident `INCIDENT-2026-05-01-spurious-skill-fires.md` and the Step 5 hotfix in PR #10). +- `~/.codec/triggers_killed.json` (Phase 2 Step 6) — persistent per-trigger kill state. Atomic-write owned by `codec_triggers.set_killed()`; do not edit by hand (the trigger keys are content-hashed and need to match what `discover_triggers()` computes). Use the PWA `POST /api/triggers/{key}/kill` endpoint instead. +- `TRIGGERS_ENABLED` env var (Phase 2 Step 6, default `true`). Setting `false` skips trigger evaluation entirely; observer keeps polling. Per-trigger kill switch via PWA is the finer knob. +- `SKILL_OBSERVATION_TRIGGER` declaration in skill files (Phase 2 Step 6) — adding one to a skill makes it auto-fire on observer signals. **High-impact change** — review the cooldown / require_confirmation / destructive flags carefully. Same trust model as plugins. ## 11. Working with this repo as a coding agent diff --git a/codec_audit.py b/codec_audit.py index 1560af3..41aab7d 100644 --- a/codec_audit.py +++ b/codec_audit.py @@ -202,6 +202,34 @@ ) +# ── Phase 2 Step 6 event names (Trigger System) ─────────────────────────────── +# Per docs/PHASE2-STEP6-DESIGN.md §3. trigger_evaluated and trigger_fired are +# `level="info"` (operational); trigger_blocked is `level="warning"` because +# block_reason values flag user-action-required or consent-failure states. +# All inherit `correlation_id` from the wrapping observer poll's cid. +TRIGGER_EVALUATED = "trigger_evaluated" +TRIGGER_FIRED = "trigger_fired" +TRIGGER_BLOCKED = "trigger_blocked" + +PHASE2_STEP6_EVENTS = frozenset({ + TRIGGER_EVALUATED, TRIGGER_FIRED, TRIGGER_BLOCKED, +}) + +# Step 6 event-specific extra-field reservations. +TRIGGER_EXTRA_FIELDS = ( + "trigger_key", # ":" + "skill_name", # str + "trigger_type", # window_title_match | clipboard_pattern | + # file_change | time | compound + "match_summary", # short, on trigger_evaluated + "dispatch_correlation_id", # on trigger_fired only + "block_reason", # on trigger_blocked only: + # cooldown | user_skipped | + # confirmation_timeout | + # ambiguous_consent | killed +) + + # ── Helpers ──────────────────────────────────────────────────────────────────── def _truncate(s, max_len: int = _PREVIEW_MAX) -> str: """Truncate a string to `max_len` chars. None/non-str → ''. Never raises.""" diff --git a/codec_dashboard.py b/codec_dashboard.py index 77045c1..fbd9de2 100644 --- a/codec_dashboard.py +++ b/codec_dashboard.py @@ -307,12 +307,21 @@ async def dispatch(self, request, call_next): from routes.agents import router as agents_router from routes.memory import router as memory_router from routes.websocket import router as websocket_router +# Phase 2 Step 6 — Trigger System PWA endpoints (auth-gated by /api/* middleware). +try: + from routes.triggers import router as triggers_router + _has_triggers = True +except Exception as _e: + log.debug(f"[triggers] routes not loaded: {_e}") + _has_triggers = False app.include_router(auth_router) app.include_router(skills_router) app.include_router(agents_router) app.include_router(memory_router) app.include_router(websocket_router) +if _has_triggers: + app.include_router(triggers_router) # ═══════════════════════════════════════════════════════════════ diff --git a/codec_observer.py b/codec_observer.py index 57bd76e..c55a5ee 100644 --- a/codec_observer.py +++ b/codec_observer.py @@ -557,6 +557,17 @@ def poll(buffer: Optional[RingBuffer] = None, _emit_observation_tick(snapshot, cadence, poll_duration_ms, len(buffer), float(cfg["poll_slow_threshold_ms"])) + # Phase 2 Step 6 — evaluate registered triggers against this snapshot. + # Inline (not a separate PM2 service) — observer poll is the only event + # source, so triggers piggyback on the same cadence. Try/except so + # trigger failures NEVER break observer polling. + if emit_audit: # only fire triggers from real polls, not test polls + try: + from codec_triggers import evaluate as _eval_triggers + _eval_triggers(snapshot) + except Exception as e: + log.debug("[observer] trigger evaluation failed (non-fatal): %s", e) + return snapshot diff --git a/codec_skill_registry.py b/codec_skill_registry.py index ac37964..9c4a266 100644 --- a/codec_skill_registry.py +++ b/codec_skill_registry.py @@ -38,6 +38,11 @@ def _extract_metadata(filepath: str) -> Optional[Dict[str, Any]]: "SKILL_DESCRIPTION", "SKILL_TRIGGERS", "SKILL_MCP_EXPOSE", + # Phase 2 Step 6 — declarative auto-fire trigger (Q3). + # AST extraction; validation happens in codec_triggers. + "SKILL_OBSERVATION_TRIGGER", + # Phase 2 Step 5 §X — skill-flag injection override. + "SKILL_NEEDS_OBSERVATION", ): try: meta[target.id] = ast.literal_eval(node.value) @@ -120,6 +125,13 @@ def get_mcp_expose(self, name: str) -> Optional[bool]: meta = self._meta.get(name, {}) return meta.get("SKILL_MCP_EXPOSE", None) + def get_observation_trigger(self, name: str) -> Optional[Dict[str, Any]]: + """Phase 2 Step 6 — return the SKILL_OBSERVATION_TRIGGER dict + for a skill, or None if not declared. Validation happens in + codec_triggers; this just surfaces what AST extracted.""" + meta = self._meta.get(name, {}) + return meta.get("SKILL_OBSERVATION_TRIGGER", None) + # ── Lazy module loading ───────────────────────────────────────────── def load(self, name: str) -> Optional[Any]: diff --git a/codec_triggers.py b/codec_triggers.py new file mode 100644 index 0000000..1691ddb --- /dev/null +++ b/codec_triggers.py @@ -0,0 +1,691 @@ +"""CODEC Trigger System (Phase 2 Step 6). + +Skills declare an `SKILL_OBSERVATION_TRIGGER` dict alongside their +existing `SKILL_TRIGGERS` list. After every codec_observer poll, this +module evaluates the snapshot against all registered triggers and +optionally dispatches matches through the existing +`codec_dispatch.run_skill` chokepoint (which Step 2's `run_with_hooks` +already wraps). + +──────────────────────────────────────────────────────────────────────── +Architecture +──────────────────────────────────────────────────────────────────────── + + codec_observer.poll() + │ + ▼ + evaluate(snapshot) + │ + ├─ for each Trigger discovered from SkillRegistry: + │ ├─ matches snapshot? → no, continue + │ ├─ killed via PWA? → yes, skip silently + │ ├─ cooldown elapsed? → no, emit trigger_blocked + skip + │ │ + │ └─ emit trigger_evaluated, then: + │ destructive=True → codec_ask_user.ask(destructive=True) + │ require_confirmation=True → PWA notification + wait + │ else → fire silently + │ + ▼ + _dispatch(trigger, snapshot) + │ + ▼ + codec_dispatch.run_skill(skill, task=) + │ + ▼ (existing chokepoint, hooks fire as usual) + skill returns → emit trigger_fired + +──────────────────────────────────────────────────────────────────────── +Trust model +──────────────────────────────────────────────────────────────────────── + +Same as plugins (Phase 1 Step 2): user-curated local Python. +SKILL_OBSERVATION_TRIGGER is data the skill author writes; CODEC just +honors it. No marketplace, no auto-install. Step 6 ships ZERO triggers +— only the plumbing. + +Every fire goes through `codec_dispatch.run_skill`, which means: + - Step 2 plugins observe via post_tool / pre_tool / on_error + - Step 4 self_improve plugin captures signals + - Step 5 observer continues to capture state + - Step 3 step budget applies + - Step 3 destructive-consent gate applies (when destructive=True) + +──────────────────────────────────────────────────────────────────────── +Safety +──────────────────────────────────────────────────────────────────────── + +1. Default cooldown is 600s (10 min) — opinion: skill authors should + not declare cooldowns under 60s without a strong reason. +2. Default require_confirmation is True — opt-in to silent fires. +3. destructive=True routes through Step 3 §1.7 strict-consent gate + (literal verb-match; two-strike timeout = ambiguous_consent). +4. Per-trigger kill switch persists at ~/.codec/triggers_killed.json. +5. Global kill switch: TRIGGERS_ENABLED env var (default true). +6. Tests MUST mock codec_dispatch.run_skill — never fire real skills. +""" +from __future__ import annotations + +import fnmatch +import hashlib +import json +import logging +import os +import re +import secrets +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +from codec_audit import ( + TRIGGER_EVALUATED, + TRIGGER_FIRED, + TRIGGER_BLOCKED, + log_event as _log_event, +) + +log = logging.getLogger("codec_triggers") + +# ── Storage ─────────────────────────────────────────────────────────────────── +_KILLED_PATH = Path(os.path.expanduser("~/.codec/triggers_killed.json")) +_KILLED_SCHEMA = 1 +_KILLED_LOCK = threading.Lock() + +# ── Module-level state ──────────────────────────────────────────────────────── +# Per-trigger last-fired timestamp (RAM only — process restart resets). +_LAST_FIRED: Dict[str, float] = {} +_LAST_FIRED_LOCK = threading.Lock() + +# Cached killed-keys set; reloaded from disk lazily. +_KILLED_CACHE: Optional[set] = None +_KILLED_CACHE_LOCK = threading.Lock() + + +# ── Kill switch ─────────────────────────────────────────────────────────────── +def _enabled() -> bool: + """Read TRIGGERS_ENABLED env var (default true). Read each call so + PM2 restart with a different env value takes effect without code + change.""" + val = (os.environ.get("TRIGGERS_ENABLED") or "true").strip().lower() + return val not in ("false", "0", "no", "off") + + +# ── Validation ──────────────────────────────────────────────────────────────── +_VALID_TYPES = frozenset({ + "window_title_match", "clipboard_pattern", "file_change", "time", + "compound", +}) + + +def _validate_trigger_dict(d: Any) -> Tuple[bool, str]: + """Returns (ok, reason). Reason is empty string when ok.""" + if not isinstance(d, dict): + return (False, f"trigger must be dict, got {type(d).__name__}") + required = {"type", "pattern", "cooldown_seconds", + "require_confirmation", "destructive"} + missing = required - set(d.keys()) + if missing: + return (False, f"missing required keys: {sorted(missing)}") + if d["type"] not in _VALID_TYPES: + return (False, f"unknown type {d['type']!r}; expected {sorted(_VALID_TYPES)}") + if not isinstance(d["cooldown_seconds"], int) or d["cooldown_seconds"] < 0: + return (False, "cooldown_seconds must be non-negative int") + if not isinstance(d["require_confirmation"], bool): + return (False, "require_confirmation must be bool") + if not isinstance(d["destructive"], bool): + return (False, "destructive must be bool") + if d["type"] == "compound": + pat = d["pattern"] + if not isinstance(pat, dict) or pat.get("op") not in ("and", "or"): + return (False, "compound pattern must be {op: 'and'|'or', children: [...]}") + children = pat.get("children") + if not isinstance(children, list) or not children: + return (False, "compound pattern must have non-empty children list") + for child in children: + ok, why = _validate_trigger_dict({**child, + **{"cooldown_seconds": 0, + "require_confirmation": False, + "destructive": False}}) + if not ok: + return (False, f"compound child invalid: {why}") + return (True, "") + + +# ── Trigger dataclass ───────────────────────────────────────────────────────── +@dataclass(frozen=True) +class Trigger: + """Validated trigger dict + stable hash key. Constructed from a + skill module's SKILL_OBSERVATION_TRIGGER constant.""" + skill_name: str + type: str + pattern: Any + cooldown_seconds: int + require_confirmation: bool + destructive: bool + key: str # ":" + raw: dict = field(default_factory=dict, repr=False, compare=False) + + @classmethod + def from_dict(cls, skill_name: str, d: dict) -> Optional["Trigger"]: + ok, why = _validate_trigger_dict(d) + if not ok: + log.warning("Invalid trigger for skill %s: %s", skill_name, why) + return None + # Stable hash over the canonical dict serialization + canonical = json.dumps(d, sort_keys=True, default=str) + h = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:8] + return cls( + skill_name=skill_name, + type=d["type"], + pattern=d["pattern"], + cooldown_seconds=int(d["cooldown_seconds"]), + require_confirmation=bool(d["require_confirmation"]), + destructive=bool(d["destructive"]), + key=f"{skill_name}:{h}", + raw=dict(d), + ) + + def short_summary(self) -> str: + if self.type == "window_title_match": + return f"window~{str(self.pattern)[:40]}" + if self.type == "clipboard_pattern": + return f"clipboard~{str(self.pattern)[:40]}" + if self.type == "file_change": + return f"file~{str(self.pattern)[:60]}" + if self.type == "time": + return f"time~{self.pattern}" + if self.type == "compound": + op = self.pattern.get("op", "?") + n = len(self.pattern.get("children", [])) + return f"compound({op}, {n} children)" + return self.type + + +# ── Discovery ───────────────────────────────────────────────────────────────── +def discover_triggers(registry) -> List[Trigger]: + """Walk the skill registry, extract validated SKILL_OBSERVATION_TRIGGER + dicts. Returns the list of Trigger instances. Skills whose trigger + fails validation are logged and skipped.""" + triggers: List[Trigger] = [] + try: + names = registry.names() + except Exception: + return triggers + for name in names: + try: + trig_dict = registry.get_observation_trigger(name) + except Exception as e: + log.debug("get_observation_trigger(%s) failed: %s", name, e) + continue + if trig_dict is None: + continue + t = Trigger.from_dict(name, trig_dict) + if t is not None: + triggers.append(t) + return triggers + + +# ── Killed-keys persistence ─────────────────────────────────────────────────── +def _load_killed() -> set: + """Read killed_keys set from disk. Cached after first call; use + _refresh_killed_cache() to invalidate.""" + global _KILLED_CACHE + with _KILLED_CACHE_LOCK: + if _KILLED_CACHE is not None: + return set(_KILLED_CACHE) + try: + with open(_KILLED_PATH) as f: + data = json.load(f) + keys = set(data.get("killed_keys", [])) + except (FileNotFoundError, json.JSONDecodeError, OSError): + keys = set() + _KILLED_CACHE = keys + return set(keys) + + +def _refresh_killed_cache() -> None: + """Invalidate the killed-keys cache. Called after writes.""" + global _KILLED_CACHE + with _KILLED_CACHE_LOCK: + _KILLED_CACHE = None + + +def is_killed(trigger_key: str) -> bool: + return trigger_key in _load_killed() + + +def set_killed(trigger_key: str, killed: bool) -> None: + """Toggle a trigger's killed state. Atomic write via tmp+rename.""" + with _KILLED_LOCK: + try: + with open(_KILLED_PATH) as f: + data = json.load(f) + if not isinstance(data, dict): + data = {"killed_keys": [], "schema": _KILLED_SCHEMA} + except (FileNotFoundError, json.JSONDecodeError, OSError): + data = {"killed_keys": [], "schema": _KILLED_SCHEMA} + keys = set(data.get("killed_keys", [])) + if killed: + keys.add(trigger_key) + else: + keys.discard(trigger_key) + data["killed_keys"] = sorted(keys) + data["schema"] = _KILLED_SCHEMA + _KILLED_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = _KILLED_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2)) + os.replace(tmp, _KILLED_PATH) + _refresh_killed_cache() + + +# ── Cooldown ────────────────────────────────────────────────────────────────── +def cooldown_remaining(trigger_key: str, cooldown_seconds: int) -> float: + """Returns seconds until trigger can fire again. 0.0 means ready.""" + with _LAST_FIRED_LOCK: + last = _LAST_FIRED.get(trigger_key, 0) + elapsed = time.time() - last + remaining = float(cooldown_seconds) - elapsed + return max(0.0, remaining) + + +def mark_fired(trigger_key: str) -> None: + with _LAST_FIRED_LOCK: + _LAST_FIRED[trigger_key] = time.time() + + +# ── Match logic per type ────────────────────────────────────────────────────── +def _match_window_title(pattern: str, snapshot: dict) -> Tuple[bool, str]: + """Returns (matched, summary). Pattern is regex, matched against + active_window.title.""" + win = snapshot.get("active_window") or {} + title = win.get("title", "") or "" + if not title: + return (False, "") + try: + m = re.search(pattern, title) + except re.error as e: + log.warning("Invalid window_title_match regex %r: %s", pattern, e) + return (False, "") + if m: + return (True, f"window:{title[:40]}") + return (False, "") + + +def _match_clipboard(pattern: str, snapshot: dict) -> Tuple[bool, str]: + cb = snapshot.get("clipboard") + if not cb: + return (False, "") + preview = cb.get("preview", "") or "" + if not preview: + return (False, "") + try: + m = re.search(pattern, preview) + except re.error as e: + log.warning("Invalid clipboard_pattern regex %r: %s", pattern, e) + return (False, "") + if m: + kind = cb.get("content_type", "text") + return (True, f"clipboard:{kind}") + return (False, "") + + +def _match_file_change(pattern: str, snapshot: dict) -> Tuple[bool, str]: + """Pattern is a glob (fnmatch). Match any path in recent_files.""" + expanded = os.path.expanduser(pattern) + recents = snapshot.get("recent_files") or [] + for rf in recents: + path = rf.get("path", "") + if not path: + continue + if fnmatch.fnmatch(path, expanded): + return (True, f"file:{os.path.basename(path)}") + return (False, "") + + +def _match_time(pattern: str, snapshot: dict) -> Tuple[bool, str]: + """Cron-like: 'M H D Mo W'. Each field is `*` (any) or an int. + Compares to wall-clock at evaluation. ≥1 min granularity per design.""" + parts = pattern.strip().split() + if len(parts) != 5: + log.warning("Invalid time pattern %r — expected 'M H D Mo W'", pattern) + return (False, "") + now = datetime.now() + fields = (now.minute, now.hour, now.day, now.month, now.weekday()) + for p, val in zip(parts, fields): + if p == "*": + continue + try: + if int(p) != int(val): + return (False, "") + except ValueError: + return (False, "") + return (True, f"time:{pattern}") + + +def _match_compound(pattern: dict, snapshot: dict) -> Tuple[bool, str]: + """{op: and|or, children: [{type, pattern}, ...]}.""" + op = pattern.get("op", "and") + children = pattern.get("children", []) or [] + matches: List[bool] = [] + summaries: List[str] = [] + for child in children: + c_type = child.get("type") + c_pattern = child.get("pattern") + ok, summary = _match_one(c_type, c_pattern, snapshot) + matches.append(ok) + if summary: + summaries.append(summary) + if op == "or": + result = any(matches) + else: + result = all(matches) + return (result, " & ".join(summaries) if result else "") + + +_MATCHERS: Dict[str, Callable[[Any, dict], Tuple[bool, str]]] = { + "window_title_match": _match_window_title, + "clipboard_pattern": _match_clipboard, + "file_change": _match_file_change, + "time": _match_time, + "compound": _match_compound, +} + + +def _match_one(trigger_type: str, pattern: Any, + snapshot: dict) -> Tuple[bool, str]: + matcher = _MATCHERS.get(trigger_type) + if matcher is None: + return (False, "") + try: + return matcher(pattern, snapshot) + except Exception as e: + log.debug("matcher %s failed: %s", trigger_type, e) + return (False, "") + + +def matches(trigger: Trigger, snapshot: dict) -> Tuple[bool, str]: + """Public: does this trigger match this observer snapshot?""" + return _match_one(trigger.type, trigger.pattern, snapshot) + + +# ── Audit emit helpers ──────────────────────────────────────────────────────── +def _emit_evaluated(trigger: Trigger, match_summary: str, + correlation_id: str) -> None: + try: + _log_event( + TRIGGER_EVALUATED, "codec-triggers", + f"trigger matched: {trigger.skill_name}", + extra={ + "trigger_key": trigger.key, + "skill_name": trigger.skill_name, + "trigger_type": trigger.type, + "match_summary": match_summary[:200], + }, + outcome="ok", level="info", + correlation_id=correlation_id, + ) + except Exception as e: + log.debug("trigger_evaluated emit failed: %s", e) + + +def _emit_fired(trigger: Trigger, dispatch_cid: str, + correlation_id: str) -> None: + try: + _log_event( + TRIGGER_FIRED, "codec-triggers", + f"trigger fired: {trigger.skill_name}", + extra={ + "trigger_key": trigger.key, + "skill_name": trigger.skill_name, + "trigger_type": trigger.type, + "dispatch_correlation_id": dispatch_cid, + }, + outcome="ok", level="info", + correlation_id=correlation_id, + ) + except Exception as e: + log.debug("trigger_fired emit failed: %s", e) + + +def _emit_blocked(trigger: Trigger, block_reason: str, + correlation_id: str) -> None: + try: + _log_event( + TRIGGER_BLOCKED, "codec-triggers", + f"trigger blocked: {trigger.skill_name} ({block_reason})", + extra={ + "trigger_key": trigger.key, + "skill_name": trigger.skill_name, + "trigger_type": trigger.type, + "block_reason": block_reason, + }, + outcome="warning", level="warning", + correlation_id=correlation_id, + ) + except Exception as e: + log.debug("trigger_blocked emit failed: %s", e) + + +# ── Dispatch ────────────────────────────────────────────────────────────────── +def _render_task(trigger: Trigger, snapshot: dict, match_summary: str) -> str: + """Compose the `task` string passed to the skill. Provides minimal + context so the skill knows WHY it fired.""" + return ( + f"[CODEC trigger fire — {trigger.type}]\n" + f"Match: {match_summary}\n" + f"Active window: {(snapshot.get('active_window') or {}).get('title', '(unknown)')}\n" + f"Trigger key: {trigger.key}" + ) + + +def _dispatch(trigger: Trigger, snapshot: dict, + match_summary: str, correlation_id: str) -> bool: + """Run the skill via codec_dispatch.run_skill. Returns True on dispatch + (caller decides if that means "fired").""" + try: + from codec_dispatch import run_skill, registry + except Exception as e: + log.warning("codec_dispatch unavailable: %s", e) + return False + try: + meta = registry.get_meta(trigger.skill_name) or {} + except Exception: + meta = {} + skill = { + "name": trigger.skill_name, + "_all_matches": [trigger.skill_name], + **meta, + } + task = _render_task(trigger, snapshot, match_summary) + dispatch_cid = secrets.token_hex(6) + try: + run_skill(skill, task) + _emit_fired(trigger, dispatch_cid, correlation_id) + mark_fired(trigger.key) + return True + except Exception as e: + log.warning("trigger dispatch failed for %s: %s", trigger.skill_name, e) + return False + + +# ── Confirmation gate ───────────────────────────────────────────────────────── +def _await_confirmation(trigger: Trigger, snapshot: dict, + correlation_id: str) -> Tuple[bool, str]: + """For require_confirmation=True (and destructive=False). + Posts to PWA via codec_ask_user.ask() with a short option list. + Returns (approved, block_reason_if_not). + + Note: codec_ask_user.ask is blocking with a deadline. Because the + observer poll loop calls evaluate() inline, this WILL block the next + poll. That's the intended behavior — auto-fires shouldn't outpace the + user's ability to approve. Default deadline is 60s (much shorter than + the ask_user default 600s) to keep observer cadence responsive.""" + try: + from codec_ask_user import ask, TIMEOUT_SENTINEL + except Exception as e: + log.debug("codec_ask_user unavailable: %s", e) + return (False, "confirmation_timeout") + answer = ask( + question=f"CODEC trigger: run skill `{trigger.skill_name}` " + f"({trigger.short_summary()})?", + options=["Approve", "Skip"], + timeout=60, + agent="codec-triggers", + asked_from="crew", + tool_name=trigger.skill_name, + ) + if answer == TIMEOUT_SENTINEL: + return (False, "confirmation_timeout") + answer_lc = (answer or "").strip().lower() + if answer_lc.startswith("approv"): + return (True, "") + return (False, "user_skipped") + + +def _await_destructive_consent(trigger: Trigger, snapshot: dict, + correlation_id: str) -> Tuple[bool, str]: + """destructive=True path: route through Step 3 §1.7 strict-consent.""" + try: + from codec_ask_user import ask, TIMEOUT_SENTINEL, _is_consenting_answer + except Exception: + return (False, "ambiguous_consent") + answer = ask( + question=f"CODEC trigger wants to run DESTRUCTIVE skill " + f"`{trigger.skill_name}` ({trigger.short_summary()}). " + f"Confirm?", + timeout=60, + destructive=True, + agent="codec-triggers", + asked_from="crew", + tool_name=trigger.skill_name, + ) + if answer == TIMEOUT_SENTINEL: + return (False, "ambiguous_consent") + return (True, "") + + +# ── Main entry: evaluate ────────────────────────────────────────────────────── +def evaluate(snapshot: dict, *, registry: Optional[Any] = None, + fire: bool = True) -> List[dict]: + """Match snapshot against all registered triggers. Optionally dispatch. + + Args: + snapshot: a single observer ring-buffer entry (from + codec_observer.poll()). + registry: optional injected SkillRegistry; defaults to the + one shared with codec_dispatch.run_skill. + fire: if True, dispatch matching triggers that pass cooldown + + consent. If False, only return match list (test mode). + + Returns: list of dicts, one per trigger evaluated, with keys + {trigger_key, skill_name, status, block_reason?, dispatch_cid?} + status ∈ {"matched_fired", "matched_pending_confirmation", + "blocked_cooldown", "blocked_killed", "blocked_user_skipped", + "blocked_confirmation_timeout", "blocked_ambiguous_consent", + "no_match"} + """ + if not _enabled(): + return [] + if registry is None: + try: + from codec_dispatch import registry as _dispatch_registry + registry = _dispatch_registry + except Exception: + return [] + + cid = secrets.token_hex(6) + triggers = discover_triggers(registry) + out: List[dict] = [] + + for trig in triggers: + # Killed check first (cheapest) + if is_killed(trig.key): + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "blocked_killed"}) + continue + + ok, summary = matches(trig, snapshot) + if not ok: + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "no_match"}) + continue + + # Match found + _emit_evaluated(trig, summary, cid) + + # Cooldown check + remaining = cooldown_remaining(trig.key, trig.cooldown_seconds) + if remaining > 0: + _emit_blocked(trig, "cooldown", cid) + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "blocked_cooldown", + "block_reason": "cooldown", + "cooldown_remaining": remaining}) + continue + + if not fire: + # Test/inspection mode + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "matched_pending_confirmation", + "match_summary": summary}) + continue + + # Consent gate + if trig.destructive: + approved, block_reason = _await_destructive_consent( + trig, snapshot, cid) + elif trig.require_confirmation: + approved, block_reason = _await_confirmation( + trig, snapshot, cid) + else: + approved, block_reason = (True, "") + + if not approved: + _emit_blocked(trig, block_reason, cid) + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": f"blocked_{block_reason}", + "block_reason": block_reason}) + continue + + # Fire! + if _dispatch(trig, snapshot, summary, cid): + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "matched_fired", + "match_summary": summary}) + else: + out.append({"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "blocked_dispatch_failed"}) + + return out + + +# ── Test helpers ────────────────────────────────────────────────────────────── +def _reset_state_for_test() -> None: + """Clear cooldowns + killed cache. Used only by tests.""" + with _LAST_FIRED_LOCK: + _LAST_FIRED.clear() + _refresh_killed_cache() + + +__all__ = [ + "Trigger", + "discover_triggers", + "matches", + "evaluate", + "is_killed", + "set_killed", + "cooldown_remaining", + "mark_fired", + "_validate_trigger_dict", + "_KILLED_PATH", +] diff --git a/docs/PHASE2-STEP6-DESIGN.md b/docs/PHASE2-STEP6-DESIGN.md new file mode 100644 index 0000000..3bb679d --- /dev/null +++ b/docs/PHASE2-STEP6-DESIGN.md @@ -0,0 +1,343 @@ +# Phase 2 Step 6 — Trigger System (Design) + +**Branch:** `phase2-step6-triggers` +**Source spec:** `docs/PHASE2-BLUEPRINT.md` §"Step 6" +**Status:** design phase, implementation follows in same PR +**Reviewer:** user + +--- + +## 0 · Why this exists + +CODEC today fires skills only when explicitly requested — wake word, chat command, MCP tool call. Step 6 lets a skill DECLARE a pattern that auto-fires it from the observer's signals (Step 5 ring buffer): + +- `stripe_dashboard_helper.py` declares `window_title_match: "Stripe — Dashboard"` → fires when you focus the Stripe tab. +- `address_lookup.py` declares `clipboard_pattern: "^\\d+\\s+\\w+\\s+(St|Ave|Rd)"` → fires when you copy a postal address. +- `csv_validator.py` declares `file_change: "~/Downloads/*.csv"` → fires when a CSV lands. + +The autopilot's hardcoded time triggers generalize into the same system. One mechanism, declarative. + +**Risk surface**: auto-firing is exactly the kind of thing that caused the May 1 incident (Apple Reminders, Terminal popups, AskUserQuestion leaks). Step 6 must be **safe by default** — conservative cooldowns, opt-in confirmation, Step 3 §1.7 destructive consent gate reused, and Step 6 ships ZERO triggers (only the plumbing). + +--- + +## 1 · Design + +### 1.1 Skill-side declaration (Q3 — locked) + +```python +# In any skill's module-level constants, alongside SKILL_TRIGGERS: +SKILL_OBSERVATION_TRIGGER = { + "type": "window_title_match" | "clipboard_pattern" | "file_change" | "time" | "compound", + "pattern": "...", # type-specific (see §1.2) + "cooldown_seconds": 600, # default 10 min — safe by default + "require_confirmation": True, # default True — safe by default + "destructive": False, # if True, uses Step 3 §1.7 strict-consent +} +``` + +**All four fields are required** in v1. No optional fields, no implicit defaults at the matcher level — the SKILL author opts in explicitly. + +### 1.2 Trigger types + +| `type` | `pattern` is | Match against | +|---|---|---| +| `window_title_match` | regex string | observer snapshot's `active_window.title` | +| `clipboard_pattern` | regex string | observer snapshot's `clipboard.preview` | +| `file_change` | glob string | observer snapshot's `recent_files[].path` (mtime within last poll cycle) | +| `time` | cron-like string `"M H D Mo W"` | wall clock at evaluation time | +| `compound` | `{"op": "and"\|"or", "children": [trigger, trigger, ...]}` | recursive evaluation | + +Time triggers limited to **≥1 minute granularity** — observer polls every 60s, no point in finer. + +### 1.3 Evaluation hook — inline in observer poll + +NO new PM2 service. After `codec_observer.poll()` appends to the ring buffer, it calls `codec_triggers.evaluate(snapshot)`. Saves a process, simpler audit trail, single source of truth on cadence. + +``` +codec-observer poll loop + │ + ▼ + poll() → append to RingBuffer → emit observation_tick + │ + ▼ + evaluate_triggers(snapshot) ← new in Step 6 + │ + ├─ For each registered trigger from SkillRegistry: + │ ├─ Type-specific match? → if no, skip + │ ├─ Cooldown elapsed? → if no, emit trigger_blocked + skip + │ ├─ Per-trigger killed? → if yes, skip silently + │ └─ require_confirmation? destructive? + │ ↓ + │ Use Step 3 §1.7 ask_user gate if destructive + │ OR fire PWA notification + await answer if require_confirmation + │ OR fire silently + ▼ + codec_dispatch.run_skill(skill, task=, ...) + ↑ ↑ + already-hooked chokepoint emits trigger_fired + (Step 2's run_with_hooks fires plugins, Step 4's + self_improve plugin captures the result) +``` + +### 1.4 Cooldown state + +Per-trigger last-fired timestamp lives in **RAM only** (process restart resets all cooldowns). State file would be tempting but introduces consistency questions; RAM is simpler. Storage: + +```python +# Module-level dict in codec_triggers.py +_LAST_FIRED: Dict[str, float] = {} # key = ":" +_LAST_FIRED_LOCK = threading.Lock() +``` + +Trigger-hash includes the full trigger dict serialized — so editing a trigger's pattern/cooldown effectively resets its cooldown (no stale state). + +### 1.5 Per-trigger kill switch (PWA) + +PWA "Triggers" tab lists all registered triggers with: +- Skill name + trigger summary (one-line render) +- Last fired timestamp +- Cooldown remaining (live counter) +- Kill switch toggle + +Kill switch state persists at `~/.codec/triggers_killed.json`: +```json +{ + "killed_keys": [":", ...], + "schema": 1 +} +``` + +A killed trigger is skipped at evaluation time (silently — no `trigger_blocked` emit; that would spam the audit log if a popular trigger is killed). + +Plus the global `TRIGGERS_ENABLED` env var (default `true`) disables the entire system at the observer-poll level. + +### 1.6 require_confirmation flow + +When `require_confirmation=True` AND `destructive=False`: + +1. Trigger matches. +2. Cooldown OK. +3. Emit `trigger_evaluated`. +4. Send a PWA notification via `~/.codec/notifications.json` with `type="trigger_pending"` and a 60s response window. +5. PWA renders an inline "Approve / Skip" panel (similar to AskUserQuestion). +6. User clicks Approve → emit `trigger_fired`, run skill via `codec_dispatch.run_skill`. +7. User clicks Skip / 60s elapses → emit `trigger_blocked` with `reason="user_skipped"` or `"confirmation_timeout"`. + +When `destructive=True`: route through `codec_ask_user.ask(destructive=True, ...)` from Step 3. Same literal-verb-match gate; two strikes → `ambiguous_consent` timeout. + +When `require_confirmation=False` AND `destructive=False`: fire silently (no PWA notification, just `trigger_fired` audit emit). + +### 1.7 Why Step 6 ships ZERO triggers + +The blueprint mentions migrating autopilot.json → SKILL_OBSERVATION_TRIGGER. **Step 6 explicitly does NOT do this.** Reasons: + +- `~/.codec/autopilot.json` currently has `enabled: false` and `triggers: []`. Nothing to migrate. +- Adding example triggers in Step 6 conflates infrastructure with policy. +- Step 6's job: build the plumbing. Skill authors (or future PRs) add triggers. Initial state: dormant. + +So at merge time, `codec_triggers.evaluate()` runs every 60s but iterates over **zero registered triggers** and exits in <1ms. No fires possible. + +--- + +## 2 · Implementation outline + +### 2.1 New / modified files + +| File | Purpose | LOC | +|---|---|---| +| `codec_triggers.py` (new) | Matcher engine, cooldown state, fire chokepoint | ~280 | +| `codec_skill_registry.py` (extend) | AST-extract `SKILL_OBSERVATION_TRIGGER` from skill files | ~30 | +| `codec_observer.py` (extend) | Call `codec_triggers.evaluate(snapshot)` after each poll | ~15 | +| `codec_audit.py` (extend) | 3 new event constants + frozenset | ~12 | +| `routes/triggers.py` (new) | `/api/triggers` GET + per-trigger kill toggle | ~120 | +| `tests/test_triggers.py` (new) | 35 tests (matcher + cooldowns + consent + kill switches) | ~500 | +| `AGENTS.md` | §3 + §6 + §10 updates | ~40 | +| **Total** | | ~+997 | + +### 2.2 Module API + +`codec_triggers.py`: + +```python +class Trigger: + """Validated trigger dict. Constructed from skill module's + SKILL_OBSERVATION_TRIGGER. Holds the original dict + a stable hash key.""" + skill_name: str + type: str + pattern: Any + cooldown_seconds: int + require_confirmation: bool + destructive: bool + key: str # ":" + +# Public API +def discover_triggers(registry) -> List[Trigger]: ... + # AST-walk the skill registry, validate each SKILL_OBSERVATION_TRIGGER + +def evaluate(snapshot: dict, *, fire: bool = True) -> List[dict]: ... + # Match the snapshot against all registered triggers; return list of + # candidates. If fire=True, also dispatch matches that pass cooldown + + # confirmation. Returns one dict per evaluated trigger with status. + +def fire(trigger: Trigger, snapshot: dict) -> bool: ... + # Internal — runs the full gate chain (cooldown, consent, dispatch). + +# Kill switch + persistence +def is_killed(trigger_key: str) -> bool: ... +def set_killed(trigger_key: str, killed: bool) -> None: ... + +# Cooldown +def cooldown_remaining(trigger_key: str, cooldown_seconds: int) -> float: ... +def mark_fired(trigger_key: str) -> None: ... +``` + +`codec_skill_registry.py` addition: + +```python +def get_observation_trigger(name: str) -> Optional[dict]: ... + # Returns the SKILL_OBSERVATION_TRIGGER dict for a skill, or None. + # Validated at metadata-extraction time; bad triggers logged + ignored. +``` + +### 2.3 Observer integration + +In `codec_observer.poll()`, after `_emit_observation_tick()`: + +```python +if cfg.get("triggers_enabled", True) and _triggers_enabled_env(): + try: + from codec_triggers import evaluate as _eval_triggers + _eval_triggers(snapshot) + except Exception as e: + log.debug("[observer] trigger evaluation failed: %s", e) +``` + +Try/except — trigger failures NEVER break observer polling. Single integration point. + +### 2.4 PWA endpoint contract + +``` +GET /api/triggers + → {triggers: [...], killed: [...], total: N} +GET /api/triggers/ + → trigger detail + last_fired_at + cooldown_remaining +POST /api/triggers//kill + → toggle killed state, returns new state +``` + +Auth-gated by existing `/api/*` middleware. + +--- + +## 3 · Audit envelope additions (extending Step 1 §1.2) + +Three new event types. All `outcome="ok"` for `trigger_evaluated` and `trigger_fired`; `outcome="warning"` for `trigger_blocked`. All `level="info"` (operational signals). + +| Event | Source | When | Extra fields | +|---|---|---|---| +| `trigger_evaluated` | `codec-triggers` | A trigger's pattern matched (before cooldown / consent gate) | `trigger_key`, `skill_name`, `trigger_type`, `match_summary` | +| `trigger_fired` | `codec-triggers` | Skill actually invoked | `trigger_key`, `skill_name`, `trigger_type`, `dispatch_correlation_id` | +| `trigger_blocked` | `codec-triggers` | Cooldown / confirmation reject / consent failure | `trigger_key`, `skill_name`, `block_reason` (`cooldown` \| `user_skipped` \| `confirmation_timeout` \| `ambiguous_consent` \| `killed`) | + +All inherit `correlation_id` from the wrapping observer poll's cid. + +--- + +## 7 · Test plan + +35 tests across `tests/test_triggers.py`. Same pattern as Step 5 — redirect `codec_audit._AUDIT_LOG` to `tmp_path`, **mock `codec_dispatch.run_skill`** (NEVER fire real skills in tests). + +### 7.1 Trigger validation (5) +- `test_trigger_dict_with_all_required_fields_validates` +- `test_trigger_dict_missing_field_rejected` +- `test_trigger_dict_unknown_type_rejected` +- `test_trigger_key_stable_across_reloads` +- `test_trigger_key_changes_when_pattern_edited` + +### 7.2 Match logic per type (10) +- 2× window_title_match (match / no-match) +- 2× clipboard_pattern (match / no-match + non-string content) +- 2× file_change (glob match / glob mismatch) +- 2× time (cron match within minute / no match) +- 2× compound (AND-success / OR-success) + +### 7.3 Cooldown (5) +- `test_cooldown_blocks_within_window` +- `test_cooldown_allows_after_window` +- `test_cooldown_per_trigger_independent` +- `test_cooldown_reset_on_pattern_edit` (new key = fresh state) +- `test_cooldown_emits_trigger_blocked_with_reason_cooldown` + +### 7.4 Confirmation + destructive (8) +- `test_require_confirmation_false_destructive_false_fires_silently` +- `test_require_confirmation_true_creates_pwa_notification` +- `test_require_confirmation_user_approve_fires_trigger` +- `test_require_confirmation_user_skip_emits_trigger_blocked` +- `test_require_confirmation_timeout_emits_trigger_blocked` +- `test_destructive_routes_through_ask_user` +- `test_destructive_two_strike_emits_trigger_blocked` +- `test_destructive_verb_match_fires_trigger` + +### 7.5 Kill switches + integration (7) +- `test_per_trigger_kill_blocks_evaluation_silently` +- `test_per_trigger_kill_state_persists_to_file` +- `test_global_TRIGGERS_ENABLED_false_skips_evaluate` +- `test_global_TRIGGERS_ENABLED_default_true` +- `test_observer_poll_calls_trigger_evaluate` +- `test_observer_poll_failure_doesnt_break_polling` (trigger raises → observer continues) +- `test_skill_registry_extracts_SKILL_OBSERVATION_TRIGGER` + +--- + +## 8 · Rollback plan + +| Severity | Action | +|---|---| +| Triggers misbehaving (rapid fires, bad pattern) | `TRIGGERS_ENABLED=false` env on codec-observer + `pm2 restart codec-observer`. Polling continues; no triggers evaluated. | +| Specific bad trigger | PWA "Triggers" tab → kill toggle. Persistent across restarts. | +| Audit-event flood (>50 trigger_evaluated/min) | `audit_report.py` flags it. Manual investigation; same revert path. | +| Hard revert | `git revert ` + `pm2 restart codec-observer`. Observer drops back to Step 5 behavior. | + +--- + +## 9 · Open questions (none for v1) + +The blueprint's Q1-Q6 + my own Q5.1-Q5.7 covered all the architectural decisions Step 6 needs. Implementation moves forward. **I'll surface anything that comes up during implementation in the PR description, NOT block on a separate review cycle.** Confirmed with user: "you decide." + +--- + +## 10 · Diff inventory + +| File | LOC | Status | +|---|---|---| +| `codec_triggers.py` (new) | ~+280 | new | +| `tests/test_triggers.py` (new) | ~+500 | new | +| `routes/triggers.py` (new) | ~+120 | new | +| `codec_skill_registry.py` | ~+30 | extend | +| `codec_observer.py` | ~+15 | extend | +| `codec_audit.py` | ~+12 | extend | +| `AGENTS.md` | ~+40 | extend | +| `docs/PHASE2-STEP6-DESIGN.md` (this file) | already created | ships in Step 6 PR | +| **Total functional + tests** | **~+997** | | + +In line with Step 5 (~+1,448) and Phase 1 step sizes. + +--- + +## Appendix · Safety summary + +What this PR does NOT do: +- Does NOT ship any skill with `SKILL_OBSERVATION_TRIGGER` set +- Does NOT migrate autopilot.json (it's empty anyway) +- Does NOT auto-fire any skill at merge time +- Does NOT touch `_HTTP_BLOCKED` +- Does NOT create Apple Reminders / Notes / Calendar entries +- Does NOT add a new PM2 service (triggers run inline in codec-observer) + +What can fire after merge (the user has to opt in twice): +1. User edits a skill file to add `SKILL_OBSERVATION_TRIGGER = {...}` +2. PM2 restart codec-observer (or `pm2 reload`) so registry re-scans +3. THEN observer polls eventually match and fire — subject to confirmation gate per skill author's choice + +This is the same trust model as plugins (Phase 1 Step 2): user-curated local Python, no marketplace, no auto-install. diff --git a/routes/triggers.py b/routes/triggers.py new file mode 100644 index 0000000..cb5aab3 --- /dev/null +++ b/routes/triggers.py @@ -0,0 +1,106 @@ +"""Phase 2 Step 6 — PWA endpoints for the Trigger System. + +Three endpoints, all auth-gated by codec_dashboard's existing /api/* +middleware: + + GET /api/triggers + List all registered triggers + their state. Returns metadata + only — no skill source code, no live snapshot data. + + GET /api/triggers/{trigger_key} + Trigger detail (last_fired_at, cooldown_remaining, killed?). + + POST /api/triggers/{trigger_key}/kill + Toggle the killed state. Body: {"killed": true|false}. + Returns the new state. + +The dashboard frontend renders a "Triggers" tab consuming these. +""" +from __future__ import annotations + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request + +log = logging.getLogger("routes.triggers") + +router = APIRouter(prefix="/api/triggers", tags=["triggers"]) + + +def _trigger_summary(trig) -> dict: + """Render a Trigger to a JSON-safe dict for the API.""" + from codec_triggers import cooldown_remaining, is_killed + return { + "trigger_key": trig.key, + "skill_name": trig.skill_name, + "type": trig.type, + "summary": trig.short_summary(), + "cooldown_seconds": trig.cooldown_seconds, + "cooldown_remaining": cooldown_remaining(trig.key, trig.cooldown_seconds), + "require_confirmation": trig.require_confirmation, + "destructive": trig.destructive, + "killed": is_killed(trig.key), + } + + +def _list_triggers() -> list: + """Discover triggers via the dispatch registry. Returns list of + summary dicts. Empty list if registry unavailable.""" + try: + from codec_dispatch import registry + from codec_triggers import discover_triggers + except Exception as e: + log.debug("trigger discovery skipped: %s", e) + return [] + try: + triggers = discover_triggers(registry) + except Exception as e: + log.debug("discover_triggers failed: %s", e) + return [] + return [_trigger_summary(t) for t in triggers] + + +@router.get("") +async def list_triggers(request: Request): + """Return all registered triggers + global enable state.""" + try: + from codec_triggers import _enabled, _load_killed + except Exception: + return {"triggers": [], "global_enabled": False, "error": "module not loaded"} + triggers = _list_triggers() + return { + "triggers": triggers, + "global_enabled": _enabled(), + "total": len(triggers), + "killed_count": sum(1 for t in triggers if t["killed"]), + } + + +@router.get("/{trigger_key}") +async def get_trigger(trigger_key: str, request: Request): + """Detail for one trigger by key. 404 if not registered.""" + matches = [t for t in _list_triggers() if t["trigger_key"] == trigger_key] + if not matches: + raise HTTPException(status_code=404, detail=f"trigger {trigger_key} not registered") + return matches[0] + + +@router.post("/{trigger_key}/kill") +async def toggle_kill(trigger_key: str, request: Request): + """Body: {"killed": bool}. Toggles the killed state. Persists to + ~/.codec/triggers_killed.json.""" + try: + from codec_triggers import set_killed, is_killed + except Exception: + raise HTTPException(status_code=500, detail="codec_triggers unavailable") + try: + body = await request.json() + except Exception: + body = {} + desired = bool(body.get("killed", not is_killed(trigger_key))) + set_killed(trigger_key, desired) + return { + "trigger_key": trigger_key, + "killed": is_killed(trigger_key), + } diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 0000000..ce3c696 --- /dev/null +++ b/tests/test_triggers.py @@ -0,0 +1,604 @@ +"""Phase 2 Step 6 tests — codec_triggers.py (Trigger System). + +35 tests organized per docs/PHASE2-STEP6-DESIGN.md §7: + §7.1 Trigger validation (5) + §7.2 Match logic per type (10) + §7.3 Cooldown (5) + §7.4 Confirmation + destructive (8) + §7.5 Kill switches + integration (7) + +CRITICAL test isolation: + - codec_dispatch.run_skill is MOCKED in every test that touches the + dispatch path. NEVER fire real skills — per the May 1 incident. + - codec_ask_user.ask is MOCKED for confirmation tests. Never block + on a real threading.Event. + - codec_audit._AUDIT_LOG redirected to tmp_path. + - _LAST_FIRED + _KILLED_CACHE module state reset per test. +""" +from __future__ import annotations + +import json +import os +import sys +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +import codec_audit +import codec_triggers + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def temp_audit_log(tmp_path, monkeypatch): + log = tmp_path / "audit.log" + monkeypatch.setattr(codec_audit, "_AUDIT_LOG", log) + return log + + +@pytest.fixture +def reset_state(monkeypatch, tmp_path): + """Reset trigger state + redirect killed-keys file to tmp_path.""" + codec_triggers._reset_state_for_test() + killed_path = tmp_path / "triggers_killed.json" + monkeypatch.setattr(codec_triggers, "_KILLED_PATH", killed_path) + codec_triggers._refresh_killed_cache() + yield + codec_triggers._reset_state_for_test() + + +@pytest.fixture +def mock_dispatch(monkeypatch): + """Mock codec_dispatch.run_skill to NEVER fire real skills. + Returns the mock so tests can assert call args.""" + fake_dispatch = MagicMock(return_value="mocked dispatch result") + fake_registry = MagicMock() + fake_registry.get_meta = MagicMock(return_value={"SKILL_NAME": "fake_skill"}) + fake_registry.names = MagicMock(return_value=[]) + fake_registry.get_observation_trigger = MagicMock(return_value=None) + + fake_module = MagicMock() + fake_module.run_skill = fake_dispatch + fake_module.registry = fake_registry + monkeypatch.setitem(sys.modules, "codec_dispatch", fake_module) + return fake_dispatch + + +@pytest.fixture +def mock_ask_user(monkeypatch): + """Mock codec_ask_user.ask to never block on threading.Event.""" + canned = {"answer": "Approve"} + + def _fake_ask(question, *, options=None, timeout=600, destructive=False, + destructive_verb=None, agent=None, crew_id=None, + asked_from="chat", tool_name=None): + return canned["answer"] + + fake_module = MagicMock() + fake_module.ask = _fake_ask + fake_module.TIMEOUT_SENTINEL = "(no answer — timed out)" + fake_module.DISABLED_SENTINEL = "(skill disabled)" + fake_module._is_consenting_answer = MagicMock(return_value=(True, "")) + monkeypatch.setitem(sys.modules, "codec_ask_user", fake_module) + return canned + + +def _records(audit_log: Path) -> list[dict]: + if not audit_log.exists(): + return [] + return [json.loads(l) for l in audit_log.read_text(encoding="utf-8").splitlines() if l.strip()] + + +def _events_of(records: list[dict], event_name: str) -> list[dict]: + return [r for r in records if r.get("event") == event_name] + + +def _make_snapshot(active_app="Chrome", title="page", + clipboard_text=None, clipboard_kind="text", + recent_files=None) -> dict: + snap = { + "ts": "2026-05-02T18:00:00.000+00:00", + "active_window": {"app": active_app, "title": title, "pid": 1}, + "screenshot_ocr": "", + "ocr_skipped": True, + "clipboard": {"preview": clipboard_text, "content_type": clipboard_kind} + if clipboard_text else None, + "recent_files": recent_files or [], + "idle_seconds": 5, + } + return snap + + +def _valid_trigger_dict(trigger_type="window_title_match", pattern="X", + cooldown=60, confirm=False, destructive=False): + return { + "type": trigger_type, + "pattern": pattern, + "cooldown_seconds": cooldown, + "require_confirmation": confirm, + "destructive": destructive, + } + + +# ───────────────────────────────────────────────────────────────────────────── +# §7.1 — Trigger validation (5) +# ───────────────────────────────────────────────────────────────────────────── + +def test_trigger_dict_with_all_required_fields_validates(): + ok, why = codec_triggers._validate_trigger_dict(_valid_trigger_dict()) + assert ok is True + assert why == "" + + +def test_trigger_dict_missing_field_rejected(): + d = _valid_trigger_dict() + del d["cooldown_seconds"] + ok, why = codec_triggers._validate_trigger_dict(d) + assert ok is False + assert "cooldown_seconds" in why + + +def test_trigger_dict_unknown_type_rejected(): + ok, why = codec_triggers._validate_trigger_dict( + _valid_trigger_dict(trigger_type="bad_type")) + assert ok is False + assert "unknown type" in why + + +def test_trigger_key_stable_across_reloads(reset_state): + t1 = codec_triggers.Trigger.from_dict("skill_x", _valid_trigger_dict()) + t2 = codec_triggers.Trigger.from_dict("skill_x", _valid_trigger_dict()) + assert t1.key == t2.key + + +def test_trigger_key_changes_when_pattern_edited(reset_state): + t1 = codec_triggers.Trigger.from_dict("skill_x", + _valid_trigger_dict(pattern="A")) + t2 = codec_triggers.Trigger.from_dict("skill_x", + _valid_trigger_dict(pattern="B")) + assert t1.key != t2.key + + +# ───────────────────────────────────────────────────────────────────────────── +# §7.2 — Match logic per type (10) +# ───────────────────────────────────────────────────────────────────────────── + +def test_window_title_match_match(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe")) + snap = _make_snapshot(title="Stripe — Dashboard | dashboard.stripe.com") + ok, summary = codec_triggers.matches(t, snap) + assert ok + assert "window:" in summary + + +def test_window_title_match_no_match(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe")) + snap = _make_snapshot(title="GitHub — open-source") + ok, _ = codec_triggers.matches(t, snap) + assert ok is False + + +def test_clipboard_pattern_match(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("clipboard_pattern", + r"https?://github\.com/")) + snap = _make_snapshot(clipboard_text="https://github.com/AVADSA25/codec", + clipboard_kind="url") + ok, summary = codec_triggers.matches(t, snap) + assert ok + assert "clipboard:url" in summary + + +def test_clipboard_pattern_no_clipboard(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("clipboard_pattern", r".")) + snap = _make_snapshot(clipboard_text=None) + ok, _ = codec_triggers.matches(t, snap) + assert ok is False + + +def test_file_change_glob_match(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("file_change", "~/Downloads/*.csv")) + snap = _make_snapshot(recent_files=[ + {"path": os.path.expanduser("~/Downloads/data.csv"), "mtime": "now"}, + {"path": os.path.expanduser("~/Downloads/notes.txt"), "mtime": "now"}, + ]) + ok, summary = codec_triggers.matches(t, snap) + assert ok + assert "data.csv" in summary + + +def test_file_change_glob_no_match(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("file_change", "~/Downloads/*.csv")) + snap = _make_snapshot(recent_files=[ + {"path": "/Users/x/Documents/notes.txt", "mtime": "now"}, + ]) + ok, _ = codec_triggers.matches(t, snap) + assert ok is False + + +def test_time_match_within_minute(monkeypatch): + """time pattern '* H * * *' matches when wall-clock hour matches.""" + from datetime import datetime + fixed = datetime(2026, 5, 2, 14, 30) # Friday May 2 14:30 + class _FakeDT: + @staticmethod + def now(): + return fixed + monkeypatch.setattr("codec_triggers.datetime", _FakeDT) + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("time", "* 14 * * *")) + ok, _ = codec_triggers.matches(t, _make_snapshot()) + assert ok is True + + +def test_time_no_match(): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("time", "* 99 * * *")) # invalid hour + ok, _ = codec_triggers.matches(t, _make_snapshot()) + assert ok is False + + +def test_compound_and_success(): + """compound AND requires all children to match.""" + pattern = { + "op": "and", + "children": [ + {"type": "window_title_match", "pattern": "Stripe"}, + {"type": "clipboard_pattern", "pattern": r"https?://"}, + ], + } + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("compound", pattern)) + snap = _make_snapshot(title="Stripe — Dashboard", + clipboard_text="https://example.com", + clipboard_kind="url") + ok, summary = codec_triggers.matches(t, snap) + assert ok is True + assert "&" in summary # combined summary + + +def test_compound_or_partial_match_succeeds(): + """compound OR succeeds when ANY child matches.""" + pattern = { + "op": "or", + "children": [ + {"type": "window_title_match", "pattern": "NotPresent"}, + {"type": "clipboard_pattern", "pattern": r"https?://"}, + ], + } + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("compound", pattern)) + snap = _make_snapshot(title="GitHub", + clipboard_text="https://example.com", + clipboard_kind="url") + ok, _ = codec_triggers.matches(t, snap) + assert ok is True + + +# ───────────────────────────────────────────────────────────────────────────── +# §7.3 — Cooldown (5) +# ───────────────────────────────────────────────────────────────────────────── + +def test_cooldown_blocks_within_window(reset_state): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict(cooldown=600)) + codec_triggers.mark_fired(t.key) + remaining = codec_triggers.cooldown_remaining(t.key, t.cooldown_seconds) + assert remaining > 599 + + +def test_cooldown_allows_after_window(reset_state): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict(cooldown=0)) + codec_triggers.mark_fired(t.key) + # cooldown=0 → always ready + assert codec_triggers.cooldown_remaining(t.key, t.cooldown_seconds) == 0.0 + + +def test_cooldown_per_trigger_independent(reset_state): + t1 = codec_triggers.Trigger.from_dict( + "skill_a", _valid_trigger_dict(pattern="A", cooldown=600)) + t2 = codec_triggers.Trigger.from_dict( + "skill_b", _valid_trigger_dict(pattern="B", cooldown=600)) + codec_triggers.mark_fired(t1.key) + # t2 has not fired + assert codec_triggers.cooldown_remaining(t2.key, t2.cooldown_seconds) == 0.0 + # t1 has + assert codec_triggers.cooldown_remaining(t1.key, t1.cooldown_seconds) > 599 + + +def test_cooldown_reset_on_pattern_edit(reset_state): + """Editing a trigger's pattern → new key → cooldown state is fresh.""" + t1 = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict(pattern="V1", cooldown=600)) + codec_triggers.mark_fired(t1.key) + t2 = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict(pattern="V2", cooldown=600)) + assert t1.key != t2.key + assert codec_triggers.cooldown_remaining(t2.key, t2.cooldown_seconds) == 0.0 + + +def test_cooldown_emits_trigger_blocked_with_reason(temp_audit_log, + reset_state, + mock_dispatch): + """evaluate() → cooldown active → emits trigger_blocked w/ block_reason=cooldown.""" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=600)) + codec_triggers.mark_fired(t.key) + # Inject our trigger via mocked registry + fake_registry = MagicMock() + fake_registry.names = MagicMock(return_value=["skill_x"]) + fake_registry.get_observation_trigger = MagicMock( + return_value=t.raw) + snap = _make_snapshot(title="Stripe — Dashboard") + out = codec_triggers.evaluate(snap, registry=fake_registry, fire=True) + assert any(r["status"] == "blocked_cooldown" for r in out) + recs = _records(temp_audit_log) + blocked = _events_of(recs, codec_audit.TRIGGER_BLOCKED) + assert len(blocked) == 1 + assert blocked[0]["extra"]["block_reason"] == "cooldown" + + +# ───────────────────────────────────────────────────────────────────────────── +# §7.4 — Confirmation + destructive (8) +# ───────────────────────────────────────────────────────────────────────────── + +def test_require_confirmation_false_destructive_false_fires_silently( + temp_audit_log, reset_state, mock_dispatch): + """confirm=False, destructive=False, cooldown ready → fire immediately.""" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, confirm=False, + destructive=False)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + fake_reg.get_meta = MagicMock(return_value={}) + snap = _make_snapshot(title="Stripe — Dashboard") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "matched_fired" for r in out) + assert mock_dispatch.call_count == 1 + + +def test_require_confirmation_true_uses_ask_user( + temp_audit_log, reset_state, mock_dispatch, mock_ask_user): + """confirm=True → routes through codec_ask_user.ask. Mocked ask returns + 'Approve' → fires.""" + mock_ask_user["answer"] = "Approve" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, confirm=True)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + fake_reg.get_meta = MagicMock(return_value={}) + snap = _make_snapshot(title="Stripe — Dashboard") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "matched_fired" for r in out) + assert mock_dispatch.call_count == 1 + + +def test_require_confirmation_user_skip_emits_blocked( + temp_audit_log, reset_state, mock_dispatch, mock_ask_user): + """User answers 'Skip' → trigger_blocked w/ user_skipped, no fire.""" + mock_ask_user["answer"] = "Skip" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, confirm=True)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + snap = _make_snapshot(title="Stripe — Dashboard") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "blocked_user_skipped" for r in out) + assert mock_dispatch.call_count == 0 + blocked = _events_of(_records(temp_audit_log), codec_audit.TRIGGER_BLOCKED) + assert blocked[0]["extra"]["block_reason"] == "user_skipped" + + +def test_require_confirmation_timeout_emits_blocked( + temp_audit_log, reset_state, mock_dispatch, mock_ask_user): + """ask returns TIMEOUT_SENTINEL → confirmation_timeout block reason.""" + mock_ask_user["answer"] = "(no answer — timed out)" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, confirm=True)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + snap = _make_snapshot(title="Stripe — Dashboard") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "blocked_confirmation_timeout" for r in out) + assert mock_dispatch.call_count == 0 + + +def test_destructive_routes_through_ask_user( + temp_audit_log, reset_state, mock_dispatch, mock_ask_user): + """destructive=True → ask_user.ask called with destructive=True (mocked).""" + mock_ask_user["answer"] = "delete the row" # contains verb (per Step 3) + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, destructive=True)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + fake_reg.get_meta = MagicMock(return_value={}) + snap = _make_snapshot(title="Stripe") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "matched_fired" for r in out) + + +def test_destructive_two_strike_emits_blocked( + temp_audit_log, reset_state, mock_dispatch, mock_ask_user): + """destructive=True with TIMEOUT_SENTINEL answer → ambiguous_consent.""" + mock_ask_user["answer"] = "(no answer — timed out)" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, destructive=True)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + snap = _make_snapshot(title="Stripe") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "blocked_ambiguous_consent" for r in out) + assert mock_dispatch.call_count == 0 + + +def test_destructive_overrides_require_confirmation( + temp_audit_log, reset_state, mock_dispatch, mock_ask_user): + """destructive=True takes precedence over require_confirmation routing.""" + mock_ask_user["answer"] = "delete" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0, confirm=True, + destructive=True)) + # Both flags set → destructive wins (uses strict consent) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + fake_reg.get_meta = MagicMock(return_value={}) + snap = _make_snapshot(title="Stripe") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "matched_fired" for r in out) + + +def test_no_match_no_audit_emit(temp_audit_log, reset_state, mock_dispatch): + """When no trigger matches, NO trigger_evaluated emit (avoids spam).""" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe")) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + snap = _make_snapshot(title="GitHub") # NOT stripe + codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + recs = _records(temp_audit_log) + assert _events_of(recs, codec_audit.TRIGGER_EVALUATED) == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# §7.5 — Kill switches + integration (7) +# ───────────────────────────────────────────────────────────────────────────── + +def test_per_trigger_kill_blocks_evaluation_silently( + temp_audit_log, reset_state, mock_dispatch): + """Killed trigger: skipped silently (NO trigger_blocked emit either).""" + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0)) + codec_triggers.set_killed(t.key, True) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + snap = _make_snapshot(title="Stripe") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert any(r["status"] == "blocked_killed" for r in out) + # No trigger_blocked emit (silent) + assert _events_of(_records(temp_audit_log), codec_audit.TRIGGER_BLOCKED) == [] + # And no dispatch + assert mock_dispatch.call_count == 0 + + +def test_per_trigger_kill_state_persists_to_file(reset_state): + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict()) + codec_triggers.set_killed(t.key, True) + # Read the file back + assert codec_triggers._KILLED_PATH.exists() + data = json.loads(codec_triggers._KILLED_PATH.read_text()) + assert t.key in data["killed_keys"] + # Toggle off + codec_triggers.set_killed(t.key, False) + data = json.loads(codec_triggers._KILLED_PATH.read_text()) + assert t.key not in data["killed_keys"] + + +def test_global_TRIGGERS_ENABLED_false_skips_evaluate( + temp_audit_log, reset_state, mock_dispatch, monkeypatch): + monkeypatch.setenv("TRIGGERS_ENABLED", "false") + t = codec_triggers.Trigger.from_dict( + "skill_x", _valid_trigger_dict("window_title_match", "Stripe", + cooldown=0)) + fake_reg = MagicMock() + fake_reg.names = MagicMock(return_value=["skill_x"]) + fake_reg.get_observation_trigger = MagicMock(return_value=t.raw) + snap = _make_snapshot(title="Stripe") + out = codec_triggers.evaluate(snap, registry=fake_reg, fire=True) + assert out == [] # full early-exit; no triggers evaluated + assert mock_dispatch.call_count == 0 + + +def test_global_TRIGGERS_ENABLED_default_true(monkeypatch): + monkeypatch.delenv("TRIGGERS_ENABLED", raising=False) + assert codec_triggers._enabled() is True + + +def test_TRIGGERS_ENABLED_off_aliases(monkeypatch): + for v in ("false", "0", "no", "off", "FALSE"): + monkeypatch.setenv("TRIGGERS_ENABLED", v) + assert codec_triggers._enabled() is False + + +def test_observer_poll_evaluates_triggers(temp_audit_log, reset_state, + monkeypatch): + """Integration: codec_observer.poll() calls codec_triggers.evaluate().""" + import codec_observer + monkeypatch.setattr(codec_observer, "_idle_seconds", lambda: 0.0) + monkeypatch.setattr(codec_observer, "_get_active_window", + lambda: {"app": "X", "title": "y", "pid": 1}) + monkeypatch.setattr(codec_observer, "_get_clipboard_now", lambda: "") + monkeypatch.setattr(codec_observer, "_get_screenshot_ocr", + lambda t, rt: ("", True)) + monkeypatch.setattr(codec_observer, "_get_recent_files", + lambda window_seconds=300: []) + # Track evaluate calls + called = [] + monkeypatch.setattr(codec_triggers, "evaluate", + lambda snap, **kw: called.append(snap) or []) + cfg = dict(codec_observer._DEFAULT_CONFIG) + cfg["ocr_enabled"] = False + fresh_buf = codec_observer.RingBuffer(maxlen=10) + codec_observer.poll(buffer=fresh_buf, cfg=cfg, emit_audit=True) + assert len(called) == 1, "observer.poll should call triggers.evaluate" + + +def test_skill_registry_extracts_SKILL_OBSERVATION_TRIGGER(tmp_path): + """Integration: a skill file with SKILL_OBSERVATION_TRIGGER is picked + up by the registry's AST scan.""" + import codec_skill_registry + skill_dir = tmp_path / "skills" + skill_dir.mkdir() + (skill_dir / "test_skill.py").write_text( + 'SKILL_NAME = "test_skill"\n' + 'SKILL_DESCRIPTION = "test"\n' + 'SKILL_TRIGGERS = ["test"]\n' + 'SKILL_OBSERVATION_TRIGGER = {\n' + ' "type": "window_title_match",\n' + ' "pattern": "Stripe",\n' + ' "cooldown_seconds": 600,\n' + ' "require_confirmation": True,\n' + ' "destructive": False,\n' + '}\n' + 'def run(task, app="", ctx=""):\n' + ' return "ok"\n' + ) + reg = codec_skill_registry.SkillRegistry(str(skill_dir)) + reg.scan() + trig = reg.get_observation_trigger("test_skill") + assert trig is not None + assert trig["type"] == "window_title_match" + assert trig["pattern"] == "Stripe" + assert trig["cooldown_seconds"] == 600