Buckler is a harness-neutral policy engine. Its core evaluator knows nothing about Cursor's hooks.json or Claude Code's hook config. Only thin adapters do.
┌─────────────────────────────────────────────┐
│ Harnesses │
│ Cursor hooks Claude Code hooks Other │
└────────┬──────────────┬───────────────┬─────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────┐
│ Adapters │
│ buckler.adapters.cursor (v1 — shipped) │
│ buckler.adapters.claude (stub) │
│ buckler evaluate CLI (generic) │
└────────────────────┬────────────────────────┘
│ PolicyInput (JSON)
▼
┌─────────────────────────────────────────────┐
│ buckler.core │
│ evaluate(PolicyInput) → PolicyOutput │
│ No harness imports. │
└────────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ YAML Packs │
│ packs/agent-git.yaml, packs/agent-gh.yaml (default-on) │
│ ~/.config/buckler/rules.d/*.yaml │
└─────────────────────────────────────────────┘
The invariant: buckler.core may only import from:
- The Python standard library
buckler.pack_loader(which is also harness-free)buckler.__init__(constants)
If a proposed change to buckler.core requires importing buckler.adapters.* or any harness-specific module, that code belongs in an adapter or in the CLI dispatch layer.
This boundary is verified in practice by the test split:
tests/test_core.py— works only withPolicyInputJSON fixtures; passes without any Cursor dependency.tests/test_adapter_cursor.py— imports frombuckler.adapters.cursorand uses Cursor-shaped fixtures.
The version field in PolicyInput and PolicyOutput is a monotonically increasing string integer ("1", "2", …).
| Kind of change | Requires version bump? |
|---|---|
Add an optional field to PolicyInput or PolicyOutput |
No |
| Remove a field | Yes |
| Rename a required field | Yes |
| Change the semantics of an existing field | Yes |
Add a new trigger value |
No (backward-compatible extension) |
Rename a trigger value |
Yes |
When the version is bumped:
- Update
policy_io_versionconstant inbuckler/__init__.py. - Update
docs/contracts/policy-io.mdwith a migration section. - Update
docs/contracts/policy-io.schema.json(add new version to enum or use anyOf). - Adapters must check
policy_io_versionand raisePolicyErrorfor unsupported versions. - Document the bump in
CHANGELOG.md(or release notes).
Packs are YAML files processed by buckler.pack_loader.load_packs():
- Builtin packs from
buckler.paths.packs_dir()(the installedpacks/directory), sorted by filename. - User overlays from
buckler.paths.user_rules_dir()(~/.config/buckler/rules.d/*.yaml), sorted alphabetically.
Rule precedence: higher priority wins; ties broken by action severity (deny > ask > nudge > allow).
Tier filtering: strict rules are excluded unless config.toml sets tier = "strict".
Each adapter is a module with two pure functions:
def adapt_input(raw: dict) -> dict: # returns PolicyInput
...
def adapt_output(output: dict, raw_input: dict) -> dict: # returns harness response
...The adapter maps the harness's native event names to abstract trigger values (pre_shell_exec, pre_shell_tool, post_tool_success, post_tool_failure). Rules reference only these abstract triggers, so they work unchanged across adapters.
buckler [--driver cursor] → cursor adapter (reads stdin, writes stdout)
buckler evaluate [--input/-i F] → harness-neutral (reads PolicyInput, writes PolicyOutput)
buckler validate → validate pack + user rules YAML (exit 1 on errors)
python -m buckler.hooks merge → idempotent hooks.json update
python -m buckler.hooks strip → remove Buckler entries from hooks.json
python -m buckler.hooks status → show current Buckler entries
The BUCKLER_DRIVER environment variable sets the default driver (default: cursor).
buckler.paths is the single source of truth for all file system paths. scripts/setup.sh mirrors its logic in Bash. If either changes, both must change together.
See docs/paths.md.
This is not implemented in v1 but reserved for future exploration:
Goal: Allow third-party packs to be distributed as signed artifacts that setup.sh update can fetch and verify separately from the core Buckler release.
Design sketch:
- A
plugintop-level key in a YAML pack declares a pack as a plugin with a registry URL and a Cosign bundle reference. buckler plugin install <name>fetches, verifies, and installs into~/.local/share/buckler/plugins/.- Plugins are loaded after builtin packs and user rules.
- The plugin registry is out of scope for v1.
This RFC exists to prevent buckler.core from assuming it is the only source of packs, and to keep the pack loading architecture extensible.