Rules your coding agent physically cannot ignore. A Claude Code plugin (and LLM-agnostic rule pack) that stops reactive patches, guessed citations, surface-level "fixes", and premature "done" claims — by intercepting the agent's own tool calls, not by asking it nicely.
Every "be rigorous" instruction you put in a prompt has the same flaw: the model decides whether to follow it. Under pressure — a long session, a compacted context, a failing test at 2am — it decides not to, and tells you it did.
cc-enforcer moves the important half of that contract out of the prompt and
into the harness. Claude Code hooks run before each tool call and before
each reply is allowed to end. A violation returns a real deny / block
verdict, so the agent cannot reason, apologise, or "just this once" its way
past it:
cc-enforcer · rule 09 violation (rolling-patch interception)
Tool: Edit
Target: hooks/scripts/stop_guard.py
Rolling-patch counter: 3 small edit(s) already applied this session;
this would be attempt #4 — at or above the threshold of 4.
To proceed, do one of:
(1) Systematic rewrite: combine your pending small fixes into a single
Edit of ≥ 50 lines / ≥ 1500 chars. Resets the counter.
(2) Batch multiple typo-class fixes into one larger Edit.
(3) Stop and surface: tell the user this file needs a rewrite.
That message is not advice printed after the fact. The edit did not happen.
Three things follow from that design:
| It survives compaction | Rules are re-injected every turn, and the hard layers live in code that does not depend on the model remembering anything. |
| It cannot be self-whitelisted | Built-in guards run before user rules, and the read-registration escape hatch is SHA-256 verified against disk — an agent that never opened a file cannot produce its digest. |
| It fails open, never closed | Any exception inside a guard logs to stderr and allows the call. A bug in the discipline can never brick your agent. |
LLM coding agents (Claude Code, Cursor, Copilot, Cline, Aider…) fall into predictable lazy patterns:
| Lazy pattern | What it looks like | Answer |
|---|---|---|
| Reactive patching | Sees a bug, wraps it in try/except, declares done. |
rule 03 + 09, PreToolUse DENY |
| Guessed citations | Cites files, line numbers or APIs that do not exist. | rule 01 + 05, Stop layer (b)/(g) |
| Keyword-search-only | Greps once, edits, never reads the surrounding architecture. | rule 04 + 08, PreToolUse DENY |
| Memory dependence | Acts on stale recollection instead of re-reading the file. | rule 04 + 08, read-before-edit gate |
| Root-cause bypass | sleep for races, --no-verify for hooks, swallowed exceptions. |
rule 03, PreToolUse(Bash) DENY |
| Half-finished work | Stops at "should work", leaves TODOs, skips the whole flow. | rule 07, Stop layer (d) |
| Premature done-claim | "Fixed" without re-running the failing case or comparing evidence. | rule 06, Stop layers (a)/(c) |
The repo ships .claude-plugin/marketplace.json, so it registers as a
single-plugin marketplace:
git clone https://github.com/skymanbp/cc-enforcer.git /path/to/cc-enforcerThen in any Claude Code session (CLI or IDE):
/plugin marketplace add /path/to/cc-enforcer
/plugin install cc-enforcer@cc-enforcer
Verify with /plugin → Installed should list cc-enforcer@cc-enforcer.
Commands then surface as /cc-enforcer:checklist, /cc-enforcer:verify, …
Requirements: Python on PATH (tested on 3.13). Hook scripts use the standard library only — no pip step, no third-party packages.
You do not need Claude Code. The rules are plain Markdown in rules/
(English is the source of truth; rules/zh/ is the Chinese
translation):
cat rules/*.md > cc-enforcer.txt # English skeleton
cat rules/zh/*.md > cc-enforcer.txt # Chinese translation
# feed as system prompt / pre-context to the agent of your choiceYou lose the hard layers (they are Claude Code hooks) and keep the reasoning
discipline. Integration patterns for OpenAI / Gemini / local models are in the
LLM portability section of docs/ARCHITECTURE.md.
Five categories, in order of how hard they bite.
The reasoning contract. Roughly half are backed by a hook; the rest are text-level discipline the Stop gate grades indirectly.
| # | Rule | What it demands | Enforcement |
|---|---|---|---|
| 01 | Verify, don't guess | Every claim about a file, API, version, error or citation is checked in the same turn it is written. "I don't know" beats a confident wrong answer. | soft + Stop (b)(g) |
| 02 | Systematic, not reactive | Answer seven questions before editing: architecture, responsibility, root cause, plan, ripple, risk, global validity. Ships a worked example, not an exhortation. | soft (graded by (e)) |
| 03 | Root cause, not symptom | Climb the causal chain — symptom site → propagation path → origin — until the answer is a mechanism. Stopping early is legal only if you name the true origin and say why. | Bash DENY + soft |
| 04 | Read fully, not keywords | Grep locates; understanding requires the whole file plus its callers. | Edit/Write DENY |
| 05 | Cite traceable sources | file:line for code, URL/DOI for literature, command + output for runtime claims. |
soft |
| 06 | Verify convergence | Re-trigger the original symptom, exercise boundary and negative cases, run existing tests, answer four self-quiz questions. Check 2b: "no regression" must be a per-item set diff, never a matching total. | Stop (a)(c) |
| 07 | Task fidelity | Decompose the user's request into checkable sub-items; every modifier word they used ("all", "strict", "mandatory") must land as a hard action, not a doc line. | Stop (d) |
| 08 | Read before edit, think before write | Full read going in; root cause / architecture / impact stated coming out. | DENY + Stop (e) |
| 09 | Systematic modification | One root cause, one unified fix — sweep the whole class, never N patches. Suppression markers need an adjacent why. | DENY ×2 + Stop (f) |
| 10 | No non-essential hardcoding | Secrets, tokens, private keys and credentials-in-URLs never become source literals. | Edit/Write DENY |
| 11 | No non-essential path dependency | No C:\Users\… / /home/you/ / $HOME baked into code; derive paths at runtime. |
Edit/Write DENY |
| 12 | Repo-wide sync | An edit is done only when every reference to it — docs, tests, downstream code, translations — is co-updated or explicitly verified current. | Stop (i), opt-in |
Full text: rules/ · index: docs/RULES.md
These refuse the call outright. Every one ships a named escape hatch, so they are bypassable by saying why, never by accident.
| Gate | Triggers on | Escape hatch |
|---|---|---|
| Read-before-edit | Editing a file that exists on disk but was never opened this session. | Read it; or register a SHA-256-verified read. |
| Suppression markers | # noqa, # type: ignore, @ts-ignore, @ts-expect-error, eslint-disable, time.sleep(…) workarounds. |
Adjacent why-comment (English or Chinese), or fix the cause. |
Bare try/except: pass |
An exception handler whose body is just pass — detected across lines, comments in between included. |
Same why-comment hatch. |
| Hardcoded secrets | Secret-named literal, PEM private-key header, AKIA…, ghp_… / xox… / AIza…, user:pass@host URLs. |
Env var, marked placeholder, or why-comment. |
| Machine-specific paths | C:\Users\…, /home/<user>/, /Users/<user>/, $HOME, %USERPROFILE%, quoted ~/…. |
Derive at runtime, or why-comment. Prose docs and lockfiles exempt. |
| Rolling patches | The 4th small edit (< 200 chars and ≤ 10 lines) to one file with no systematic rewrite in between. | One rewrite of ≥ 50 lines / ≥ 1500 chars resets the counter. |
| Dangerous shell | --no-verify, --no-gpg-sign, git push --force (not --force-with-lease), chmod 777, git rebase --skip, --break-system-packages, rm -rf on / $HOME ~. |
Fix the hook failure / permission / conflict instead. |
| Your own edicts | Any regex you registered as a must edict. |
Only you can relax it. |
The Stop hook reads the reply the agent is about to finish with. If it contains a completion claim, nine layers grade it. Layers (e)(f)(g)(i) apply only to turns that actually edited a file.
| Layer | Rule | Blocks when the reply… |
|---|---|---|
| (a) | 06 | claims done with no evidence — no command, no output, no counts. |
| (b) | 01 | pairs a done-claim with a hedge ("should be fine", "probably", "应该"). |
| (c) | 06 | shows evidence but never answers the four convergence questions. |
| (d) | 07 | passes convergence but never reconciles against the user's original request. |
| (e) | 08 | edited a file without surfacing ≥ 3 of root cause / architecture / solution / impact / risk. |
| (f) | 09 | edited a file without the root cause + impact + solution triplet. |
| (g) | 01+06 | says "I edited X" while X's mtime on disk is unchanged. |
| (h) | — | has no tldr, or a tldr item longer than 160 characters. |
| (i) | 12 | tripped a project sync-gate group with no co-update and no sync-check: line. |
A block renders as a uniform status table naming the failing row, a
[Recovery — …] section with concrete fix steps, and a one-line plain-language
summary. Grace is per layer (v0.29): the layer that just blocked is forgiven
on the next attempt — you can never be blocked twice for the same row — but a
different layer you are still violating will still fire. Escalation is bounded
by the layer count and any clean reply resets it.
The table reports evaluation order, not the alphabet (v0.30). (b) runs first, because a hedge invalidates a done-claim however much evidence sits beside it, so a layer-(a) block shows "(b) ✅ Pass" and a layer-(b) block shows "(a) ⏸ pending". Until v0.30 both verdicts came from the display index, so a hedge block printed "(a) ✅ Pass" — asserting evidence had been found on a turn where the evidence check never ran. A gate built to catch unfounded claims does not get to make one.
Most "custom rules" features are just more text in a prompt. Here your rule becomes a regex a hook matches against the literal content of every Edit, Write and Bash call before it lands:
/cc-enforcer:edict add E01 "no mongoose, use prisma" --must \
--deny-edit 'from ["'"'"']mongoose["'"'"']' \
--deny-bash 'npm\s+(i|install)\s+mongoose'- Two severities, mechanically different.
must+ a regex →PreToolUseDENY naming the edict id.should→ reminder text only, never denies. - Two scopes.
.claude/cc-enforcer/edicts.toml(project, commit it so the team shares the red line) or~/.claude/cc-enforcer/edicts.toml(--global). - Hot-reloaded — the loader re-reads on every hook event, so you can iterate on a regex mid-session.
- Re-injected every turn, so compaction cannot quietly drop your rules.
- Ordered after the built-ins by design: an edict can add restrictions, never subtract them.
- Fails safe, not quiet: an unparseable severity falls back to
must, and a malformed edict drops itself with a stderr diagnostic instead of blocking work.
Details: docs/EDICTS.md
6 slash commands, one subagent, two auto-triggered skills:
| Surface | What it does |
|---|---|
/cc-enforcer:checklist |
Prints the 8-section checklist (pre-edit → post-edit → convergence → fidelity → read/think → systematic → TL;DR → repo sync). Its patch-marker item is a closed set synchronised with the hook's real constant, so you cannot tick every box and still get denied. |
/cc-enforcer:verify |
Turns the agent's last reply into untrusted input: extracts every factual claim, buckets it (code location / behaviour / external / run result) and prescribes a re-verification method per bucket. Recall is explicitly not allowed. |
/cc-enforcer:edict |
list / add / remove / reload / path for Imperial Edicts (--global for personal scope). |
/cc-enforcer:gc |
Lists — or with --apply, deletes — session-state files older than N days. Dry-run by default, and the command file forbids the agent from choosing --apply for you. |
/cc-enforcer:i18n |
Checks every translation still matches the English skeleton file-for-file and heading-for-heading. |
/cc-enforcer:sync-gate |
init / list / check / add / remove / path for this project's rule-12 co-update groups. check is the point: the gate's loader is failing-open, so a dropped group or a glob that matches no file makes it stop guarding silently — an unenforced gate you still trust is worse than none. check names both and exits 1, so it works in CI. |
verifier subagent |
A deliberately crippled read-only checker (Read/Grep/Glob only — a permission fact, not an instruction). Returns intact / drift / missing / mismatch / unverifiable per claim. It cannot become the fixer, so it has no incentive to quietly patch a discrepancy. |
systematic-debug skill |
Auto-triggers on bug-fix language and takes over the workflow: build a fast deterministic reproduction loop first, then hypothesise. "A 30-second intermittent flaky loop is barely better than no loop; a 2-second deterministic loop is a debugging superpower." |
repo-refresh skill |
Auto-triggers on repo-audit language: sweeps code and prose together for stale / outdated / redundant / wrong / drifted content, then asks you to convert whatever coupling it found into a sync-gate group. |
The interesting engineering is in the detectors, and most of it exists because the naive version demonstrably failed:
- Source is lexed, not regexed.
line.find("#")finds the#inside a URL — which once let a single neighbouringAPI = "https://api.example.com"line disable the secret detector entirely (examplereads as a rationale token).lib/srclex.pydistinguishes code from comment from docstring from data literal, with literal masking and bracket-joined logical lines. - Shell is tokenised, not string-matched.
lib/shellcmd.pysplits a compound command into per-invocation argv, resolves git global options to the true subcommand, and recurses intobash -cpayloads. Sorm -f build.log && git push origin mainnever attributes-fto the push,$(git push --force)is still denied, andecho git commit --no-verifyis correctly allowed. - Markers end at real token boundaries.
\btreats a hyphen as a boundary, so a naive matcher denied@ts-ignore-generatedand# noquality. The detectors use(?![\w-]). - The escape hatch is self-securing. The read-registration hatch recomputes
the file's SHA-256 from disk; an agent that never opened it cannot produce the
digest.
false && register_read.py …earns no credit either — the hook fires before execution and cannot know which branch the shell takes. - Rationales must be substantive. A leading
TODO/FIXME/HACKis a deferral, not a reason, and still denies. Decorative padding is stripped first, and CJK rationales are measured by distinct-character count. - Concurrency is handled, with numbers. Every hook invocation is a separate
OS process and Claude Code fires tools in parallel. Mutations hold a
cross-process advisory lock and save atomically — measured before the fix:
2–3 of 10 recorded reads lost at 10-way parallelism, and 192/200 saves lost to
Windows
os.replace-vs-open-reader collisions. - The repo is held to its own rules. Three CI gates make documentation claims
un-drift-able: a version gate (every version pointer, the badge and the newest
CHANGELOG heading pinned to
plugin.jsonby a closed set — "a blacklist would have let it through"), a doc gate (every number in this README derived from the code at test time), and an i18n gate.
| Event | Matcher | Behaviour | Implementation |
|---|---|---|---|
SessionStart |
— | Inject the 12-rule discipline summary + reply schema + Imperial Edicts (English by default, any language via CC_ENFORCER_LANG). |
inject_context.py |
UserPromptSubmit |
— | Re-inject per-turn decision triggers + edicts — the defence against context compaction. | inject_context.py |
PreToolUse |
Read|Edit|Write |
Record reads, capture mtime baselines, and run the content + frequency + edict gates listed above. | read_guard.py |
PreToolUse |
Bash |
Tokenise the command, deny bypass flags and destructive operations, process read registrations, scan edicts. | bash_guard.py |
Stop |
— | The nine-layer done-claim decision, rendered as a status table + recovery + plain-language line. | stop_guard.py |
Both injections are budgeted against Claude Code's 10,000-character hook-output cap: the contract is protected and the (unbounded) edict list is what yields, elided at whole-edict boundaries with a pointer — because half an edict still reads as a complete instruction.
Nine scripts under hooks/scripts/ sit on nine shared
lib/ modules. Only the four in the table above are
registered as hooks; the other five (register_read.py, manage_edicts.py,
manage_sync_gate.py, gc_state.py, i18n_check.py) back the escape hatch,
the slash commands and CI. They deliberately stay in the same directory rather
than moving to a tools/ tree: gc_state.py is imported by
inject_context.py for auto-GC and register_read.py's real logic lives
inside bash_guard.py, so neither is a standalone CLI, and separating them
would buy a tidier directory name with a cross-tree sys.path splice. Full
contracts: docs/ARCHITECTURE.md §2.
| Variable | Effect |
|---|---|
CC_ENFORCER_LANG=<code> |
Injection language for prompts, edicts and deny reasons. Unset / en = English skeleton; zh = Chinese; any other code reads <dir>/<code>/ with per-file fallback to English. |
CC_ENFORCER_DISABLE_LAYER_G=1 |
Turn off Stop layer (g) file-claim verification. The other eight layers still apply. |
CC_ENFORCER_AUTO_GC_DAYS=N |
Auto-prune session state older than N days at SessionStart, rate-limited to once per 24h. Unset / 0 → disabled. |
CLAUDE_PLUGIN_DATA |
Session-state base dir. Set by Claude Code; falls back to ${CLAUDE_PROJECT_DIR}/.claude/local/cc-enforcer/, then ~/.claude/local/cc-enforcer/. |
CLAUDE_PROJECT_DIR |
Project root, used to resolve .claude/cc-enforcer/edicts.toml and sync-gate.toml. |
Per-project sync gate (rule 12) is opt-in: declare co-update groups in
.claude/cc-enforcer/sync-gate.toml and Stop layer (i) enforces them.
Stated plainly, because a discipline plugin that oversells itself is the failure mode it exists to prevent:
- Everything fails open. A guard that throws logs to stderr and allows the call. Unreadable state is treated permissively. This is deliberate — a bug in the plugin must never brick your agent — but it means enforcement is best-effort, not a security boundary.
- The Stop layers only engage on a completion claim. A reply that never says "done" is never graded.
- Rules 02 and 05 have no hook of their own, and the reasoning half of rules 03, 09 and 12 is text-level. No hook can verify that you actually swept a defect class; it can only verify that you said you did.
- The hard layers are Claude Code-specific. Other agents get the rule pack.
- Detectors prefer misses to false alarms —
宁可漏报不误报. Documented gaps live in each rule file rather than being quietly patched.
cc-enforcer/
├── rules/ # 12 rules + index — English skeleton (source of truth)
│ └── zh/ # Chinese translation, structurally CI-gated
├── prompts/ # SessionStart + per-turn injections (+ zh/)
├── hooks/
│ ├── hooks.json # event → script wiring
│ └── scripts/
│ │ # -- hook entry points (the four in hooks.json) --
│ ├── inject_context.py # soft layer: SessionStart + per-turn injection
│ ├── read_guard.py # hard layer: read-before-edit, content + frequency gates
│ ├── bash_guard.py # hard layer: command discipline, read registration
│ ├── stop_guard.py # hard layer: the nine-layer done-claim gate
│ │ # -- auxiliary entry points (not hooks) --
│ ├── register_read.py # SHA-256-verified read-cache escape hatch
│ ├── manage_edicts.py # Imperial Edicts CRUD CLI
│ ├── manage_sync_gate.py # rule-12 co-update groups: CRUD + `check` diagnostics
│ ├── gc_state.py # session-state GC: CLI + auto-GC callee
│ ├── i18n_check.py # skeleton ↔ translation structural parity
│ └── lib/ # -- nine shared modules --
│ ├── srclex.py # judgement: code vs comment vs docstring vs literal
│ ├── mdctx.py # judgement: markdown fence / blockquote context
│ ├── shellcmd.py # judgement: tokenise → segments → argv → subcommand
│ ├── state.py # state: per-session, cross-process lock, atomic save
│ ├── tomlio.py # config: tolerant TOML reader + the shared writer
│ ├── projroot.py # config: project-root detection, shared by both loaders
│ ├── edicts.py # feature: Imperial Edicts loader / matcher / renderer
│ ├── envfile.py # feature: CLAUDE_ENV_FILE dedupe hygiene (v0.34)
│ └── sync_gate.py # feature: rule-12 groups — read, write and match
├── commands/ # 6 slash commands
├── agents/verifier.md # read-only citation checker subagent
├── skills/ # systematic-debug, repo-refresh (auto-invoked)
├── docs/ # index + ARCHITECTURE, RULES, EDICTS, I18N
└── tests/ # 617 black-box + unit tests (python -m unittest discover tests)
│ # each file is named after what it covers — see tests/README.md
├── _helpers.py # shared run_hook(...) subprocess fixture
├── test_<hook>.py # black-box subprocess tests, one per hook entry point
├── test_<lib|cli>.py # unit tests for shared modules and auxiliary scripts
├── test_version_sync.py # drift gate: every version pointer vs plugin.json
├── test_doc_sync.py # drift gate: documented counts + inventories vs code
├── test_i18n_sync.py # drift gate: every translation vs the English skeleton
└── test_audit_*.py # per-audit-round regression suites (v026 x2, v027)
All scripts are covered by 617 tests in tests/ — black-box
subprocess tests that launch each hook exactly as Claude Code does (module-level
state, stdin, stdout buffering and exit codes all differ when a script is
imported instead), plus unit tests for the shared models and the three drift
gates. CI: ubuntu-latest × windows-latest × Python 3.13, fail-fast: false,
zero dependencies. The Windows leg is not box-ticking — several regressions here
are Windows-only by construction (os.replace sharing violations, \r\n
defeating end-of-line anchors, unquoted drive paths).
v0.32.1 — the roadmap is empty, by decision. Both remaining entries were
retired rather than carried: per-session ephemeral edicts (structurally
blocked — the edict CLI is a Bash subprocess with no session_id) and the
layer-(g) content-hash upgrade (premise measured false — mtime here resolves to
1 ms, while layer (g) compares a first-encounter baseline against closing
time, seconds apart). A feature list carrying entries nobody will build is the
staleness this repo exists to catch. The release also caught rules/12 still
describing the pre-v0.32 escape hatch — and rules/ is the LLM-agnostic
product, so a stale rule file ships weaker discipline to every non-Claude-Code
consumer.
v0.32.0 — two things v0.31 recorded instead of closing. Both decided by the user; neither is a defect fix, so that one was a minor.
Layer (i) rejects an acknowledgement that answers nothing. v0.31.1 named
the consequence of making sync-check mandatory — a required field invites
boilerplate, and sync-check: n/a settled a named group as firmly as a real
sweep report. It now must be the agent's own line (read through the same
lib/mdctx model layer (h) uses, so a quoted or fenced marker does not count)
and carry content:
同步核对: prompts 侧核对过,本次改动不影响注入文案。 → settles the group
sync-check: n/a · 无 · - · (empty) → BLOCK (v0.32)
Pinned by its own test so the limit cannot drift into an implied guarantee:
sync-check: checked it is just as empty and still passes. This closes the
bottom tier — bare placeholders — and claims nothing more, because refusing an
honest report is the worse error. A deliberate strictness increase.
check now runs against this repository, in CI. v0.31.0 shipped it on the
argument that an unenforced gate you still trust is worse than none, gave it a
non-zero exit so it could run in CI — and then never ran it on itself. Wired as
a test (matching test_i18n_sync, so a local unittest run covers it too),
with the twin that keeps it from passing on an empty config. Suite 594 → 601
tests.
other closing obligation — before / edits / convergence / fidelity / closing / tldr — has been a schema field since v0.20, and the v0.20 design is that the
field name IS the Stop-hook marker. Rule 12 arrived three releases later, so
its acknowledgement was the one duty with no slot to write it in. Now:
closing: {root cause: ..., impact: ..., solution: ...}
sync-check: <co-files updated, or why none needed> # rule 12, edit turns
tldr: "<one plain sentence>"No detector changed — the key already matches SYNC_MARKERS. It does not
weaken layer (i), which was verified by probe rather than by reading the
code: since v0.27 a marker settles only groups a previous block actually
named, so a first violation still blocks and names its group. Also fixed: the
Chinese README's version badge had drifted two releases behind while every gate
was green — the badge check pinned README.md and not its mirror, so it now
discovers every README*.md from disk. Suite 590 → 594 tests.
enforceable since v0.23 and authorable only by hand-writing TOML — with no way
to see what the loader made of it. That gap mattered because
lib/sync_gate.py is failing-open by design:
a dropped group, or a glob that matches no file, does not raise. It just stops
guarding, and prints one stderr line nobody reads.
An unenforced gate you still trust is worse than no gate, because you have stopped looking. So:
$ /cc-enforcer:sync-gate check
ok hooks-tests.when 'hooks/scripts/*.py' → 18 file(s)
!! code-docs.require 'nowhere/*.rst' → 0 file(s)
1 problem(s):
• group code-docs: require glob 'nowhere/*.rst' matches NO file in the repo.
check reports every group the loader kept, every group it dropped and why,
and every dead glob — exiting 1, so it runs in CI. init / add / remove / list / path round it out, mirroring the Imperial-Edict CLI that has had exactly this
treatment since v0.12.
Deliberately not auto-created. No hook writes into your project directory —
that invariant has held for every release, and an auto-created empty template
would be functionally identical to no file (zero groups means layer (i) stays
inert), bought at the price of an unrequested file in everyone's git status.
init creates it when you ask.
Writes are verified twice. Not just "does this parse" but "does the loader
still see every group" — a require = [] entry is valid TOML that
sync_gate.load() silently discards, so a parse-only check would let the CLI
report success over a group that guards nothing. On failure the previous file is
restored byte-for-byte.
A defect found in this feature's own first smoke test, reported here rather
than quietly fixed. The CLI picked its write target with config_path() — the
resolver built for reading, which tries several roots and takes the first that
already holds a file. Run against a project with no config yet, it fell through
to the process cwd and wrote two groups into this plugin's own config. Root
cause: "where do I read from" and "where do I write to" are different
questions, and a read-resolver's fallback chain is precisely what makes it the
wrong answer to the second. Fixed at the mechanism — default_project_path()
(deterministic) and load_file() (no fallback) — with the class swept: both
call sites, both pinned by regression tests, and the read resolver's fallback
explicitly pinned too, so "fixing" it later by making everything deterministic
cannot silently break the hook path.
Also: lib/tomlio.py now owns the TOML writer as well as the reader, so the
edict CLI and the sync-gate CLI share one encoder instead of the second copy
missing the next fix. Suite 565 → 590 tests.
Earlier releases: CHANGELOG.md.
The plugin enforces its own rules on its own development — expect to be denied
by it while working on it. Read CLAUDE.md §4 before opening a PR:
- Read every related file end-to-end before editing.
- Trace downstream impact — editing a rule means updating the prompt, the docs, the checklist and the translation in the same change.
- Cite
file:line; never "I think" / "should be". - Fix root causes. No
--no-verify, no swallowed errors.
MIT — see LICENSE.