diff --git a/AGENTS.md b/AGENTS.md index 17e9552..48b6f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,18 +153,20 @@ After every `codec_observer.poll()`, `codec_triggers.evaluate(snapshot)` walks t **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). +**Per-skill mute config** (post Step 6 hotfix): persistent at `~/.codec/triggers.json`. JSON file with `muted_skills` (permanent) and `muted_until` (ISO-8601 timestamp). Muted matches DO emit `trigger_muted` (warning), unlike kill which is silent. Default contents (when file missing): `{"muted_skills": ["clipboard_url_fetch"]}`. See `docs/PHASE2-STEP6-TRIGGER-MUTE.md`. + **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`). +**4 audit events**: `trigger_evaluated` (info, on match), `trigger_fired` (info, on dispatch), `trigger_blocked` (warning, with `block_reason`), `trigger_muted` (warning, with `mute_source`). **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). +Implementation: `codec_triggers.py` (Trigger dataclass, validation, matchers, dispatch, mute config), `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). ### Shift Report (Phase 2 Step 7) @@ -446,13 +448,14 @@ 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`. +Four 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). `trigger_muted` fires when an otherwise-eligible match is suppressed by the runtime mute config (`~/.codec/triggers.json` — see `docs/PHASE2-STEP6-TRIGGER-MUTE.md`). 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. | +| `trigger_muted` | `codec-triggers` | warning | `trigger_key`, `skill_name`, `trigger_type`, `mute_source` (`muted_skills` \| `muted_until`), `muted_until` (only when source=`muted_until`) | `PHASE2_STEP6_EVENTS` frozenset exposed. @@ -654,6 +657,8 @@ These zones break running infrastructure if changed without coordination. NEVER - `~/.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. +- `~/.codec/triggers.json` (Phase 2 Step 6 mute config) — user-facing soft-disable for noisy triggers. Schema: `{"muted_skills": [...], "muted_until": {skill: ISO8601}}`. Cached in `_MUTE_CACHE`; hand-edits require service restart OR `codec_triggers._refresh_mute_cache()`. Default contents (when file missing): `{"muted_skills": ["clipboard_url_fetch"]}` — preserves PR #38's old behavior. **Writing the file replaces defaults entirely; no merge.** See `docs/PHASE2-STEP6-TRIGGER-MUTE.md`. +- `_DEFAULT_MUTE_CONFIG` in `codec_triggers.py` (Phase 2 Step 6 mute config) — hardcoded fallback when `~/.codec/triggers.json` is missing. Touching this changes the on-fresh-install behavior — coordinate with the user before adding/removing skills from the default list. - `~/.codec/shift_report_state.json` (Phase 2 Step 7) — per-day fire dedup state (`last_fired_date`, `last_fired_at`, `last_trigger_kind`). Owned by `skills/shift_report.mark_fired_today()`. Safe to delete to force-fire today again; do not hand-edit (atomic-write contract). - `SHIFT_REPORT_ENABLED` env var (Phase 2 Step 7, default `true`). False blocks all three trigger paths (time / idle / manual). - `~/.codec/config.json:shift_report.{daily_at_hour, daily_at_minute, idle_minutes, lookback_hours, auto_save_path}` — Phase 2 Step 7 tunables. `auto_save_path` is `null` by default (notification-only); set to a directory path to also write `YYYY-MM-DD.md` files. diff --git a/codec_audit.py b/codec_audit.py index 16e5cb9..883a005 100644 --- a/codec_audit.py +++ b/codec_audit.py @@ -210,9 +210,10 @@ TRIGGER_EVALUATED = "trigger_evaluated" TRIGGER_FIRED = "trigger_fired" TRIGGER_BLOCKED = "trigger_blocked" +TRIGGER_MUTED = "trigger_muted" PHASE2_STEP6_EVENTS = frozenset({ - TRIGGER_EVALUATED, TRIGGER_FIRED, TRIGGER_BLOCKED, + TRIGGER_EVALUATED, TRIGGER_FIRED, TRIGGER_BLOCKED, TRIGGER_MUTED, }) # Step 6 event-specific extra-field reservations. @@ -227,6 +228,9 @@ # cooldown | user_skipped | # confirmation_timeout | # ambiguous_consent | killed + "mute_source", # on trigger_muted only: + # "muted_skills" | "muted_until" + "muted_until", # on trigger_muted only when source=muted_until ) diff --git a/codec_triggers.py b/codec_triggers.py index 1691ddb..8e0851d 100644 --- a/codec_triggers.py +++ b/codec_triggers.py @@ -84,6 +84,7 @@ TRIGGER_EVALUATED, TRIGGER_FIRED, TRIGGER_BLOCKED, + TRIGGER_MUTED, log_event as _log_event, ) @@ -94,6 +95,15 @@ _KILLED_SCHEMA = 1 _KILLED_LOCK = threading.Lock() +# Mute config — user-facing soft-disable for whole skills (any pattern they +# declare). When the file is absent, defaults apply. The hand-edit + restart +# is the documented path; tests use _refresh_mute_cache() to reload. +_MUTE_CONFIG_PATH = Path(os.path.expanduser("~/.codec/triggers.json")) +_DEFAULT_MUTE_CONFIG: Dict[str, Any] = { + "muted_skills": ["clipboard_url_fetch"], + "muted_until": {}, +} + # ── Module-level state ──────────────────────────────────────────────────────── # Per-trigger last-fired timestamp (RAM only — process restart resets). _LAST_FIRED: Dict[str, float] = {} @@ -103,6 +113,11 @@ _KILLED_CACHE: Optional[set] = None _KILLED_CACHE_LOCK = threading.Lock() +# Cached mute config; reloaded from disk lazily. Hand-edits to the JSON file +# require either a service restart or a call to _refresh_mute_cache(). +_MUTE_CACHE: Optional[dict] = None +_MUTE_CACHE_LOCK = threading.Lock() + # ── Kill switch ─────────────────────────────────────────────────────────────── def _enabled() -> bool: @@ -281,6 +296,93 @@ def set_killed(trigger_key: str, killed: bool) -> None: _refresh_killed_cache() +# ── Runtime mute config ─────────────────────────────────────────────────────── +def _load_mute_config() -> dict: + """Read mute config from disk, cached. Returns _DEFAULT_MUTE_CONFIG when + the file is missing or malformed (fail-open: no muting on bad config).""" + global _MUTE_CACHE + with _MUTE_CACHE_LOCK: + if _MUTE_CACHE is not None: + return dict(_MUTE_CACHE) + try: + with open(_MUTE_CONFIG_PATH) as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("triggers.json root must be a JSON object") + ms = data.get("muted_skills") or [] + mu = data.get("muted_until") or {} + if not isinstance(ms, list): + raise ValueError("muted_skills must be a list") + if not isinstance(mu, dict): + raise ValueError("muted_until must be a dict") + cfg = { + "muted_skills": [str(s) for s in ms if isinstance(s, str)], + "muted_until": {str(k): str(v) for k, v in mu.items() + if isinstance(k, str) and isinstance(v, str)}, + } + except FileNotFoundError: + cfg = dict(_DEFAULT_MUTE_CONFIG) + except (json.JSONDecodeError, OSError, ValueError) as e: + log.warning("triggers.json unreadable (%s); applying defaults", e) + cfg = dict(_DEFAULT_MUTE_CONFIG) + _MUTE_CACHE = cfg + return dict(cfg) + + +def _refresh_mute_cache() -> None: + """Invalidate the mute-config cache. Tests + future setter API call this.""" + global _MUTE_CACHE + with _MUTE_CACHE_LOCK: + _MUTE_CACHE = None + + +def _parse_iso8601(ts: str) -> Optional[datetime]: + """Best-effort ISO-8601 parser. Accepts trailing 'Z' as UTC.""" + if not isinstance(ts, str) or not ts.strip(): + return None + s = ts.strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + +def _resolve_mute(skill_name: str) -> Tuple[bool, str, Optional[str]]: + """Internal: returns (muted, source, until_iso). source ∈ {"", + "muted_skills", "muted_until"}; until_iso is the raw timestamp when + source is "muted_until", else None. + + A skill is muted when either: + - its name is in `muted_skills` (permanent until removed), OR + - `muted_until[skill]` parses to a future-utc datetime. + """ + cfg = _load_mute_config() + muted_skills = cfg.get("muted_skills") or [] + if skill_name in muted_skills: + return (True, "muted_skills", None) + until_map = cfg.get("muted_until") or {} + until_raw = until_map.get(skill_name) + if not until_raw: + return (False, "", None) + parsed = _parse_iso8601(until_raw) + if parsed is None: + return (False, "", None) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + if parsed > datetime.now(timezone.utc): + return (True, "muted_until", until_raw) + return (False, "", None) + + +def _is_muted(skill_name: str) -> bool: + """Public bool helper per the spec contract — True if `skill_name` is + currently suppressed by ~/.codec/triggers.json.""" + muted, _, _ = _resolve_mute(skill_name) + return muted + + # ── Cooldown ────────────────────────────────────────────────────────────────── def cooldown_remaining(trigger_key: str, cooldown_seconds: int) -> float: """Returns seconds until trigger can fire again. 0.0 means ready.""" @@ -450,6 +552,28 @@ def _emit_fired(trigger: Trigger, dispatch_cid: str, log.debug("trigger_fired emit failed: %s", e) +def _emit_muted(trigger: Trigger, mute_source: str, + until_iso: Optional[str], correlation_id: str) -> None: + extra = { + "trigger_key": trigger.key, + "skill_name": trigger.skill_name, + "trigger_type": trigger.type, + "mute_source": mute_source, + } + if until_iso is not None: + extra["muted_until"] = until_iso + try: + _log_event( + TRIGGER_MUTED, "codec-triggers", + f"trigger muted: {trigger.skill_name} ({mute_source})", + extra=extra, + outcome="warning", level="warning", + correlation_id=correlation_id, + ) + except Exception as e: + log.debug("trigger_muted emit failed: %s", e) + + def _emit_blocked(trigger: Trigger, block_reason: str, correlation_id: str) -> None: try: @@ -618,6 +742,20 @@ def evaluate(snapshot: dict, *, registry: Optional[Any] = None, # Match found _emit_evaluated(trig, summary, cid) + # Mute check — soft-disable via ~/.codec/triggers.json. Audited + # (trigger_muted) so the user sees what they're suppressing. + muted, mute_source, until_iso = _resolve_mute(trig.skill_name) + if muted: + _emit_muted(trig, mute_source, until_iso, cid) + entry = {"trigger_key": trig.key, + "skill_name": trig.skill_name, + "status": "blocked_muted", + "mute_source": mute_source} + if until_iso is not None: + entry["muted_until"] = until_iso + out.append(entry) + continue + # Cooldown check remaining = cooldown_remaining(trig.key, trig.cooldown_seconds) if remaining > 0: @@ -671,10 +809,11 @@ def evaluate(snapshot: dict, *, registry: Optional[Any] = None, # ── Test helpers ────────────────────────────────────────────────────────────── def _reset_state_for_test() -> None: - """Clear cooldowns + killed cache. Used only by tests.""" + """Clear cooldowns + killed cache + mute cache. Used only by tests.""" with _LAST_FIRED_LOCK: _LAST_FIRED.clear() _refresh_killed_cache() + _refresh_mute_cache() __all__ = [ @@ -688,4 +827,8 @@ def _reset_state_for_test() -> None: "mark_fired", "_validate_trigger_dict", "_KILLED_PATH", + "_MUTE_CONFIG_PATH", + "_is_muted", + "_load_mute_config", + "_refresh_mute_cache", ] diff --git a/docs/PHASE2-STEP6-TRIGGER-MUTE.md b/docs/PHASE2-STEP6-TRIGGER-MUTE.md new file mode 100644 index 0000000..30a983e --- /dev/null +++ b/docs/PHASE2-STEP6-TRIGGER-MUTE.md @@ -0,0 +1,229 @@ +# Phase 2 Step 6 — Runtime trigger mute config + +**Status:** Live. Follow-up to Phase 2 Step 6 (Trigger System). +**Module:** `codec_triggers.py` +**Audit event:** `trigger_muted` (`PHASE2_STEP6_EVENTS`) +**Config file:** `~/.codec/triggers.json` + +--- + +## 0 · Why this exists + +Phase 2 Step 6 lets skills declare a `SKILL_OBSERVATION_TRIGGER` that +auto-fires on observer signals. In practice some triggers turn out to +be too noisy for a given user — the obvious example is +`clipboard_url_fetch`, which prompts on every URL copied. Pre-mute, the +only remediations were: + +1. Comment out the `SKILL_OBSERVATION_TRIGGER` block in the skill file + (loses the declaration entirely; PR #38 used this). +2. PWA-toggle the per-trigger kill switch (silent, persistent, but + per-pattern-hash — editing the pattern resets it). +3. Set `TRIGGERS_ENABLED=false` (kills ALL triggers). + +None of these scale — option 1 needs a code edit, option 2 hides the +state from the audit log entirely, and option 3 is a sledgehammer. + +The runtime mute config gives users a fourth path: **soft-disable a +skill's auto-fire by name, leave the skill code untouched, and keep the +suppression visible in the audit log.** + +--- + +## 1 · Config shape + +Path: `~/.codec/triggers.json` + +```json +{ + "muted_skills": ["clipboard_url_fetch"], + "muted_until": { + "stripe_dashboard_helper": "2026-12-01T00:00:00Z", + "csv_validator": "2026-06-15T18:00:00+02:00" + } +} +``` + +Fields: + +| Field | Type | Meaning | +|---|---|---| +| `muted_skills` | `list[str]` | Skill names that are **permanently** muted until removed from the list. | +| `muted_until` | `dict[str, str]` | Skill names mapped to ISO-8601 timestamps (UTC `Z` or `±HH:MM` offsets). The skill is muted **until** that timestamp passes. | + +Either field may be absent; both default to empty in user-supplied +configs. + +A skill is muted if: + +- it appears in `muted_skills`, **OR** +- `muted_until[skill]` parses to a future timestamp. + +Past timestamps in `muted_until` are silently ignored (the skill is no +longer muted; the entry is left in the file as a record). + +--- + +## 2 · Defaults + +When `~/.codec/triggers.json` does not exist, the code falls back to: + +```python +_DEFAULT_MUTE_CONFIG = { + "muted_skills": ["clipboard_url_fetch"], + "muted_until": {}, +} +``` + +This preserves the old PR #38 behavior (`clipboard_url_fetch` does not +auto-fire) without requiring a code edit. **As soon as you write a +`triggers.json` file, your file is the source of truth — defaults are +not merged in.** If you create a triggers.json with empty +`muted_skills`, `clipboard_url_fetch` will start auto-firing. + +This is intentional: explicit > implicit. The user file is canonical. + +--- + +## 3 · Examples + +### 3a · Mute the noisy clipboard URL fetcher (default behavior) + +No config file needed — the default applies. Or, if you have a config +file: + +```json +{ + "muted_skills": ["clipboard_url_fetch"] +} +``` + +### 3b · Mute multiple skills + +```json +{ + "muted_skills": ["clipboard_url_fetch", "stripe_dashboard_helper"] +} +``` + +### 3c · Snooze a skill until next month + +```json +{ + "muted_until": { + "csv_validator": "2026-06-01T00:00:00Z" + } +} +``` + +### 3d · Re-enable `clipboard_url_fetch` + +Write the file with the skill removed from `muted_skills`: + +```json +{ + "muted_skills": [] +} +``` + +Then restart `codec-observer` (or the process running `evaluate()`) so +the cache picks up the new file. The trigger declaration in +[skills/clipboard_url_fetch.py](../skills/clipboard_url_fetch.py) is +already active — no code edit needed. + +--- + +## 4 · Caching + reload + +The parsed config is cached in process memory after the first +`_load_mute_config()` call. To pick up hand-edits to +`~/.codec/triggers.json`: + +- **Recommended:** restart the service that runs `evaluate()` — + typically `codec-observer` (`pm2 restart codec-observer`). +- **For tests / interactive sessions:** call + `codec_triggers._refresh_mute_cache()`. + +A future setter API (e.g. `POST /api/triggers/mute/{skill}` parallel to +the existing `POST /api/triggers/{key}/kill`) would invalidate the +cache automatically; that is intentionally not in this PR. + +--- + +## 5 · Audit visibility + +When a trigger matches the observer snapshot but is suppressed by mute, +the evaluation pipeline emits the new `trigger_muted` audit event: + +```json +{ + "ts": "2026-05-03T12:14:23.451+00:00", + "schema": 1, + "event": "trigger_muted", + "source": "codec-triggers", + "outcome": "warning", + "level": "warning", + "extra": { + "trigger_key": "clipboard_url_fetch:8a3f1b22", + "skill_name": "clipboard_url_fetch", + "trigger_type": "clipboard_pattern", + "mute_source": "muted_skills", + "correlation_id": "" + } +} +``` + +For `mute_source = "muted_until"`, the event also carries the raw +`muted_until` timestamp string in `extra.muted_until`. + +Why warning, not info? Mute is a deliberate user signal that suppresses +otherwise-eligible automation. Surfacing it at warning lets the +audit_report skill flag it for review (you may want to re-enable it, +or you may have forgotten the entry exists). + +The pre-existing `trigger_evaluated` event still fires *before* the +mute check, so you can see in the audit log: + +1. `trigger_evaluated` — the snapshot matched the pattern +2. `trigger_muted` — but the user has it suppressed + +If you want a trigger fully silenced (no audit emit at all), use the +per-trigger kill switch (`POST /api/triggers/{key}/kill`) instead. + +--- + +## 6 · Comparison: kill vs mute + +| Property | Kill switch (`triggers_killed.json`) | Mute config (`triggers.json`) | +|---|---|---| +| Granularity | Per-trigger (skill + pattern hash) | Per-skill name | +| Persistence | Atomic-write to disk | User-edited JSON | +| API setter | `POST /api/triggers/{key}/kill` | None in this PR (manual edit) | +| Audit on block | Silent (no emit) | Emits `trigger_muted` (warning) | +| Reset on pattern edit | Yes (key changes) | No (skill name unchanged) | +| Time-bounded | No | Yes (`muted_until`) | +| Default state | All triggers enabled | `clipboard_url_fetch` muted | +| Use when… | You changed your mind about a specific pattern | You want a whole skill silenced (or temporarily snoozed) | + +Both layers compose: a killed trigger is silently skipped before the +match check; a muted skill is loudly skipped after the match check +(so audit captures relevance). + +--- + +## 7 · Implementation notes + +- **No file is auto-created.** The first `_load_mute_config()` call on + a fresh install reads defaults from code — it does not write a + starter file. Users opt in to a config file by creating it. +- **Fail-open on parse errors.** If `triggers.json` is malformed JSON, + the code logs a warning and applies defaults. This avoids accidentally + un-muting a noisy trigger because of a missing comma. +- **No merging.** A user-supplied config replaces defaults entirely. To + preserve the default mute *and* add your own, copy the default list: + `{"muted_skills": ["clipboard_url_fetch", "your_skill"]}`. +- **Timezone handling.** Timestamps without an explicit timezone are + treated as UTC. Use `Z` or `+00:00` for clarity. +- **Mute is checked AFTER `trigger_evaluated` but BEFORE cooldown,** + so muted triggers do not consume cooldown budget and do not get + `trigger_blocked: cooldown` emits. diff --git a/skills/clipboard_url_fetch.py b/skills/clipboard_url_fetch.py index fb0ef5d..482e42a 100644 --- a/skills/clipboard_url_fetch.py +++ b/skills/clipboard_url_fetch.py @@ -39,30 +39,29 @@ SKILL_MCP_EXPOSE = False # local-only; no value over MCP since clipboard isn't shared # ────────────────────────────────────────────────────────────────────────────── -# Phase 2 Step 6 declarative trigger — DISABLED BY DEFAULT. +# Phase 2 Step 6 declarative trigger — MUTED BY DEFAULT via runtime config. # -# Auto-firing on every URL copied to clipboard turned out to be too noisy in -# practice — the average user copies several URLs per minute while working, -# and `require_confirmation: True` produced one ask_user notification per -# fresh URL despite the 10-min cooldown. The trigger was firing every ~7 min -# with each new URL, blocking the agent runner queue and burying real -# notifications under "codec-triggers is asking a question" spam. +# The trigger is declared but suppressed by `~/.codec/triggers.json`'s +# `muted_skills` list (default contents include `clipboard_url_fetch`) — +# see docs/PHASE2-STEP6-TRIGGER-MUTE.md. # -# Manual paths still work. Say "fetch the link" / "summarize this URL" or -# call the skill via MCP / chat / voice — `run()` reads the clipboard at -# invocation time. The skill itself is fully functional; only the -# auto-fire-on-clipboard-pattern path is muted. +# Why muted: auto-firing on every URL copied to clipboard was too noisy in +# practice — users copy several URLs per minute while working, and +# `require_confirmation: True` produced one ask_user notification per fresh +# URL despite the 10-min cooldown. The trigger was firing every ~7 min, +# blocking the agent runner queue and burying real notifications. # -# To re-enable, uncomment the dict below. Future work: per-skill enable -# flag in `~/.codec/triggers.json` so users toggle without editing code. +# To re-enable: remove `clipboard_url_fetch` from `muted_skills` in +# `~/.codec/triggers.json` (no code edit needed). Manual paths always work +# regardless of mute state. # ────────────────────────────────────────────────────────────────────────────── -# SKILL_OBSERVATION_TRIGGER = { -# "type": "clipboard_pattern", -# "pattern": r"https?://[^\s<>'\"]+", -# "cooldown_seconds": 600, # 10-min per-trigger cooldown -# "require_confirmation": True, # ask user before fetch -# "destructive": False, # read-only operation -# } +SKILL_OBSERVATION_TRIGGER = { + "type": "clipboard_pattern", + "pattern": r"https?://[^\s<>'\"]+", + "cooldown_seconds": 600, # 10-min per-trigger cooldown + "require_confirmation": True, # ask user before fetch + "destructive": False, # read-only operation +} import re diff --git a/tests/test_triggers.py b/tests/test_triggers.py index ce3c696..33f846d 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -46,11 +46,14 @@ def temp_audit_log(tmp_path, monkeypatch): @pytest.fixture def reset_state(monkeypatch, tmp_path): - """Reset trigger state + redirect killed-keys file to tmp_path.""" + """Reset trigger state + redirect killed-keys file + mute config to tmp_path.""" codec_triggers._reset_state_for_test() killed_path = tmp_path / "triggers_killed.json" + mute_path = tmp_path / "triggers.json" monkeypatch.setattr(codec_triggers, "_KILLED_PATH", killed_path) + monkeypatch.setattr(codec_triggers, "_MUTE_CONFIG_PATH", mute_path) codec_triggers._refresh_killed_cache() + codec_triggers._refresh_mute_cache() yield codec_triggers._reset_state_for_test() @@ -575,6 +578,55 @@ def test_observer_poll_evaluates_triggers(temp_audit_log, reset_state, assert len(called) == 1, "observer.poll should call triggers.evaluate" +# ───────────────────────────────────────────────────────────────────────────── +# §7.6 — Runtime mute config (3) +# ───────────────────────────────────────────────────────────────────────────── + +def test_muted_skill_name_skips_fire(temp_audit_log, reset_state, mock_dispatch): + """Skill listed in muted_skills → trigger evaluation skips dispatch and + emits trigger_muted. No real fire.""" + codec_triggers._MUTE_CONFIG_PATH.write_text( + json.dumps({"muted_skills": ["skill_x"]})) + codec_triggers._refresh_mute_cache() + + 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) + 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"] == "blocked_muted" for r in out) + assert mock_dispatch.call_count == 0 + muted = _events_of(_records(temp_audit_log), codec_audit.TRIGGER_MUTED) + assert len(muted) == 1 + assert muted[0]["extra"]["skill_name"] == "skill_x" + assert muted[0]["extra"]["mute_source"] == "muted_skills" + + +def test_muted_until_past_timestamp_not_muted(reset_state): + """muted_until[skill] in the past → _is_muted returns False (expired).""" + past_iso = "2020-01-01T00:00:00+00:00" + codec_triggers._MUTE_CONFIG_PATH.write_text( + json.dumps({"muted_until": {"skill_x": past_iso}})) + codec_triggers._refresh_mute_cache() + + assert codec_triggers._is_muted("skill_x") is False + + +def test_muted_until_future_timestamp_muted(reset_state): + """muted_until[skill] in the future → _is_muted returns True.""" + future_iso = "2099-01-01T00:00:00+00:00" + codec_triggers._MUTE_CONFIG_PATH.write_text( + json.dumps({"muted_until": {"skill_x": future_iso}})) + codec_triggers._refresh_mute_cache() + + assert codec_triggers._is_muted("skill_x") is True + + 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."""