Modal execution for Claude Code - enforce workflows like TDD by limiting actions based on current mode.
Claude Code is great at completing tasks, but it often takes shortcuts. Ask it to do TDD and it might:
- Jump straight to implementation without writing a test
- Modify your test file while "fixing" a bug
- Skip the red-green-refactor cycle entirely
You can remind Claude to follow the process, but those instructions get lost during context compaction. There's no enforcement - just hope.
Define your own workflow as a state machine. You create modes with:
- Permissions - what files/tools Claude can use in each mode
- Transition constraints - conditions for moving between modes
You decide what works for you. Want strict TDD? Lock down source files until tests fail. Prefer a lighter touch? Skip permissions and just use transitions as guideposts.
Example: a TDD workflow
idle ──────────► test-dev ──────────► feature-dev ──────────► idle
"describe a "test exists "all tests
bug/feature" and fails" pass"
This config restricts test-dev to test files only, feature-dev to source files only. But that's just one approach - your modes.yaml defines whatever workflow fits your process.
Why this works:
- Mode state survives context compaction (injected every prompt)
- Permissions enforced by hooks, not just instructions
- Constraints visible to Claude, guiding rather than just blocking
- Fully customizable to match how you actually work
/plugin marketplace add kerinin/claude-modes
/plugin install modes@claude-modes
Then run /modes:setup to configure permissions. This adds the modes MCP tools to your allow list so Claude can check and update mode state without prompting you each time.
Copy the example TDD workflow to your project:
cp -r ~/.claude/plugins/modes/examples/tdd/* .claude/This gives you a working TDD workflow out of the box. Read on to understand what each file does and how to customize it.
Modes config lives in your project's .claude/ directory:
This is your state machine. It defines what modes exist and when Claude can move between them.
name: tdd-workflow
default: idle
modes:
idle:
transitions:
- to: test-dev
constraint: User has described a bug or feature to work on
test-dev:
transitions:
- to: feature-dev
constraint: |
A test exists that targets the bug/feature.
The test has been executed and is currently failing.
feature-dev:
transitions:
- to: idle
constraint: All tests are passing.The constraint is shown to Claude and guides when it should transition. Claude evaluates whether the constraint is satisfied and calls the transition tool when ready.
These work just like your project's root CLAUDE.md, but only get loaded when Claude is in that mode. Use them to give Claude mode-specific guidance.
<!-- .claude/CLAUDE.test-dev.md -->
You are writing a failing test. Focus on:
1. Understanding the expected behavior
2. Writing a test that verifies that behavior
3. Running the test to confirm it fails
Do NOT modify implementation code in this mode.Your root CLAUDE.md is always loaded. Mode instructions are additive - they don't replace your base instructions, they supplement them for that phase of work.
These constrain what Claude can do while in a specific mode. The allow and deny lists use glob patterns to control file and tool access.
{
"permissions": {
"allow": [
"Read(**)",
"Write({test/**,**/*.test.ts})",
"Edit({test/**,**/*.test.ts})",
"Bash(npm test*)"
],
"deny": [
"Write(src/**)",
"Edit(src/**)"
]
}
}In this example, while in test-dev mode Claude can read anything, write/edit test files, and run tests - but it cannot touch source files. The hook blocks the action before it happens.
Permissions are optional. If you just want transitions as guideposts without hard enforcement, skip the settings files entirely.
Once configured, modes work automatically. Here's what a TDD session might look like:
You: Fix the bug where users can't log in with email addresses containing a plus sign
Claude: I'll help fix that login bug. Let me first write a test to verify the issue.
[MODE: idle → test-dev]
Creating test for plus sign in email...
[Creates test/auth.test.ts]
Running the test to confirm it fails...
[Runs: npm test]
Test fails as expected. Now I'll implement the fix.
[MODE: test-dev → feature-dev]
[Edits src/auth.ts to fix email parsing]
Running tests to verify the fix...
[Runs: npm test]
All tests pass.
[MODE: feature-dev → idle]
You: Great, thanks!
What's happening behind the scenes:
-
Context injection - Every prompt, Claude sees the current mode, its instructions, and available transitions. This is why Claude knows to start with a test.
-
Permission enforcement - In
test-dev, if Claude tried to editsrc/auth.ts, the hook would block it. Claude literally can't skip ahead. -
Transition gating - Claude can't move to
feature-devuntil it's written and run a failing test. The constraint is visible, so Claude knows what it needs to do. -
State persistence - Even if the conversation compacts and loses earlier messages, the mode state persists. Claude stays in
feature-devuntil tests pass.
Sometimes you need to override the workflow. The slash commands let you intervene:
/modes:mode- Check current mode and available transitions/modes:mode <name>- Force transition to a specific mode (requires approval)/modes:mode reset- Return to the default mode
Force transitions require explicit approval because they bypass the constraint system. This is intentional - the workflow should guide normal operation, with manual overrides as the exception.
You can display the current mode in Claude Code's status line - similar to how terminal prompts show the git branch. This gives you constant visibility into which mode is active.
When configured, you'll see something like ⟪test-dev⟫ at the bottom of Claude Code.
To set this up, run /modes:setup and opt in when asked about the status line. Or manually:
- Copy the script:
cp ~/.claude/plugins/modes/examples/statusline/modes-statusline.sh ~/.claude/
chmod +x ~/.claude/modes-statusline.sh- Add to
~/.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "~/.claude/modes-statusline.sh"
}
}The status line updates automatically when you transition between modes.
The examples/ directory includes a TDD workflow. Here are other workflows where modes shine:
The problem: Claude jumps straight to implementation, or modifies tests while "fixing" bugs.
Why modes help: Separate test-dev and feature-dev phases with file permissions. Claude literally cannot edit source files until a failing test exists, and cannot touch tests while implementing. The red-green-refactor cycle becomes the only path forward.
The problem: Claude starts coding before design decisions are documented and approved.
Why modes help: A design mode that only allows editing docs/design files. The transition constraint requires design approval before moving to implementation. Claude can explore and prototype in design mode, but can't ship code until the approach is locked in.
The problem: Claude attempts fixes without first reproducing and diagnosing the issue.
Why modes help: A reproduce → diagnose → fix workflow. In reproduce mode, Claude can only read code and run tests - no edits allowed. It must demonstrate the bug exists before moving on. In diagnose mode, it documents the root cause before fix mode unlocks editing.
The problem: Claude makes changes without ensuring tests pass before and after.
Why modes help: A verify-green → refactor → verify-green cycle. The first phase confirms tests pass (constraint: "all tests passing"). Refactoring mode allows edits but the transition back requires tests to pass again. No refactor can leave tests broken.
The problem: Claude modifies auth, crypto, or other sensitive code without proper review.
Why modes help: Permissions can restrict which files are editable in each mode. A security-review mode might allow reading sensitive files but require explicit user approval (via forced transition) before entering a mode that can edit them.
Your project's base settings.json permissions are always enforced - mode permissions can only add restrictions, not remove them. If your base settings deny Write(src/**), no mode can override that.
This means your base permissions should generally be permissive. Let modes handle the restrictions for specific workflow phases.
You don't need permissions to get value from modes. Start with just modes.yaml - the transition constraints alone help Claude follow your workflow. Add settings.<mode>.json files later if you find Claude needs harder guardrails.
Constraints are shown to Claude to help it decide when to transition. Write them as clear conditions Claude can evaluate:
# Good - Claude can check this
constraint: A failing test exists that covers the bug
# Less good - vague, hard to evaluate
constraint: Ready to implementCLAUDE.<mode>.md files work best when they focus Claude's attention on the current phase. Don't repeat everything from your root CLAUDE.md - mode instructions are additive.
<!-- Good - focused on this phase -->
You are writing a failing test. Focus on the expected behavior.
Do NOT modify implementation code yet.
<!-- Less good - restating general practices -->
You are writing a failing test. Follow our coding standards.
Use TypeScript. Write clean code. Add comments...Don't copy a workflow verbatim - adapt it to your actual process. If you sometimes skip writing tests for trivial changes, maybe your constraint should be "User has described a bug or feature that warrants a test" rather than requiring tests for everything.