To-Triggers — Author Vault Gate Triggers from Intent
Convert a stated rule into a trigger entry in vault.yaml. The division of
labor is fixed: the user owns the intent ("never let a tag happen before the
retro"); the agent owns the matching sophistication. A mis-authored trigger
either never fires (silent protocol decay) or fails the vault.yaml load
(fail-loud: one invalid trigger takes down every tool). Never hand the user a
regex question they didn't ask for.
Source of truth
The trigger schema is read at use time, never from memory:
- The
wikiskill'sreferences/vault-yaml.md§ Harness gate triggers (ships alongside these skills) — field vocabulary and examples. - The vault's own
vault.schema.json(vault root) andkmd validate— the runtime authority. When the reference file is unavailable, these suffice.
Interview flow — recommend first
Draft a complete trigger from the user's sentence, present it, and ask only what could not be inferred. Never open the interview with questions the intent already answers.
Intent class. Map the sentence:
The user wants Class Event a reminder when a topic comes up injectprompta nudge alongside a risky tool call injectorwarnpretoolan action denied until a precondition holds blockpretoolOnly
pretoolcan block. A prompt-time rule that sounds like a gate ("never tag before the retro") is really a pretool block on the tagging command plus, optionally, a prompt-time reminder — propose both, let the user drop one.Matching draft. Author the mechanics (rules below) with the trigger
id(descriptive kebab-case; duplicate ids keep the first occurrence).Payload register.
text(inject) andreason(block) are read by an agent mid-session: one ASCII line naming the protocol pointer, no banners. A blockreasontells the denied agent what to do instead, not just why.Scope. Propose where it lands: the active scope's
triggers_extra(additive), ortriggers_extra._allfor vault-wide. A full-replacetriggerssection only on explicit request — it drops the engine defaults and the compiled file source too.Precondition. When the rule is state-dependent ("until the retro is newer than the release note"), attach
when: newer-than {fresh, than}— the trigger fires when the predicate is FALSE, is suppressed when true, and is skipped loudly when unevaluable. The clock is frontmatterupdated; an emptythanset passes vacuously. Globs select the two page sets; each scope names its own retro/release conventions.
Matching mechanics — the part the user never writes
Keywords (prompt inject). The engine matches through a stemmed, word-boundary FTS5 porter table. Consequences:
- Two or three distinctive keywords beat a long list. Never enumerate word
forms —
releasealready matches "releasing", "released". - Word-boundary matching kills substring false positives ("prerelease" does
not match
release); don't defend against them with regex. intentregexes are the escape hatch for phrasings stemming cannot reach, not the default.
Pretool matchers. AND-composed, deterministic:
toolequals the event's tool name.args_matchis an authored, anchored regex over the serialized tool input (compound commands included). Draft it against real command shapes — for "block force pushes": matchesgit push --force origin mainandgit push -f, does not matchecho "force push". Write the near-miss counterexamples down; they become the dry-run cases.filesglobs are cwd-relativized:**crosses directories (**/may match empty),*stays within a segment,?is one character.
Noise budget. Name the cost of a broad draft before accepting it: inject-class dedup defaults to once per session per trigger, so a broad keyword still fires in every session; block-class is exempt from dedup and fires on every matching event. Propose the narrower match first, with the broad one as the explicit fallback. Two consequences worth designing for:
- One id sharing broad keywords and a sharp intent regex shares one dedup budget — an early keyword hit silences the sharp moment. Split into two triggers when the sharp match must survive keyword noise.
- The
dedupfield overrides the default per trigger:never(every match — pair only with sharp matchers),{minutes: N}(re-fires each bucket within a session — for long sessions that outlive their context). The schema rejectsdedupon block triggers.
Test before write — the dry-run loop
No draft touches vault.yaml untested. Write the candidate entries to a
temp file as a bare YAML list (exactly the entries, not nested under a
scope key) and pipe synthetic events through the engine:
cat > /tmp/triggers-draft.yaml <<'EOF'
- id: release-protocol-reminder
on: prompt
enforce: inject
keywords: [tagging, releasing]
text: "Release protocol: ops-publish-kmd is the release chain."
EOF
Synthetic events — one JSON object on stdin per run, always with
--explain:
# prompt event: {"session_id": "...", "prompt": "...", "cwd": "..."}
printf '%s' '{"session_id":"dry-1","prompt":"thinking about releasing tomorrow","cwd":"/tmp"}' \
| kmd hook prompt <vault-root> --explain --triggers /tmp/triggers-draft.yaml
# pretool event: {"session_id": "...", "tool_name": "...", "tool_input": {...}, "cwd": "..."}
printf '%s' '{"session_id":"dry-2","tool_name":"Bash","tool_input":{"command":"git push --force origin main"},"cwd":"/tmp"}' \
| kmd hook pretool <vault-root> --explain --triggers /tmp/triggers-draft.yaml
Reading the trace. --explain prints one JSON object naming the
resolved scope and, per trigger: the matcher verdict (hit, or which stage
missed — tool-miss/args-miss/files-miss/payload-miss; prompt
triggers carry keywords/intent evidence instead), the typed predicate
verdict (satisfied/unmet/vacuous/unknown), the dedup verdict
(exempt/never/fresh/suppressed), whether it fired, and the exact
outcome the harness would receive. Diagnose a near-miss from the trace,
never from empty output. Hook events always exit 0 — the trace is the only
signal.
No state spent. --explain never writes dedup state: probes repeat
stably, and a probe never silences the trigger for a live session. A
dedup: "suppressed" verdict means existing session state already carries
the key — retest under a throwaway session_id (dry-1, dry-2, …) to
see the fresh path.
Fire + near-miss discipline. Every matcher runs at least one
intended-fire case and one near-miss counterexample, and the user sees both
results. The near-misses were written down while drafting the matcher —
"prerelease" against keywords: [releasing] (word boundary holds),
echo "force push" against the force-push regex (no match). A matcher
proven only on its fire case is untested.
Predicate drafts. A when: newer-than candidate is tested against the
real vault filesystem read-only — the vault-root argument points at the real
vault so the globs select real pages. Never fabricate retro or release
fixtures inside a real vault to make a predicate evaluate.
Write gate
- Show the exact YAML block and its target key.
- Write only on explicit approval —
vault.yamlis a controlled-vocabulary surface. - Run
kmd validateafter the write. Red means revert before anything else: an invalid config fails the load for every tool, not just the new trigger.