Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion codec_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
)


Expand Down
145 changes: 144 additions & 1 deletion codec_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
TRIGGER_EVALUATED,
TRIGGER_FIRED,
TRIGGER_BLOCKED,
TRIGGER_MUTED,
log_event as _log_event,
)

Expand All @@ -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] = {}
Expand All @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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__ = [
Expand 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",
]
Loading
Loading