Document editing governance for Claude Code — enforces structured documentation workflows through a dual-layer gate system plus standalone link graph tooling.
When Claude attempts to edit a .md file, the plugin intercepts the operation at two layers: a hard gate (skill-gate) ensures the doc-maintenance workflow was loaded, and a soft gate (recall-gate) surfaces related documents, orphan signals, and broken outlinks before the edit proceeds. Both gates are fail-open — any anomaly silently allows the edit rather than blocking work.
# From marketplace
claude plugin add doc-gate@WooDragon-cc-pluginsPreToolUse:Edit/Write
│
├─ [1] skill-gate.sh (5s timeout)
│ Hard gate — denies unless doc-maintenance skill was invoked this session.
│ Global ~/.claude/CLAUDE.md gets highest-strength messaging.
│
├─ [2] recall-gate.sh (10s timeout)
│ Soft gate — on first edit of each .md file, invokes recall-gate.py
│ for BM25 recall + link graph analysis. Deny-once-per-file (retry passes).
│ Contains double-deny guard: skips if skill-gate already denied.
│
└─ recall-gate.py
Python engine (zero dependencies). Performs:
- BM25 lexical recall against all .md files in the repo
- Orphan detection (no inbound links)
- Broken outlink verification
PreToolUse:Skill
│
└─ skill-marker.sh (5s timeout)
Records session-scoped marker when doc-maintenance is invoked.
Never blocks. skill-gate.sh checks this marker.
Both recall-gate.py and docs-graph.py auto-detect the project root via detect_root():
RECALL_GATE_ROOTenv var — explicit override (recall-gate only)- Walk-up for
CLAUDE.md— from the edited file upward, with co-occurrence constraint:CLAUDE.mdfound, no.gitseen below → accept (non-git project root)CLAUDE.md+.gitin the same directory → accept (monorepo root)CLAUDE.mdwithout.git, but.gitseen below → skip (workspace marker, not a project root)
- Innermost
.git— first.gitrecorded during walk-up, used as safe fallback cwd()— final fallback
This supports monorepo with independent sub-repos: place CLAUDE.md at the monorepo root alongside .git to enable cross-repo recall. Without CLAUDE.md, behavior degrades to the innermost git repo (safe, backward-compatible).
Gates communicate through session-scoped marker files in $SKILL_GATE_DIR (default /tmp/claude-reviews):
| Marker | Created by | Checked by | Purpose |
|---|---|---|---|
.skill-gate-{session}-doc-maintenance |
skill-marker.sh | skill-gate.sh | Proves doc-maintenance skill was invoked |
.recall-gate-{session}-{file_hash} |
recall-gate.sh | recall-gate.sh | Per-file deny-once (retry passes silently) |
Markers auto-expire after SKILL_GATE_STALE_MIN / RECALL_GATE_STALE_MIN minutes (default 120). This doubles as a context refresh mechanism — after 2 hours, the gates re-fire to surface fresh analysis.
Both gates skip files that shouldn't be governed:
| Category | Examples |
|---|---|
| Basename | MEMORY.md, SKILL.md, CHANGELOG.md, LICENSE.md (case-insensitive) |
| Path | .claude/*, .claude-plugin/*, .agents/*, node_modules/*, .git/*, logs/*, pipeline/*, intake/*, deliverables/* |
| Temp dirs | /tmp/*, /var/tmp/*, /var/folders/*, /private/tmp/* |
| Non-.md | Any file not ending in .md (case-insensitive) |
pipeline/* covers deep-research's machine-generated intermediate artifacts (e.g. pipeline/verification/*.json-adjacent notes) — the doc-maintenance workflow doesn't apply to them. intake/* covers deep-research's G0 requirement-gate products (e.g. intake/requirements/research-goal.md), which the Lead generates semi-automatically before doc-maintenance is relevant.
.agents/* covers the whole team-ops runtime workspace, not just .agents/directives/* as an earlier version scoped it (#176). directives/, intel/, handoffs/, and tasks/ are all protocol intermediate artifacts consumed by protocol machinery, not human readers, and downstream .gitignore setups already ignore the directory as a whole. The narrower scoping used to deny non-directives roles (e.g. intel) that have no Skill tool and thus no way to self-invoke doc-maintenance to unlock — and because teammates share session_id with the main session while the marker is keyed by session_id, the failure showed up intermittently rather than consistently.
As of v1.6.0, */deliverables/* is also excluded from the gate. It used to stay governed, but that conflicted structurally with ADR-010 (main-session cost remediation): deliverables writes always go through a subagent, and the gate marker is keyed by session_id — since each subagent gets its own session id, no legitimate pass-through path exists, and the gate measured a 100% bypass rate on deliverables edits in practice. deliverables content is governed by its own quality system instead (G1–G3 sufficiency gates + Stage 6 validation review), so excluding it removes pure friction without losing coverage.
All path exclusions match on path components at any nesting depth, not just the project root — */deliverables/* fires equally on deliverables/final/report.md and on projects/x/deliverables/final/report.md.
Both gates share this exclusion list from a single source, scripts/_doc_gate_exclude.sh — add new gate exclusions there, not per-script. This list is allowed to diverge from the EXCLUDED_DIRS list in tools/_doc_gate_common.py (used for the recall-gate's link-graph corpus): the gate governs which edits require the doc-maintenance workflow, while the corpus governs which files are indexed for BM25/link-graph analysis — different concerns, different audiences. pipeline/* and intake/* are excluded from both (machine-generated, not real content to recall against). deliverables/* is excluded from the gate only — the corpus still indexes it, since deliverables remain real documentation worth surfacing in recall/orphan/broken-link checks even though editing them bypasses the workflow gate.
Exception: ~/.claude/CLAUDE.md is always gated despite living under .claude/ — it's the global config with the highest pollution surface.
When recall-gate fires, it performs three checks in a single pass:
Tokenizes the content being written (English words + Chinese bigrams), scores against all .md files in the repo using BM25 + filename Jaccard similarity. Surfaces the top-N most similar documents to prevent content duplication.
Checks whether the file being edited has zero inbound links from other documents. Flags files that may need to be added to an index or table of contents.
Parses Markdown links in the content being written and verifies that each target file exists. Reports broken references before they're committed.
The deny message combines all three signals into a structured advisory. On retry, the per-file marker allows the edit through silently.
Standalone CLI for querying the document link graph. Zero dependencies, no persistent state.
# Run from project root (auto-detects root via detect_root)
python3 plugins/doc-gate/tools/docs-graph.py check # Broken link detection
python3 plugins/doc-gate/tools/docs-graph.py backlinks FILE # Who links to FILE
python3 plugins/doc-gate/tools/docs-graph.py links FILE # What FILE links to
python3 plugins/doc-gate/tools/docs-graph.py orphans # Zero-inlink documents
python3 plugins/doc-gate/tools/docs-graph.py hubs [-n 10] # Top-N most linked documents
python3 plugins/doc-gate/tools/docs-graph.py related FILE # 2-hop neighborhood
python3 plugins/doc-gate/tools/docs-graph.py export [--out F] # node_link JSON graph
# Explicit root
python3 docs-graph.py --root /path/to/repo check
# JSON output
python3 docs-graph.py --json orphansExit codes: 0 = ok, 1 = broken links found (check only), 2 = argument/tool error.
| Variable | Default | Description |
|---|---|---|
SKILL_GATE_DISABLED |
0 |
1 disables the hard gate (skill-gate) |
SKILL_GATE_DIR |
/tmp/claude-reviews |
Marker directory (shared with plan-review) |
SKILL_GATE_LOG_DIR |
(empty) | Log directory; falls back to REVIEW_LOG_DIR, then ~/.claude/logs |
SKILL_GATE_STALE_MIN |
120 |
Marker expiry in minutes |
RECALL_GATE_DISABLED |
0 |
1 disables the soft gate (recall-gate) |
RECALL_GATE_ROOT |
(auto) | Explicit repo root override |
RECALL_GATE_THRESHOLD |
0.30 |
Minimum BM25 score for recall results |
RECALL_GATE_TOP_N |
5 |
Maximum recall results shown |
RECALL_GATE_STALE_MIN |
120 |
Recall marker expiry in minutes |
The plugin bundles one skill (skills/doc-maintenance/SKILL.md) that provides structured documentation workflows:
- Pre-flight: layer validation, naming checks, duplicate detection
- Execute: guided by operation-specific checklists (CREATE / MODIFY / RENAME / ARCHIVE / RESTRUCTURE)
- Post-flight: index sync, broken link check, stale file cleanup
The skill also enforces graduated CLAUDE.md governance — global ~/.claude/CLAUDE.md requires all four generalization criteria to pass; project-level CLAUDE.md uses them as guidelines.
As of v1.7.0, the skill also carries a writing-standards reference (references/writing-standards.md) — an ambiguity layer (§A, hard constraints) plus a typography layer (§B, mechanical rules deferred to tooling once available). The CREATE/MODIFY checklists in §5.1/§5.2 point straight at that file. Since v1.7.1, skill-gate.sh's deny message carries an unconditional imperative plus the absolute path to it and no item digest — a readable digest let an agent mistake the summary for the rules and skip the reference entirely (#139).
# In-repo hook tests (skill-gate + skill-marker)
bats plugins/doc-gate/tests/
# Full suite including recall-gate (private path)
bats tests/recall-gate.bats
python3 -m unittest tests/test_recall_gate.py -v| Suite | Tests | Coverage |
|---|---|---|
skill-gate.bats |
64 | Filters, exclusions, gate enforcement, fail-open, CLAUDE.md governance, stale cleanup, e2e |
skill-marker.bats |
13 | Tracked/untracked skills, marker creation, namespacing, kill switch |
recall-gate.bats |
37 | Filters, kill switch, fail-open, markers, double-deny, BM25 integration, orphan, broken outlinks, monorepo root detection, e2e |
exclude.bats |
19 | Shared _doc_gate_exclude.sh predicate — pipeline/intake/logs/tmp/git/node_modules/deliverables/.agents/* (incl. relative & nested paths) excluded, docs/CLAUDE.md/no-dot-prefix paths governed |
test_recall_gate.py |
67 | Tokenization, BM25 scoring, link extraction/resolution, corpus building, orphan/broken checks, cmd_gate output, detect_root |
test_exclude.py |
1 | build_corpus_and_graph excludes pipeline/, includes deliverables/ and docs/ |
See GitHub Issues for detailed change logs:
- v1.7.2 —
.agents/*exclusion widened from.agents/directives/*— the narrower scope denied other team-ops runtime artifacts (intel/,handoffs/,tasks/), and non-Skill-equipped roles likeintelhad no way to self-unlock (#176) - v1.7.1 — Removed the writing-standards item digest from both delivery points (SKILL.md §2 table and the
skill-gate.shdeny message): a readable digest created false satiety, so the model skippedreferences/writing-standards.mdand self-checked against rules it never read. Both now carry an unconditional imperative plus the authoritative path only; §5.1/§5.2 checklist pointers go straight to the reference instead of hopping through §2 (#139) - v1.7.0 — Writing-standards reference added (
references/writing-standards.md): an ambiguity layer (§A, STE-inspired hard constraints) and a typography layer (§B, mechanical/tool-deferrable); SKILL.md §5.1/§5.2 checklists gained corresponding checks;skill-gate.shdeny messages now inject a condensed summary with an absolute path to the full reference (#136) - v1.6.0 —
*/deliverables/*excluded from the gate (ADR-010 conflict: no legitimate pass-through path under subagent-scoped markers, 100% observed bypass); gate exclusion list intentionally diverges from the recall-gate corpus'sEXCLUDED_DIRS, which still indexes deliverables (#124) - v1.5.0 — Path exclusions collapsed to single source (
_doc_gate_exclude.sh); addedpipeline/*exclusion for deep-research intermediate artifacts,deliverables/*remains governed - v1.2.1 — Root detection: CLAUDE.md co-occurrence anchor for monorepo support (#22)
- v1.2.0 — Recall gate: BM25 lexical recall + link graph triple-dimension gate
- v1.0.4 — Deny message discourages env-var bypass
- v1.0.3 — CLAUDE.md governance + global tiering + team-ops exclusion