C2 — Build a Committed .claude/ Steering Kit

Summary — what this page covers In C1 you wrote a CLAUDE.md that loads on every turn. Now you'll split those conventions into a committed .claude/ steering kit — path-scoped rules, a skill, a subagent, a guardrail hook, settings, and a plugin — and trim CLAUDE.md down to a map. To prove the skill works, you'll ship the first reviews endpoint it produces and make it permanent.

Time: ~45 minutes · Format: hands-on, with Claude Code · You start from: checkpoint/c1-claude-md · You end at: checkpoint/c2-steering-kit

C1 put everything in one always-on file. That works, but every convention costs context on every turn, and a CLAUDE.md "never do X" line is only a suggestion — Claude can still do X. In C2 you make steering scoped (rules that load only when relevant), reusable (a skill that triggers by description, a subagent that runs on a cheaper model), and enforced (a hook that blocks destructive commands). Claude Code itself helps you build the kit — and then uses it.


1. Prerequisites

  • Day 1 access: the Claude Code CLI and a paid Claude plan.
  • jq installed — the destructive-command hook parses its JSON input with it (jq --version). Preinstalled on most macOS/WSL dev setups; otherwise brew install jq.

2. Start from the C1 checkpoint

Each lab starts from the previous checkpoint, and the matching tag is the answer key. C2 starts from checkpoint/c1-claude-md; checkpoint/c2-steering-kit holds the full as-built files if you get stuck.

# from the repo root, branch off the C1 checkpoint
git switch -c my-c2 checkpoint/c1-claude-md

# everything you run happens inside the solution folder
cd src/BookTracker

# sanity check
dotnet build BookTracker.sln

Peek at the answer key any time without leaving your branch: git show checkpoint/c2-steering-kit:src/BookTracker/.claude/settings.json


3. Add path-scoped rules

Rules hold the conventions that lived inline in your C1 CLAUDE.md. A paths: glob in the frontmatter makes each one path-scoped — Claude loads it only when it reads a file that matches, so the conventions cost context only when they're relevant.

Ask Claude Code to create them, or write them by hand. Create .claude/rules/api-endpoints.md:

---
paths:
  - "BookTracker.Api/Endpoints/**/*.cs"
---
# API endpoint conventions

- Keep endpoints thin — business logic lives in Core services, not the handler.
- Accept and return DTOs (in `BookTracker.Core/Dtos`); never return EF entities.
- Validate inputs before they reach the data layer.
- Async all the way; thread the `CancellationToken` through.

And .claude/rules/data-access.md:

---
paths:
  - "BookTracker.Data/**/*.cs"
---
# Data-access conventions

- Parameterized queries / EF LINQ only — never build SQL by string concatenation.
- Use the async EF APIs; thread the `CancellationToken` through.
- Schema changes go through EF Core migrations in `BookTracker.Data`.

Gotcha — rules load on read, not create. A path-scoped rule fires when Claude opens a matching file, not when it's about to create one from scratch. Keep creation-time guidance unscoped (in CLAUDE.md or the skill). Also keep these project-level — user-level ~/.claude/rules/ globs are currently unreliable.


4. Add the add-api-endpoint skill (a folder, not a file)

A skill is a folder, not a single file: a SKILL.md whose description is the trigger, plus supporting references/. Claude auto-applies it when a request matches the description — you don't invoke it by name.

Create .claude/skills/add-api-endpoint/SKILL.md:

---
name: add-api-endpoint
description: Use when adding a new HTTP endpoint to BookTracker.Api — creating the route,
  handler, request/response DTOs, and wiring it into the Minimal API. Triggers on requests like
  "add an endpoint", "expose a new route", "create a GET/POST for ...".
---
# Add an API endpoint

## Procedure
1. Read `references/endpoint-pattern.md` and an existing endpoint to match the house style.
2. Define request/response DTOs in `BookTracker.Core/Dtos`.
3. Add the handler and map the route in `BookTracker.Api/Endpoints`.
4. Build, then add a test mirroring `BookTracker.Tests`.

## Gotchas
- Never return an EF entity — map to a DTO.
- Validate inputs before touching the data layer.
- Keep the handler thin; put logic in a Core service.

Then add the reference doc .claude/skills/add-api-endpoint/references/endpoint-pattern.md: a short markdown page describing the canonical BookTracker endpoint shape — a MapGroup per resource, handler → Core service, returns a *Dto, takes a CancellationToken, validates input, returns Results.Ok/NotFound/Created — and pointing at an existing clean endpoint (GET /books) as the live example.


5. Add the test-writer subagent

A subagent is a .md file: YAML frontmatter plus a body that becomes its system prompt. Pinning model: claude-haiku-4-5 is the cost lever — test generation runs on the cheaper model. (You'll use this subagent for real in C4.)

Create .claude/agents/test-writer.md:

---
name: test-writer
description: Generate xUnit tests for a class, matching the patterns in BookTracker.Tests.
model: claude-haiku-4-5
---
You write focused xUnit tests. Read the target class and the existing tests first,
mirror the house style, and cover the happy path plus the important edge cases.
Return only the test code and a one-line summary.

6. Add the guardrail hook (this is the enforcement)

A CLAUDE.md line can ask Claude not to run something; a hook can stop it. A PreToolUse hook receives the tool call as JSON on stdin, and exit 2 blocks the call (its stderr is shown to Claude). Create .claude/hooks/block-destructive.sh:

#!/usr/bin/env bash
# PreToolUse (Bash) guardrail: block destructive commands.
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')

if echo "$cmd" | grep -Eiq 'rm[[:space:]]+-rf|DROP[[:space:]]+TABLE|git[[:space:]]+push.*--force'; then
  echo "Blocked by guardrail: destructive command pattern detected." >&2
  exit 2
fi
exit 0

Mark it executable:

chmod +x .claude/hooks/block-destructive.sh

This is the section's punchline: a CLAUDE.md "never run X" line is a suggestion; this exit-2 hook is enforcement. It needs jq to read the command out of the JSON input.


7. Register the hooks in settings.json

.claude/settings.json wires the guardrail in as a PreToolUse hook on Bash, and adds a PostToolUse build hook that runs dotnet build after every Edit/Write — so compile failures land back in Claude's context and it self-corrects. Create .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [
        { "type": "command", "command": ".claude/hooks/block-destructive.sh" }
      ]}
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write", "hooks": [
        { "type": "command", "command": "dotnet build" }
      ]}
    ]
  }
}

Settings load at session start, so test from a fresh session (start a new claude session) after editing this file.


8. Bundle it as a plugin

A plugin packages the rules + skill + hooks so a teammate gets the whole setup in one install; components are discovered by convention. Create .claude/plugins/booktracker-kit/plugin.json:

{
  "name": "booktracker-kit",
  "version": "0.1.0",
  "description": "BookTracker steering kit: API/data rules, the add-api-endpoint skill, and a destructive-command guardrail."
}

9. Trim CLAUDE.md down to a map

Now move the path-scoped conventions out of CLAUDE.md — they live in .claude/rules/ and load automatically. Keep the rest (Description · Stack · Commands · Architecture); remove the inline ## Conventions list from C1 and replace it with a pointer:

## Conventions

Path-scoped conventions live in `.claude/rules/` (loaded automatically when Claude works in the
matching files): `api-endpoints.md`, `data-access.md`.

Net effect: the always-on CLAUDE.md gets shorter, and the conventions only cost context when Claude is actually in Endpoints/** or Data/**.


10. Use the skill: ship the reviews endpoint

C0 deliberately shipped no reviews feature. Now test the add-api-endpoint skill by asking for it in plain language — the skill should auto-apply by description (no /-command, no naming it):

Add a GET /api/books/{id}/reviews endpoint that returns the book's reviews, newest first.

Claude follows the skill's procedure and the new rules. Make the result permanent — it follows the house repository pattern (CoreDataApi):

  • BookTracker.Core/Dtos/ReviewDto.csId, BookId, Reviewer, Rating, Body, CreatedOn.
  • BookTracker.Core/Interfaces/IReviewRepository.cs — persistence port; GetForBookAsync(int bookId, CancellationToken ct) returns entities, newest first.
  • BookTracker.Core/Services/IReviewService.cs + ReviewService.cs — projects entities to ReviewDto; clean (no GAP patterns).
  • BookTracker.Data/Repositories/ReviewRepository.cs — async EF LINQ.
  • BookTracker.Api/Endpoints/ReviewsEndpoints.csGET /api/books/{id}/reviewsReviewDto[] newest-first; thin handler → IReviewService; 404 if the book doesn't exist.
  • Seed a few reviews, add an AddReviews EF migration, and in Program.cs register IReviewService/IReviewRepository and call app.MapReviewsEndpoints().

As-built gotcha: sort the reviews newest-first client-side — materialize with .AsNoTracking().Where(...).ToListAsync(ct) then OrderByDescending(r => r.CreatedOn) in memory. SQLite can't ORDER BY a DateTimeOffset in SQL, so don't push the ordering into the query.

Build, test, and run to confirm:

dotnet build BookTracker.sln
dotnet test BookTracker.sln
dotnet run --project BookTracker.Api    # http://localhost:5255

# in a second terminal
curl http://localhost:5255/api/books/1/reviews    # → ReviewDto[], newest first
curl -i http://localhost:5255/api/books/9999/reviews    # → 404 for an unknown book

11. Verify the kit, then commit it

Confirm each piece behaves before committing:

  • /memory shows a path-scoped rule loading only on a matching file — open one under BookTracker.Api/Endpoints/ and confirm api-endpoints is loaded; confirm it's absent when you're working elsewhere.

  • The add-api-endpoint skill auto-applied by description in step 10 (you never named it).

  • From a fresh session, ask Claude to run a destructive command and watch it get blocked (exit 2) with the reason; then make a real edit and watch the build hook fire.

Everything under .claude/ is committed so the kit travels with the repo:

git add .claude CLAUDE.md
git add BookTracker.Core BookTracker.Data BookTracker.Api
git commit -m "C2: steering kit + reviews endpoint"

Checkpoint — you're done when:

  • dotnet build and dotnet test are green.

  • /memory shows a path-scoped rule loading only on a matching file.

  • add-api-endpoint is a folder with a SKILL.md, and it auto-applies by description.

  • From a fresh session, a destructive command is blocked (exit 2) and a real edit fires the build hook.

  • GET /api/books/1/reviews returns reviews newest-first; an unknown book returns 404.

  • CLAUDE.md is trimmed — no inline conventions; it points at .claude/rules/.

  • Everything under .claude/ is committed, including plugin.json.

Your branch should now match checkpoint/c2-steering-kit.


What's next

Lab 3 (C2 → C3): you'll connect Claude Code to your IDE and add the GitHub MCP server — and your committed .claude/ kit travels with the repo and keeps applying there (same agent, same config).