
AI coding agents forget everything the moment your session ends. Every new chat, every fresh terminal, every reset context window starts from zero — no memory of your linting rules, your folder conventions, or the fact that you told it three times yesterday not to use default exports.
Rules files and hooks are how you fix that. They're not a bolt-on "prompt quality" product — they're native, built-in mechanisms in Cursor, Windsurf, and Claude Code that inject persistent context before the model ever sees your prompt, and (in Claude Code's case) can deterministically gate what the agent is allowed to do.
This guide covers the real, current mechanism in each tool, with working code, plus how to keep them in sync so you're not maintaining three divergent config files by hand.
Why this matters
LLMs are stateless between completions. Without a rules mechanism, "use functional components," "no default exports," "TypeScript strict mode," and "never touch .env" all have to be retyped, every session, and the model still might not follow them consistently.
Rules files fix the first problem: they inject standing context automatically. Hooks (Claude Code only, as of this writing) fix the second problem: they give you deterministic enforcement that doesn't depend on the model remembering anything at all.
Keep that distinction in mind through the rest of this guide — rules shape what the agent knows; hooks control what the agent is allowed to do.
Cursor: Project Rules (.mdc)
The old single-file .cursorrules is deprecated. The current system is Project Rules — multiple .mdc (Markdown + Config) files living in .cursor/rules/, each with its own activation logic.
Rule types
Type | Location | Scope | Shared via |
|---|---|---|---|
Project Rules |
| Current repo | Git |
User Rules | Cursor Settings → Rules | All your projects | Not shared |
Team Rules | Cursor dashboard | All team members | Dashboard (paid plans) |
| Project root/subdirs | Current repo | Git |
The .mdc format
Each file is YAML frontmatter + Markdown body:
---
description: "TypeScript conventions for this project"
globs: "src/**/*.ts,src/**/*.tsx"
alwaysApply: false
---
# TypeScript Standards
- Use strict mode (`tsconfig` → `strict: true`)
- Prefer interfaces over type aliases for object shapes
- Use const objects instead of enums
- No default exports except for Next.js page components
- All function parameters must have explicit types
- Use early returns for error conditionsFrontmatter fields and the four activation modes
Field | Type | Effect |
|---|---|---|
| string | Summary the agent uses to decide relevance in "Agent Requested" mode |
| string | string[] | File patterns that auto-attach this rule when a matching file is open |
| boolean | If |
Combining these gives you four practical modes:
<!-- 1. Always — injected into every single conversation -->
---
description: "Core project context"
alwaysApply: true
---
<!-- 2. Auto-Attached — fires only when matching files are in context -->
---
description: "Django ORM and DRF patterns"
globs: "**/backend/**/*.py"
alwaysApply: false
---
<!-- 3. Agent Requested — model reads the description and decides -->
---
description: "How to write and run integration tests in this repo"
alwaysApply: false
---
<!-- 4. Manual — only injected when explicitly @-mentioned in chat -->
---
description: "One-off migration playbook, rarely needed"
alwaysApply: false
---A realistic rule set
.cursor/rules/
├── project-context.mdc # alwaysApply: true
├── typescript.mdc # globs: src/**/*.{ts,tsx}
├── testing.mdc # globs: **/*.test.ts
├── api-error-format.mdc # description-based, agent decides
└── db-migration-local.mdc # manual, gitignored, personal only.cursor/rules/testing.mdc:
---
description: "Testing conventions using Vitest"
globs: "**/*.test.ts,**/*.spec.ts"
alwaysApply: false
---
# Testing Standards
- Use Vitest, not Jest
- One `describe` block per exported function
- Mock external HTTP calls with `msw`, never with manual `fetch` stubs
- Every new API route needs at least one happy-path and one error-path testPractical rules
Keep each
.mdcfile under ~500 lines — a bloated file burns context budget on every request.Name files in kebab-case by concern (
error-responses.mdc, notrules2.mdc).Disable a rule without deleting it: set
alwaysApply: falseand strip theglobs/description.For personal, non-shared overrides, use a
*-local.mdcnaming convention and add.cursor/rules/*-local.mdcto.gitignore.AGENTS.mdis the simpler fallback for small projects that don't need glob-level scoping — and it doubles as a cross-tool format (see "Keeping rules in sync across tools" below).
Windsurf: Rules & Memories (Cascade)
Windsurf's agent, Cascade, splits persistent context into two separate systems that people often conflate:
Memories — auto-generated by Cascade during a session, or created on request ("remember this"). Workspace-scoped, free (no credit cost), not shared across projects.
Rules — explicitly authored by you, always active, applied globally or per-workspace.
Rule storage locations
Location | Scope |
|---|---|
| Every workspace |
| Current workspace |
| That subdirectory only |
| Current workspace, still supported |
Activation modes
Mode | Behavior |
|---|---|
Always On | Injected into every Cascade interaction |
Manual | Only applied when |
Model Decision | Cascade reads a natural-language description and decides relevance itself |
Glob | Applied when files matching a pattern (e.g. |
Example: global_rules.md
# Global Rules
- Server Components by default in Next.js; Client Components only when
the file needs interactivity, state, or browser-only APIs
- Never suggest `any` in TypeScript — use `unknown` and narrow it
- Prefer named exports over default exportsExample: workspace rule, glob-scoped
---
trigger: glob
globs: "*.py"
---
# Python Backend Rules
- Follow PEP 8; format with `ruff format`
- All public functions require type hints and a docstring
- Use `pydantic` models for request/response validation, not raw dictsXML-tag style (Windsurf's alternate, model-friendly format)
Windsurf documentation also supports wrapping rules in XML-style tags, which some teams find easier for the model to parse and cite back:
<coding_guidelines>
- My project's programming language is Python
- Use early returns when possible
- Always add docstrings when creating new functions and classes
</coding_guidelines>Practical rules
Each rule file has a hard character cap (in the low five figures — Windsurf truncates content beyond it), so split large standards into multiple focused files rather than one giant
global_rules.md.Add
.windsurfrules(the legacy single-file format) to.gitignoreif it holds personal preferences rather than team conventions.Don't put project-specific facts ("we migrated the auth service last sprint") in Rules — that's what auto-generated Memories are for. Rules should hold durable policy, not session trivia.
Review Cascade's auto-generated memories periodically via the Memories panel; stale ones actively mislead the agent.
Claude Code: CLAUDE.md + Hooks
Claude Code splits the same problem into two genuinely different tools, and this is the part most guides gloss over: CLAUDE.md is context, hooks are control. CLAUDE.md can be ignored by a distracted model. A hook cannot.
CLAUDE.md hierarchy
Claude Code walks up the directory tree and merges multiple CLAUDE.md files, broadest to most specific:
Level | Location | Scope |
|---|---|---|
Managed policy |
| Org-wide, enterprise-managed |
User |
| Every project, every session |
Project |
| This repo, committed to git |
Subdirectory |
| Loaded on demand when Claude works in that folder |
More specific files layer on top of (don't fully replace) broader ones — a frontend/CLAUDE.md and a backend/CLAUDE.md can carry completely different conventions in a monorepo without bloating the root file.
Example CLAUDE.md
# Project: Acme Billing Service
## Stack
- Node 20, TypeScript, Fastify, Postgres via Prisma
- Tests: Vitest. Run with `npm test`, not `npm run test:watch` in CI contexts.
## Conventions
- All monetary values are integer cents, never floats
- Every new endpoint needs a corresponding OpenAPI entry in `openapi.yaml`
- Do not add new npm dependencies without calling it out explicitly in your response
## Commands
- `npm run dev` — start local server on :3000
- `npm run db:migrate` — apply pending Prisma migrations
- `npm run lint:fix` — run before considering any task done
@docs/architecture.md
@~/.claude/personal-style.mdThe @import syntax
CLAUDE.md files can pull in other files with @path/to/file:
Supports relative paths, absolute paths, and home-directory paths (
@~/.claude/...)Imports can themselves import other files, up to 5 levels deep
@references are not evaluated inside code spans or fenced code blocks — so documenting the syntax (as above) doesn't accidentally trigger a real import
Use this to split a sprawling root file into composable pieces (@docs/architecture.md, @docs/testing-strategy.md) instead of one 2,000-line wall of text.
Hooks: deterministic quality gates
CLAUDE.md is a request, not a guarantee — the model can still skip a step under time pressure inside a long agentic run. Hooks are shell commands Claude Code fires automatically at fixed points in its execution loop, regardless of what the model "remembers."
Where hooks live:
~/.claude/settings.json # global, all projects
.claude/settings.json # project-level, committable
.claude/settings.local.json # project-level, gitignored
<managed policy path> # org-wide, admin-controlledKey lifecycle events:
Event | Fires | Can block the action? |
|---|---|---|
| Before a tool call executes | ✅ Yes (exit code |
| After a tool call succeeds | ❌ No — but can trigger follow-up (format, test, notify) |
| When you submit a prompt, before Claude processes it | ✅ Yes |
| When Claude finishes responding | — |
| New session begins | — |
| Before context gets compacted | — |
| Claude sends a notification | — |
Basic shape, in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}Every hook receives structured JSON on stdin describing the event (tool_name, tool_input, etc.), and controls what happens next via its exit code:
exit 0→ continue normallyexit 2(onPreToolUse) → block the tool call; stderr is shown to the model as the reasonNon-zero on
PostToolUse→ surfaced as feedback, but the action already happened
Example: block edits to .env and force-pushes
#!/bin/bash
# .claude/hooks/block-dangerous.sh
INPUT=$(cat)
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input | tojson')
if echo "$TOOL_INPUT" | jq -r '.file_path // empty' | grep -q '\.env'; then
echo "Blocked: .env files must be edited manually, not by the agent" >&2
exit 2
fi
if echo "$TOOL_INPUT" | jq -r '.command // empty' | grep -q 'push.*--force'; then
echo "Blocked: force push requires human review" >&2
exit 2
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/block-dangerous.sh" }
]
}
]
}
}Example: auto-format and auto-test after every edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}This is the real difference from a rules file: writing "always run Prettier after editing" in CLAUDE.md is a hope. A PostToolUse hook is a guarantee — it runs every time, independent of the model's attention.
Set an explicit timeout (in seconds) on any hook that shells out to something slow, like a full test suite — default timeouts are short and have changed across releases, so don't rely on the default for long-running commands.
Side-by-side comparison
Cursor | Windsurf | Claude Code | |
|---|---|---|---|
Context file(s) |
|
|
|
Legacy single-file format |
|
| — |
Granular scoping | Per-file, via | Per-file, via glob trigger | Per-directory, via nested files |
Model decides relevance? | Yes ( | Yes ("Model Decision" mode) | No — loaded by directory/hierarchy |
Auto-generated memory | No | Yes (Cascade Memories) | No |
Team-shared rules | Yes (paid dashboard) | Via committed | Via committed |
Can block an action outright | No | No | Yes — |
Auto-run commands after edits | No | No | Yes — |
Cross-file import | No native syntax | No native syntax |
|
The takeaway: Cursor and Windsurf give you rich, granular context injection. Claude Code gives you that too (via CLAUDE.md), plus a second, independent layer of enforcement that doesn't exist in the other two as of this writing.
One rule set, three formats — a worked example
Say you want one policy — "REST handlers must validate input with Zod, and every mutation needs a test" — enforced consistently. Here's the same rule expressed natively in each tool.
Cursor — .cursor/rules/api-validation.mdc:
---
description: "Input validation and test coverage for REST handlers"
globs: "src/routes/**/*.ts"
alwaysApply: false
---
# API Validation
- Every route handler must validate `req.body` with a Zod schema before use
- Every handler that mutates state needs a corresponding test in `*.test.ts`Windsurf — .windsurf/rules/api-validation.md:
---
trigger: glob
globs: "src/routes/**/*.ts"
---
# API Validation
- Every route handler must validate `req.body` with a Zod schema before use
- Every handler that mutates state needs a corresponding test in `*.test.ts`Claude Code — a section inside src/routes/CLAUDE.md:
## API Validation
- Every route handler must validate `req.body` with a Zod schema before use
- Every handler that mutates state needs a corresponding test in `*.test.ts`...and optionally backed by a hook that actually checks it, rather than trusting the model read the section:
#!/bin/bash
# .claude/hooks/check-zod-validation.sh — PostToolUse on route file edits
FILE=$(cat | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" == src/routes/* ]] && ! grep -q "z\.object\|zodSchema" "$FILE"; then
echo "Warning: $FILE has no visible Zod validation call" >&2
fi
exit 0Notice the content is identical — only the wrapper (frontmatter keys, file location) differs. That's exactly the problem the next section solves.
Keeping rules in sync across tools
Maintaining near-duplicate content in .cursor/rules/, .windsurf/rules/, and CLAUDE.md by hand drifts fast. A small but real open-source ecosystem exists specifically to solve this — write once, generate the rest.
Ruler (@intellectronica/ruler)
The most established of these. One .ruler/ directory is the source of truth; ruler apply distributes it to every supported agent's native format (Claude Code, Cursor, Windsurf, Copilot, Codex CLI, Cline, Aider, and others).
npm install -g @intellectronica/ruler
cd your-project
ruler initruler init scaffolds:
.ruler/
├── AGENTS.md # your canonical rules, in plain Markdown
├── ruler.toml # which agents to target, output paths
└── mcp.json # optional shared MCP server configSplit large rule sets into focused files — they're concatenated alphabetically:
.ruler/
├── AGENTS.md
├── coding_style.md
├── api_conventions.md
└── security_guidelines.mdApply to every configured agent, or target specific ones:
ruler apply # all configured agents
ruler apply --agents cursor,claude # only these two
ruler revert # undo, restores from .bak files
ruler revert --dry-run # preview the undo firstruler.toml controls which agents get generated and where — check ruler --help for the exact keys in the version you install, since the CLI evolves, but the shape is roughly:
default_agents = ["claude", "cursor", "windsurf", "copilot"]
[agents.cursor]
enabled = true
[agents.windsurf]
enabled = trueEach generated file gets a source marker for traceability, e.g.:
<!-- Source: .ruler/api_conventions.md -->...so if Cursor's rule looks wrong, you know exactly which canonical file to fix.
Lighter alternatives
If you don't want a full tool, a pre-commit hook that regenerates target files from one canonical AGENTS.md accomplishes the same thing with a few lines:
# .husky/pre-commit
npx agentsync sync # example: regenerates CLAUDE.md, .cursorrules, etc. from AGENTS.md
git add -AThe specific tool matters less than the principle: pick one canonical file, generate the rest, and never hand-edit a generated file.
Hooks as a real quality gate
Since Claude Code is the only one of the three with an enforcement layer today, it's worth showing a fuller pattern: a "quality gate" that runs lint, type-check, and tests after every batch of edits, and reports failures back to the model so it can self-correct — without you doing anything.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/quality-gate.sh",
"timeout": 120
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/quality-gate.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[[ "$FILE" != *.ts && "$FILE" != *.tsx ]] && exit 0
npx eslint "$FILE" --quiet 2>&1 | tee /tmp/lint-out.txt
if [ -s /tmp/lint-out.txt ]; then
echo "ESLint found issues in $FILE — fix before continuing:" >&2
cat /tmp/lint-out.txt >&2
exit 1 # PostToolUse can't block, but this surfaces feedback to the model
fi
npx tsc --noEmit -p . 2>&1 | tee /tmp/tsc-out.txt
if [ -s /tmp/tsc-out.txt ]; then
echo "Type errors present:" >&2
cat /tmp/tsc-out.txt >&2
exit 1
fi
exit 0Because this is a PostToolUse hook it can't undo the edit — but a non-zero exit surfaces the lint/type output directly to the model as feedback, so in an agentic loop it typically self-corrects on the next turn instead of you catching it in review three files later.
For anything that should be prevented outright rather than corrected after the fact (touching production config, running rm -rf, editing CI secrets), use PreToolUse with exit 2 instead — that's the only exit code across either event that actually stops the action.
Common mistakes
Treating
CLAUDE.mdas an enforcement mechanism. It's context. If something must never happen, it needs aPreToolUsehook, not a bullet point.One giant rules file. All three tools reward focused, single-topic files over a 2,000-line monolith — it wastes context budget and the model deprioritizes buried instructions.
Forgetting
alwaysApply/triggersemantics. A Cursor rule with noglobsandalwaysApply: falseand a vague description may simply never fire. Test that your rule actually attaches.Committing personal preferences. "I prefer terse commit messages" belongs in Cursor's User Rules or a gitignored local file — not in a team's shared
.cursor/rules/.No
timeouton slow hooks. A hook that shells out to a full test suite without an explicit timeout can stall the agent loop.Hand-editing generated files. If you adopt Ruler or a similar sync tool, edit the canonical source only — hand edits get silently overwritten on the next
apply.
Cheat sheet
Cursor
.cursor/rules/*.mdc → frontmatter: description, globs, alwaysApply
.cursor/rules/*-local.mdc → gitignore for personal overrides
Windsurf
global_rules.md → all workspaces
.windsurf/rules/*.md → this workspace (or subdirectory)
.windsurfrules → legacy single-file, still works
Claude Code
~/.claude/CLAUDE.md → user, all projects
./CLAUDE.md → project root
./**/CLAUDE.md → subdirectory, loaded on demand
@path/to/file.md → import (max depth 5)
~/.claude/settings.json → global hooks
.claude/settings.json → project hooks (commit this)
.claude/settings.local.json → project hooks (gitignore this)
Sync tool
npm install -g @intellectronica/ruler
ruler init && ruler applyFAQ
Is .cursorrules still supported?
Yes, but it's deprecated — Cursor still reads it, but new projects should use .cursor/rules/*.mdc for glob scoping and per-rule activation modes.
Does Windsurf have anything like Claude Code's hooks?
Not as of this writing. Windsurf's Rules and Memories shape context; they don't gate or block tool calls the way a Claude Code PreToolUse hook can.
Can a Cursor or Windsurf rule stop the agent from running a command?
No — both are context-injection systems. Neither has a deterministic blocking mechanism equivalent to Claude Code's PreToolUse + exit 2.
What's the difference between Windsurf Memories and Rules? Memories are auto-generated (or on-request) facts scoped to a workspace, free to create, meant for session-specific context. Rules are explicitly authored, always-active policy. Don't put durable team conventions in Memories — they're not guaranteed to persist the way a committed rules file is.
Do I need a separate tool to keep these in sync? Not strictly — you can hand-maintain three files for a small project. Past a handful of rules, drift becomes real; a sync tool like Ruler (or a simple pre-commit script) pays for itself quickly.
Where do MCP server configs fit into this?
Outside the scope of this guide, but the same tools generally handle it: Cursor uses .cursor/mcp.json, Claude Code uses .mcp.json, and Ruler can distribute a shared mcp.json alongside your rules.
Comments (0)
Login to post a comment.