Imported from chris-peterson/ClaudeWatch (
AGENTS.md). Install upstream withnpx skills add chris-peterson/ClaudeWatch. Copyright stays with the author.
AGENTS.md — Working on ClaudeWatch
This is the build philosophy and key constraints document for ClaudeWatch.
The contract lives in SPEC.md; this document captures the how to
think about it. Every Claude Code session should read this before making
non-trivial changes.
What ClaudeWatch is
A Claude Code plugin that wraps every shell invocation — Bash and Monitor,
which runs its command in the same shell — in a PreToolUse hook and applies
regex-based safety rules. The core safety advantage over Claude
Code's built-in deny rules is that patterns match anywhere in the command
string (re.search()), so compound commands (git add . && git push --force),
heredocs, and reordered flags are not bypassable by syntactic tricks.
Core contracts (don't break these)
- Determinism. Given the same command, the same
cwdand project root, the samewatches/tree, and the same on-disk state of the paths those name, the engine must always produce the same decision. No clocks, no randomness, no network on the decision path.cwdandCLAUDE_PROJECT_DIRenter the decision only as the deterministic per-invocation inputs theis_in_project_treepredicate ([RL-15], [RL-16]) resolvesrmtargets against — pure string work, no filesystem access.CLAUDE_PROJECT_DIRis the one environment variable on the decision path;CLAUDEWATCH_LOGgoverns a side channel only. The one predicate that does read the filesystem isis_recoverable([RL-18]), which asks git whether a delete target could be restored — a question with no textual answer. Its calls are read-only, bounded, and fail closed, so the decision stays a function of the command, the roots, and the on-disk state those name; a second predicate wanting filesystem access is a spec change, not a local call. Decision logging (on by default, see [LOG-01]–[LOG-04];CLAUDEWATCH_LOG=offopts out) is a side channel: it stamps a timestamp and writes a file after the decision is computed, never feeding back into it. Keep it that way — the clock stays in_log_event, not inevaluate_rules. - Exit code is always 0. A non-zero exit blocks the host (Claude Code)
from getting a useful decision. All errors are surfaced as
denydecisions with explanatory messages. - Single coalesced decision per invocation. Multiple matching rules
across multiple rule sets aggregate into one
deny(preferred) or oneask. Never emit more than one decision. - The engine ships self-contained.
scripts/watchdog.pyand everything on the decision path use only the Python standard library (with a minimal built-in YAML parser), so the plugin installs cleanly and evaluates rules fast in any environment Claude Code runs in — no pip step on the hot path. Build- and release-time tooling that never runs on the decision path (e.g. shipyard'sgen-plugin-json, run by CI's project job) may use PyYAML; it never runs in the hook. - Allow-by-default. When no rule matches, the engine produces no stdout output. Silence is allow.
Build philosophy
- The engine is generic; the rules are domain-specific. Treat
scripts/watchdog.pyas a stable library. New safety domains are new YAML files inwatches/, not new code. unless_conditionpredicates are the one exception, so keep them thin. A predicate answers something a regex cannot — "does this path resolve inside the tree" — so it is domain logic living in the engine, and adding one is an engine change with a spec requirement behind it. Push everything that isn't the domain judgment down into a shared helper:_command_operandsparses POSIX utility syntax and takes the program as an argument, so a predicate for a different destructive tool reuses it rather than writing a second tokenizer that drifts from its guards. A predicate that has grown its own parsing is the signal that something belongs in the shared layer.- Make adding a rule set frictionless. Drop a
watch-*.ymlfile, add a test file, regenerate docs. No registration, no manifest, no engine change. - Rules are documentation. Every rule has a
reasonand (almost always) arefURL. The canonical message is<rule>: <reason> — <ref>, used verbatim in the prompt and the log alike; the rule-set name is omitted since therefURL supplies it. If you can't articulate why a rule exists, don't add it. - Block is for "no recovery"; ask is for "permanent but recoverable".
git push --forceblocks (overwrites remote history).git commitasks (you can amend or reset). When in doubt, preferask— block rules are un-bypassable. exceptis a noise filter, not a security exception.exceptonly applies to ask rules; block rules ignore it with a stderr warning. Useexceptto skip prompts on demonstrably-safe variants (rm -rf /tmp/...), not to make a block rule "softer."- Allow-by-default (deny-list) vs ask-by-default (allow-list). Most sets
are deny-lists: allow-by-default, enumerate the dangerous ops as block/ask —
you don't want to prompt on every
git status. Flip to an allow-list — one broad catch-allaskplus anexceptfor the safe subset — only when the tool surface is huge and mostly consequential (a cloud control plane), the safe subset is small and cleanly identifiable (AWSget-/list-/describe-/head-verbs), and an un-prompted action is costly (spend, data loss, prod change). Litmus test: enumerate the small side.watch-awsis the allow-list example;watch-gitis the deny-list example.
Repo conventions
- One Python file (
scripts/watchdog.py), one YAML format, one hook config (hooks/hooks.json). Resist refactoring into modules until there's a concrete reason — the simplicity is a feature. - Tests are bash scripts that pipe JSON to the engine and assert decisions.
Keep them readable and self-contained —
tests/test-watch-<name>.shmirrorswatches/watch-<name>.yml. Those files each load one rule set, which is how the engine is not invoked: the hook loads the whole directory, so a rule reading a token out of another tool's command costs a prompt no per-set file can see.tests/test-all-watches.shis the tier that evaluates againstwatches/entire — put a cross-set expectation there. - Docs are generated from rules YAML by
build/gen-rules-doc.py. Don't hand-editdocs/_sitecontent for rule references; edit the YAML and runjust docs.
When making changes
-
Rule edits — Use
/ClaudeWatch:rulesfor interactive edits; it validates and previews. Manual edits to YAML are fine for bulk changes, but runjust testbefore committing. -
Engine changes — Update
scripts/watchdog.pyandtests/test-engine.shin the same change. The engine has a small surface; every behavior should be exercised by a test. -
New or changed rule set — Add/edit
watches/watch-<name>.ymlandtests/test-watch-<name>.sh. Nohooks.jsonchange needed (auto-discovery). A rule set is described in hand-maintained indexes that drift independently — update whichever the change affects:README.md— rule-sets table (one row per set)SPEC.md[SH-01]— enumeration with block/ask coverage prose
The
docs/_sitereference is generated, not hand-maintained — runjust docs. A dev-time PostToolUse hook (.claude/settings.json→.claude/hooks/remind-rules-index.py) emits this same checklist after anywatches/watch-*.ymlWrite/Edit so it isn't forgotten; it is not part of the shipped plugin. -
Spec changes — Update
SPEC.mdfirst. If a code change reveals a spec problem (ambiguity, missing requirement), note it and resolve via the Gap Resolution Protocol (see the spec-driven recipe), don't silently change the implementation.
plugin.yml is canonical
plugin.yml (repo root) is the single source of truth for the plugin's
descriptor. .claude-plugin/plugin.json is generated from it — don't
hand-edit plugin.json. The suite: block also feeds the bridge.ai
marketplace SPA.
CI is the only writer. .github/workflows/project.yml runs shipyard's
project action on every push, which regenerates plugin.json, hooks.json,
and plugin.yml's suite.describe block from their sources and commits the
result straight back to the branch — so a committed artifact matches its
source at all times, and the diff a reviewer approves is the change that
lands. Editing plugin.yml, hooks/hooks.yml, a skill, or a rule needs no
local regeneration step; push, and the projection job's commit is what shows
up next.
To see what the projection job would write without keeping it — useful when
debugging a red run — use just check, then git restore . to discard.
release.yml resyncs the generated artifacts once more as a backstop when it
cuts a release, so plugin.json at a release tag is current even on the rare
run where the project job didn't land first.
Releasing
Releasing is one workflow_dispatch on .github/workflows/release.yml whose
only input is the bump level (patch, minor, major).
Write the notes before dispatching — reading what landed is what tells you the bump, so the two are one judgment:
git log $(git describe --tags --abbrev=0)..main --no-merges
Commit that section under ## Unreleased in CHANGELOG.md, then dispatch
with the bump level the notes imply. A missing or empty ## Unreleased
section fails the run.
Dispatching triggers shipyard's reusable release workflow, which:
- derives the next version from
plugin.yml, - retitles
CHANGELOG.md's## Unreleasedsection to that version, - resyncs the generated artifacts as a backstop (the project job already committed them when the source changed, so this ordinarily finds nothing to resync),
- commits the bump, then tags that commit — so
plugin.jsonat the tag reports the version the tag names, - publishes the GitHub Release from the section it just wrote, and
- notifies the bridge.ai marketplace to rebuild its catalog via
repository_dispatch.
main is always the latest released state, and version in plugin.yml
stays the source of truth — but it's now written by the release rather than
hand-edited. The chris-peterson marketplace tracks ClaudeWatch as an unpinned
git source (default-branch HEAD), so step 4's commit to main is the moment
consumers see the update; the tag and the marketplace notify are not what
claude plugin update reads.
Known constraints (do not paper over)
- macOS-style absolute paths appear in shipped rules (
~/.ssh/...,~/.aws/credentials). These are user-home patterns, not platform-specific per se — but the documentation references unix conventions. If cross-platform support becomes a goal, that's a spec change, not a quick fix. - YAML parser is minimal. It handles the format ClaudeWatch ships and
nothing more. Multi-line strings, anchors, and
!!tagconstructs are not supported. If you need them, that's a spec discussion, not a copy-paste of PyYAML. - Word normalization ([EN-15]) reaches the spellings that survive as a
literal word, not obfuscation in general.
"git" commit,g\it commit,git "commit"andrm "-r" /etcall resolve before matching, and the walk steps over an option to reach the subcommand behind it (git -C /repo "push"). What stays out of reach is a word assembled at runtime, because nothing in the command text says what it will be —C=git; $C commitis the shape to expect. A shell has unbounded ways to spell a word, so treat the rules as a guard against the destructive command an agent writes plainly, not as a sandbox against one trying to get past it. - Normalization changes how a word is spelled, never where a pattern looks.
Patterns search anywhere in the command, so
echo rm -rf /decides the same asrm -rf /— and after normalization so doesecho "rm" -rf /. That breadth is the deliberate trade in [RL-03]; resolving a spelling neither widens nor narrows it.
Reading order for new contributors
README.md— the elevator pitch and install pathSPEC.md— the contractscripts/watchdog.py— the engine (under 250 lines)watches/watch-git.yml— the canonical rule-set exampleSCHEMA.md— the YAML rule format reference (for rule authors)tests/test-watch-git.sh— the test pattern- This file — for "how to think about adding things"