Skip to content

Commit 2d2ff3f

Browse files
authored
Merge pull request #11 from AVADSA25/phase2-step6-triggers
feat(triggers): Trigger System (Phase 2 Step 6)
2 parents 26e6add + 02a1bfb commit 2d2ff3f

9 files changed

Lines changed: 1855 additions & 2 deletions

AGENTS.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,44 @@ CODEC has a background process (`codec-observer` PM2 service, `codec_observer.py
131131

132132
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).
133133

134+
### Trigger System (Phase 2 Step 6)
135+
136+
CODEC skills can declaratively auto-fire on observer signals. A skill adds a `SKILL_OBSERVATION_TRIGGER` dict alongside its existing `SKILL_TRIGGERS` list:
137+
138+
```python
139+
SKILL_OBSERVATION_TRIGGER = {
140+
"type": "window_title_match", # or clipboard_pattern / file_change / time / compound
141+
"pattern": r"Stripe — Dashboard",
142+
"cooldown_seconds": 600, # min seconds between fires (RAM-only state)
143+
"require_confirmation": True, # PWA approval gate before fire
144+
"destructive": False, # if True, routes through Step 3 §1.7 strict-consent
145+
}
146+
```
147+
148+
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).
149+
150+
**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).
151+
152+
**Cooldown**: per-trigger last-fired timestamp in RAM (process restart resets all). Trigger key = `<skill_name>:<sha8(trigger_dict)>` — editing a pattern resets cooldown via key change.
153+
154+
**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).
155+
156+
**Global kill switch**: `TRIGGERS_ENABLED=false` env var on `codec-observer` skips evaluation entirely.
157+
158+
**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.
159+
160+
**3 audit events**: `trigger_evaluated` (info, on match), `trigger_fired` (info, on dispatch), `trigger_blocked` (warning, with `block_reason`).
161+
162+
**PWA endpoints**:
163+
- `GET /api/triggers` — list all registered triggers + state
164+
- `GET /api/triggers/{key}` — detail with cooldown_remaining
165+
- `POST /api/triggers/{key}/kill` — toggle kill state
166+
167+
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).
168+
134169
### Other known gaps (tracked for Phase 2 follow-on)
135170
- No formal teammate / sub-agent recursion — Crew is the only multi-agent primitive
136-
- Step 6 (Triggers) and Step 7 (Shift Report Crew) — Phase 2 Steps still pending
171+
- Step 7 (Shift Report Crew) — final Phase 2 step still pending
137172

138173
## 4. Skill system
139174

@@ -262,6 +297,17 @@ Four new event names exported from `codec_audit.py` for the Continuous Observati
262297

263298
`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`.
264299

300+
### Phase 2 Step 6 audit events (Trigger System)
301+
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`.
302+
303+
| Event | Source | level | extra fields |
304+
|---|---|---|---|
305+
| `trigger_evaluated` | `codec-triggers` | info | `trigger_key`, `skill_name`, `trigger_type`, `match_summary` |
306+
| `trigger_fired` | `codec-triggers` | info | `trigger_key`, `skill_name`, `trigger_type`, `dispatch_correlation_id` |
307+
| `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. |
308+
309+
`PHASE2_STEP6_EVENTS` frozenset exposed.
310+
265311
### Notifications (`~/.codec/notifications.json`)
266312
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`.
267313

@@ -402,7 +448,10 @@ These zones break running infrastructure if changed without coordination. NEVER
402448
- `~/.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).
403449
- `~/.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.
404450
- `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.
405-
- `~/.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.
451+
- `~/.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).
452+
- `~/.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.
453+
- `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.
454+
- `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.
406455

407456
## 11. Working with this repo as a coding agent
408457

codec_audit.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,34 @@
202202
)
203203

204204

205+
# ── Phase 2 Step 6 event names (Trigger System) ───────────────────────────────
206+
# Per docs/PHASE2-STEP6-DESIGN.md §3. trigger_evaluated and trigger_fired are
207+
# `level="info"` (operational); trigger_blocked is `level="warning"` because
208+
# block_reason values flag user-action-required or consent-failure states.
209+
# All inherit `correlation_id` from the wrapping observer poll's cid.
210+
TRIGGER_EVALUATED = "trigger_evaluated"
211+
TRIGGER_FIRED = "trigger_fired"
212+
TRIGGER_BLOCKED = "trigger_blocked"
213+
214+
PHASE2_STEP6_EVENTS = frozenset({
215+
TRIGGER_EVALUATED, TRIGGER_FIRED, TRIGGER_BLOCKED,
216+
})
217+
218+
# Step 6 event-specific extra-field reservations.
219+
TRIGGER_EXTRA_FIELDS = (
220+
"trigger_key", # "<skill_name>:<sha8(trigger_dict)>"
221+
"skill_name", # str
222+
"trigger_type", # window_title_match | clipboard_pattern |
223+
# file_change | time | compound
224+
"match_summary", # short, on trigger_evaluated
225+
"dispatch_correlation_id", # on trigger_fired only
226+
"block_reason", # on trigger_blocked only:
227+
# cooldown | user_skipped |
228+
# confirmation_timeout |
229+
# ambiguous_consent | killed
230+
)
231+
232+
205233
# ── Helpers ────────────────────────────────────────────────────────────────────
206234
def _truncate(s, max_len: int = _PREVIEW_MAX) -> str:
207235
"""Truncate a string to `max_len` chars. None/non-str → ''. Never raises."""

codec_dashboard.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,21 @@ async def dispatch(self, request, call_next):
307307
from routes.agents import router as agents_router
308308
from routes.memory import router as memory_router
309309
from routes.websocket import router as websocket_router
310+
# Phase 2 Step 6 — Trigger System PWA endpoints (auth-gated by /api/* middleware).
311+
try:
312+
from routes.triggers import router as triggers_router
313+
_has_triggers = True
314+
except Exception as _e:
315+
log.debug(f"[triggers] routes not loaded: {_e}")
316+
_has_triggers = False
310317

311318
app.include_router(auth_router)
312319
app.include_router(skills_router)
313320
app.include_router(agents_router)
314321
app.include_router(memory_router)
315322
app.include_router(websocket_router)
323+
if _has_triggers:
324+
app.include_router(triggers_router)
316325

317326

318327
# ═══════════════════════════════════════════════════════════════

codec_observer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,17 @@ def poll(buffer: Optional[RingBuffer] = None,
557557
_emit_observation_tick(snapshot, cadence, poll_duration_ms,
558558
len(buffer), float(cfg["poll_slow_threshold_ms"]))
559559

560+
# Phase 2 Step 6 — evaluate registered triggers against this snapshot.
561+
# Inline (not a separate PM2 service) — observer poll is the only event
562+
# source, so triggers piggyback on the same cadence. Try/except so
563+
# trigger failures NEVER break observer polling.
564+
if emit_audit: # only fire triggers from real polls, not test polls
565+
try:
566+
from codec_triggers import evaluate as _eval_triggers
567+
_eval_triggers(snapshot)
568+
except Exception as e:
569+
log.debug("[observer] trigger evaluation failed (non-fatal): %s", e)
570+
560571
return snapshot
561572

562573

codec_skill_registry.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ def _extract_metadata(filepath: str) -> Optional[Dict[str, Any]]:
3838
"SKILL_DESCRIPTION",
3939
"SKILL_TRIGGERS",
4040
"SKILL_MCP_EXPOSE",
41+
# Phase 2 Step 6 — declarative auto-fire trigger (Q3).
42+
# AST extraction; validation happens in codec_triggers.
43+
"SKILL_OBSERVATION_TRIGGER",
44+
# Phase 2 Step 5 §X — skill-flag injection override.
45+
"SKILL_NEEDS_OBSERVATION",
4146
):
4247
try:
4348
meta[target.id] = ast.literal_eval(node.value)
@@ -120,6 +125,13 @@ def get_mcp_expose(self, name: str) -> Optional[bool]:
120125
meta = self._meta.get(name, {})
121126
return meta.get("SKILL_MCP_EXPOSE", None)
122127

128+
def get_observation_trigger(self, name: str) -> Optional[Dict[str, Any]]:
129+
"""Phase 2 Step 6 — return the SKILL_OBSERVATION_TRIGGER dict
130+
for a skill, or None if not declared. Validation happens in
131+
codec_triggers; this just surfaces what AST extracted."""
132+
meta = self._meta.get(name, {})
133+
return meta.get("SKILL_OBSERVATION_TRIGGER", None)
134+
123135
# ── Lazy module loading ─────────────────────────────────────────────
124136

125137
def load(self, name: str) -> Optional[Any]:

0 commit comments

Comments
 (0)