Writ

Writ turns supported workflow requirements into executable checks and acceptance gates for Claude Code, instead of relying on the model to follow written instructions. It runs on your machine: hooks that Claude Code calls around each tool use, a local daemon that makes the decisions, and a Neo4j database in Docker that stores rules and decision records.
Watch demo · Quick start · Acceptance gates · Why was I blocked? · Documentation · Support Writ
Watch Writ in action
Looping excerpt above. Click it for the full captioned demo.
Watch the demo (1:21) · Download the MP4
A real Claude Code session building an offline security briefing command in A.S.E., edited for length with captions and no narration. See the plan and test approval gates refuse writes, specialist agents explore, plan, implement and review, and writ audit-session record the gate events.
The checks pass, then review finds additional edge cases. The recording shows workflow enforcement and verification, not a guarantee of bug-free code.
The problem, and the approach
A rule in CLAUDE.md, a skill or a system prompt is an instruction: the model has to find it, keep it through a long session, and decide it applies. When context compacts or fills, instructions fade, and nothing announces it.
Writ moves selected requirements out of the prompt and into code that runs when the agent acts. A source write before an approved plan is refused, not discouraged.
Context delivers the rules, methodology and past decisions that fit the current prompt, file and workflow phase. Enforcement checks each attempted action and blocks it, asks you, or reports a finding. Continuity records what you approved and why files changed, so the next session starts from it.
Writ constrains a cooperative agent; it is not a sandbox. See Evidence and limits. The longer argument is in docs/instructions-vs-enforcement.md.
Acceptance gates
Work mode puts two acceptance gates between a request and the first source write: an approved plan, then approved test skeletons. Each gate is code that runs when the agent tries to write and again when you approve. Only your own reply opens a gate.
The gates at a glance
| Gate | Requires | Checked when | Blocks | Satisfied by |
|---|
Mode [ENF-GATE-MODE] | A declared mode | Every write (Write, Edit, NotebookEdit, shell writes) | All writes except plan.md, capabilities.md and exempt paths | Auto-route on a clearly build- or research-shaped prompt, or writ mode set <mode> <session_id> (the refusal prints the command with your session id) |
Plan gate phase-a [ENF-GATE-PLAN] | plan.md (in .claude/plans/<session_id>/, else the project root) with ## Files, ## Analysis, ## Rules Applied (only rule ids Writ injected this session, or "No matching rules") and ## Capabilities (unchecked boxes) | Each write, and the validator when you reply | Every write except tests, migrations, __init__.py, .claude/ markdown, plan.md, capabilities.md and the OS temp directory | The agent asks "Say approved to proceed.", you reply approved, the validator passes; the phase moves from planning to testing |
Test-skeleton gate test-skeletons [ENF-GATE-TEST] | A test file with a test signature (def test_, it(, test(, @Test, ...), written this session or named in the plan's ## Files; or a manual-testing grant you typed (manual test approved) | Same | Same | Same; the phase moves from testing to implementation |
Drift [ENF-GATE-DRIFT] | plan.md unchanged since it was approved (ticking a capability box does not count) | Every write | Every write except the exempt paths (tests, migrations, plan.md, .claude/ markdown); scratch files in the OS temp directory are refused too | One fresh approved |
Plan freeze (implementation phase, tagged [ENF-GATE-PLAN]) | plan.md is not edited | Writes to plan.md | plan.md | Reply replan approved (returns to planning and clears both gates) |
Project boundary [ENF-PROJECT-BOUNDARY] | The path is inside the project root, or declared by absolute path in ## Files | Writes in Work mode | Writes outside the project (the OS temp directory is exempt) | Declare the path, then approve again (the edit re-arms the gates) |
Debug root cause [DEBUG-GATE-ROOT-CAUSE] (debug mode) | A populated ## Root cause in .claude/debug/<session_id>/debug.md | Source writes | Source edits | Write the root cause |
Credentials [SEC-CREDENTIAL-WRITE] (every mode, before every exemption) | Not a credential path (.env, *.key, *.pem, ~/.ssh/, ...) | Every write | Credential paths | Nothing the agent can do; you write those files yourself. Unlike other refusals, this one never turns into a confirmation prompt |
The test-skeleton gate checks that a test exists, not what it asserts. A separate check, ENF-PROC-TDD-001, refuses whole-file writes of source files under src/, lib/ or app/ until a conventional test file with assertion markers exists.
An example, start to finish
Illustrative session. Lines starting with [ and the fenced refusals are the exact strings Writ prints, copied from the source; the rest is abbreviated.
-
You ask: "Add a slugify() helper to src/textutil.py, with tests." Writ prints the line below, and the session starts in the planning phase.
[Writ: implementation request -> work mode set automatically]
-
The agent tries to write src/textutil.py. The write is refused:
[ENF-GATE-PLAN] ALL writes blocked -- plan not yet approved. DO NOT attempt more writes.
Present your plan to the user and say: "Say approved to proceed."
Wait for the user to say "approved" before attempting ANY file writes.
-
The agent writes .claude/plans/<session_id>/plan.md (allowed) and ends its turn with "Say approved to proceed."
-
You read the plan and reply approved. Writ prints the two lines below; the angle-bracket parts vary.
[Writ: planning gate approved -> testing] (advanced by the approval hook on your confirmation; no agent self-approval)
[Writ: validated <path to plan.md> (project root from <how it was found>)] (if that is not the plan you meant to approve, the project root is wrong: invalidate the gate before continuing)
-
The agent tries the source file again and is refused:
[ENF-GATE-TEST] ALL writes blocked -- test skeletons not yet approved. DO NOT attempt more writes.
Write test skeleton files first (test files ARE allowed), present them to the user, and say: "Say approved to proceed."
-
The agent writes tests/test_textutil.py containing def test_slugify_... and asks: "Test skeletons written: test_textutil (3 tests). Say approved to proceed." You reply approved, and Writ prints:
[Writ: testing gate approved -> implementation] (advanced by the approval hook on your confirmation; no agent self-approval)
-
The agent writes src/textutil.py; it is allowed. Credential paths and paths outside the project stay refused.
-
What is recorded: each refusal and approval is a row on the audit stream (gate_denial, phase_advance) in ~/.local/state/writ/logs/<project>/audit.jsonl, and writ audit-session <session_id> prints the timeline. Approving the plan snapshots it as a Decision; when you commit, the post-commit hook links the commit, each changed file, the reason the plan gave for it and the rules the agent was shown. writ recall reads it back, and the next session's first prompt carries it as a briefing.
When the plan changes after approval
Approvals are bound to the plan's fingerprint. Editing plan.md after approving it re-arms the gates; the next write is refused with:
[ENF-GATE-DRIFT] ALL writes blocked -- plan.md changed after it was approved, so the approval for phase-a no longer covers it. DO NOT attempt more writes.
Tell the user what changed and say: "Say approved to proceed."
One fresh approval re-binds the gates to the current plan. Ticking a capability box is not a change and never causes this.
Review the change; the agent asks again and one approved re-binds the gates.
During implementation plan.md itself is frozen. To change it, reply replan approved: the session returns to planning with both gates cleared, and source writes wait for both approvals again.
What counts as an approval: approved works only as your whole message (case and a trailing ., ! or , are ignored), and only when the agent's previous turn asked for it with say approved, reply approved, type approved or approved to proceed. Otherwise nothing advances and Writ prints:
[Writ: approval detected with no request in front of it, nothing was advanced]
If you meant it, reply approved anyway; that waives the request check and is recorded on the audit stream (approval_evidence_override). Approvals also end on writ mode set, and when a commit lands the last file the approved plan declared.
Why the agent cannot approve itself
Your reply is read by a hook on Claude Code's prompt-submit event, which mints a single-use token bound to the pending gate and the plan's fingerprint. Advancing a gate claims that token. The agent has no way to mint one, the token and session-state files are write-protected ([ENF-GATE-STATE]), and a tokenless attempt is refused and logged as agent_self_approval_blocked. /writ-approve sends the same token through a slash command; it cannot create one. The full contract is in docs/reference/session-and-gates.md.
Quick start
-
Check prerequisites: Linux or macOS, Claude Code, Python 3.11 or newer (python3 --version), and Docker with its daemon running (docker info). git is needed only for a clone install; jq and curl are optional. Neo4j wants about 1 GB of memory, and the install needs network access once, to download packages and the embedding model.
-
Install the plugin:
claude plugin marketplace add infinri/Writ
claude plugin install writ@writ
-
Open Claude Code in any project. Writ prints one command on its own line, bash <plugin root>/scripts/bootstrap-plugin.sh; run it in a terminal (add --preflight to only check prerequisites). It creates the venv, starts Neo4j, sets a private database password, loads the rules, starts the daemon, and patches ~/.claude/settings.json, ~/.claude/CLAUDE.md and the slash commands. It is idempotent; re-running it is the update.
-
When it prints Writ plugin is ready and ! Restart Claude Code for the hooks to take effect., restart Claude Code.
-
Check health, using the Plugin root line from the banner:
"<plugin root>/bin/writ" status
"<plugin root>/bin/writ" doctor