DEV Community

Cover image for Prompt & Rules Quality for Cursor, Windsurf, and Claude Code: The Complete Developer Guide
Pushpum Vats
Pushpum Vats

Posted on Originally published at zyvop.com

Prompt & Rules Quality for Cursor, Windsurf, and Claude Code: The Complete Developer Guide

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 .cursor/rules/*.mdc Current repo Git
User Rules Cursor Settings → Rules All your projects Not shared
Team Rules Cursor dashboard All team members Dashboard (paid plans)
AGENTS.md 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 conditions
Enter fullscreen mode Exit fullscreen mode

Frontmatter fields and the four activation modes

Field Type Effect
description string Summary the agent uses to decide relevance in "Agent Requested" mode
globs string | string[] File patterns that auto-attach this rule when a matching file is open
alwaysApply boolean If true, injected into every chat regardless of context

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
---
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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 test
Enter fullscreen mode Exit fullscreen mode

Practical rules

  • Keep each .mdc file under ~500 lines — a bloated file burns context budget on every request.

  • Name files in kebab-case by concern (error-responses.mdc, not rules2.mdc).

  • Disable a rule without deleting it: set alwaysApply: false and strip the globs/description.

  • For personal, non-shared overrides, use a *-local.mdc naming convention and add .cursor/rules/*-local.mdc to .gitignore.

  • AGENTS.md is 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
~/.codeium/windsurf/global_rules.md (or via Settings) Every workspace
.windsurf/rules/*.md in the project root Current workspace
.windsurf/rules/ in a subdirectory That subdirectory only
.windsurfrules (legacy single file) Current workspace, still supported

Activation modes

Mode Behavior
Always On Injected into every Cascade interaction
Manual Only applied when @mentioned in the Cascade input
Model Decision Cascade reads a natural-language description and decides relevance itself
Glob Applied when files matching a pattern (e.g. src/*/.ts) are in context

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 exports
Enter fullscreen mode Exit fullscreen mode

Example: 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 dicts
Enter fullscreen mode Exit fullscreen mode

XML-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>
Enter fullscreen mode Exit fullscreen mode

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 .gitignore if 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 /Library/Application Support/ClaudeCode/CLAUDE.md (macOS) / /etc/claude-code/CLAUDE.md (Linux) Org-wide, enterprise-managed
User ~/.claude/CLAUDE.md Every project, every session
Project CLAUDE.md at repo root This repo, committed to git
Subdirectory <dir>/CLAUDE.md 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.md
Enter fullscreen mode Exit fullscreen mode

The @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-controlled
Enter fullscreen mode Exit fullscreen mode

Key lifecycle events:

Event Fires Can block the action?
PreToolUse Before a tool call executes ✅ Yes (exit code 2)
PostToolUse After a tool call succeeds ❌ No — but can trigger follow-up (format, test, notify)
UserPromptSubmit When you submit a prompt, before Claude processes it ✅ Yes
Stop When Claude finishes responding
SessionStart New session begins
PreCompact Before context gets compacted
Notification 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"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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 normally

  • exit 2 (on PreToolUse) → block the tool call; stderr is shown to the model as the reason

  • Non-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
Enter fullscreen mode Exit fullscreen mode
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Edit|Write",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/block-dangerous.sh" }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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) .cursor/rules/*.mdc .windsurf/rules/*.md, global_rules.md CLAUDE.md (hierarchical)
Legacy single-file format .cursorrules (deprecated) .windsurfrules (still supported)
Granular scoping Per-file, via globs Per-file, via glob trigger Per-directory, via nested files
Model decides relevance? Yes (description, no alwaysApply) 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 .windsurf/rules/ Via committed CLAUDE.md
Can block an action outright No No Yes — PreToolUse hooks
Auto-run commands after edits No No Yes — PostToolUse hooks
Cross-file import No native syntax No native syntax @path imports, depth 5

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`
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

...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 0
Enter fullscreen mode Exit fullscreen mode

Notice 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 init
Enter fullscreen mode Exit fullscreen mode

ruler 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 config
Enter fullscreen mode Exit fullscreen mode

Split large rule sets into focused files — they're concatenated alphabetically:

.ruler/
├── AGENTS.md
├── coding_style.md
├── api_conventions.md
└── security_guidelines.md
Enter fullscreen mode Exit fullscreen mode

Apply 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 first
Enter fullscreen mode Exit fullscreen mode

ruler.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 = true
Enter fullscreen mode Exit fullscreen mode

Each generated file gets a source marker for traceability, e.g.:

<!-- Source: .ruler/api_conventions.md -->
Enter fullscreen mode Exit fullscreen mode

...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 -A
Enter fullscreen mode Exit fullscreen mode

The 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
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
#!/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 0
Enter fullscreen mode Exit fullscreen mode

Because 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.md as an enforcement mechanism. It's context. If something must never happen, it needs a PreToolUse hook, 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/trigger semantics. A Cursor rule with no globs and alwaysApply: false and 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 timeout on 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 apply
Enter fullscreen mode Exit fullscreen mode

FAQ

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.


Originally published on ZyVOP

💡 For more articles like this, subscribe to the ZyVOP newsletter!

Top comments (0)