Development Playbook

Make Claude Code interview you, then keep the editor locked until you approve the spec

A /spec skill, a 50-line checker and one PreToolUse hook that stops the agent writing code outside a spec you've signed off. About 15 minutes to set up.

Part of the hooks reading guide

A blueprint floor plan of a single room with one gateway marked by a glowing checkmark

The usual way an interview-first workflow fails is boring. The agent asks good questions, writes a good spec, and then the spec is a suggestion. Twenty minutes into the build it has touched a file the spec never mentioned, because it was nearby and the change looked sensible.

Tokenise has already covered the interview, spec and fresh session routine as a prompt you paste. This is the part that makes it stick: a /spec skill that runs the interview the same way every time, a small script that rejects thin specs, and a hook that refuses edits to any file an approved spec doesn’t name. You approve the spec by changing one word. Until you do, the agent can’t write code.

The pieces are three scripts, two skills and one settings entry. Everything below is the working version, including the one bug the first real run exposed.

The shape of it

A spec is a Markdown file in specs/ with five sections and a status line. The status is draft or approved, and only a human flips it.

  • /spec <feature> interviews you, writes the draft, and runs the checker on it until it passes.
  • spec_check.py fails any spec with a missing section, a leftover TBD, a file path that doesn’t exist, or a “Done when” with no command in it.
  • spec_gate.py is a PreToolUse hook on Edit|Write. No approved spec means no edits. An approved spec means edits only to the files it lists.
  • /build <spec> is for the fresh session that implements it.

The check matters more than it looks. A spec the agent wrote in its own words can look finished and still say “handle errors sensibly”. The checker can’t judge quality, but it can refuse the obvious ways a spec fails to be a spec.

The spec format

Here’s the target the skill writes. It’s the same skeleton the best-practices guide recommends: files named, scope bounded, ending with a way to prove it works.

# CSV export of the current report
Status: draft

## Goal
Add a plain function `exportReportCsv(id: string): string`.

## Files
- `src/api/reports.ts`
- `src/api/export.ts` (new)

## Out of scope
- HTTP routes, authentication, streaming

## Decisions
- Whole CSV built in memory; throw above 100000 rows

## Done when
- `node --experimental-strip-types src/api/export.test.ts` exits 0

Paths in backticks under Files are the contract. A path marked (new) is one the agent may create. Everything else must already exist. That one rule is what the gate enforces later.

The checker

Put the parsing in a shared module so the checker and the hook can’t disagree about what a spec says. Save this as .claude/scripts/specs.py:

"""Shared spec parsing and checks. Used by spec_check.py and spec_gate.py."""
import re
from pathlib import Path

REQUIRED = ["Goal", "Files", "Out of scope", "Decisions", "Done when"]
PLACEHOLDER = re.compile(r"\b(TBD|TODO|FIXME)\b|\?\?|decide later|to be confirmed", re.I)


def sections(text):
    """Map each '## Heading' to its body text."""
    out, name = {}, None
    for line in text.splitlines():
        m = re.match(r"##\s+(.+?)\s*$", line)
        if m:
            name = m.group(1)
            out[name] = []
        elif name:
            out[name].append(line)
    return {k: "\n".join(v).strip() for k, v in out.items()}


def status(text):
    m = re.search(r"^Status:\s*(\w+)", text, re.M)
    return m.group(1).lower() if m else None


def file_entries(body):
    """Yield (path, is_new) for each backticked path in the Files section."""
    for line in body.splitlines():
        m = re.match(r"\s*[-*]\s+`([^`]+)`(.*)", line)
        if m:
            yield m.group(1), "(new)" in m.group(2).lower()


def problems(path, root):
    text = Path(path).read_text()
    found = []
    if status(text) not in ("draft", "approved"):
        found.append("first line block needs 'Status: draft' or 'Status: approved'")
    secs = sections(text)
    for name in REQUIRED:
        if not secs.get(name):
            found.append(f"section '{name}' is missing or empty")
    for m in PLACEHOLDER.finditer(text):
        found.append(f"placeholder left in spec: '{m.group(0)}'")
    for rel, is_new in file_entries(secs.get("Files", "")):
        exists = (Path(root) / rel).exists()
        if is_new and exists and status(text) == "draft":
            found.append(f"{rel} is marked (new) but already exists")
        if not is_new and not exists:
            found.append(f"{rel} does not exist; mark it (new) or fix the path")
    if not re.search(r"`[^`]+`", secs.get("Done when", "")):
        found.append("'Done when' needs at least one runnable command in backticks")
    return found

The status(text) == "draft" condition on the (new) check looks odd, and it’s the bug mentioned earlier. It’s explained under “What goes wrong” below. The command-line wrapper is short, saved as .claude/scripts/spec_check.py:

#!/usr/bin/env python3
"""Usage: spec_check.py specs/<name>.md. Exit 0 if the spec is complete, 1 if not."""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import specs

target = sys.argv[1]
issues = specs.problems(target, Path.cwd())
if issues:
    print(f"{target}: {len(issues)} problem(s)")
    for i in issues:
        print(f"  - {i}")
    sys.exit(1)
print(f"{target}: ok")

Feed it a deliberately bad spec (an empty Out of scope, a TBD, a file that doesn’t exist, a “new” file that does, and a “Done when” of “It works”) and it prints:

specs/csv-export.md: 5 problem(s)
  - section 'Out of scope' is missing or empty
  - placeholder left in spec: 'TBD'
  - src/api/export.ts does not exist; mark it (new) or fix the path
  - src/reports/rows.ts is marked (new) but already exists
  - 'Done when' needs at least one runnable command in backticks

That output is also what the agent sees when it runs the check, which is the point. It reads the list, fixes the spec and runs the check again.

The /spec skill

Create .claude/skills/spec/SKILL.md:

---
name: spec
description: Interview the user about a feature and write a checked spec to specs/. Use before any non-trivial feature.
disable-model-invocation: true
allowed-tools: Bash(python3 .claude/scripts/spec_check.py *)
---

Feature to spec: $ARGUMENTS

Do not write any code. The only file you may create is specs/<kebab-name>.md.

1. Read the code the feature touches. Do not ask questions you can answer by reading it.
2. Interview the user. Ask at most four questions at a time, using AskUserQuestion when it is available and plain numbered questions otherwise. Cover data volume, permissions, failure cases, what must not change and how the user will know it works. Challenge vague answers such as "whatever's sensible": ask again with two concrete options.
3. Keep going until every question you would otherwise guess at is answered.
4. Write specs/<kebab-name>.md in exactly this shape:

   # <Title>
   Status: draft

   ## Goal
   ## Files
   - `path/to/existing.ts`
   - `path/to/created.ts` (new)
   ## Out of scope
   ## Decisions
   - one bullet per interview answer that changed the design
   ## Done when
   - a runnable command in backticks, and what it must show

5. Run `python3 .claude/scripts/spec_check.py specs/<kebab-name>.md`. Fix every problem it prints and run it again until it says ok.
6. Stop. Tell the user to read the spec, edit anything wrong, and change the status line to `Status: approved` themselves. Never set it yourself.

Two choices in the front matter. disable-model-invocation: true means the skill only runs when you type /spec, so the agent can’t decide to start interviewing you mid-task. And $ARGUMENTS is replaced with whatever follows the command.

The “challenge vague answers” line earns its place. Without it, “whatever’s sensible” gets written into the spec as a decision.

The gate

This is the part that changes behaviour. Save .claude/scripts/spec_gate.py:

#!/usr/bin/env python3
"""PreToolUse hook for Edit|Write. Blocks edits unless an approved spec covers the file."""
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import specs

event = json.load(sys.stdin)
root = Path(event["cwd"]).resolve()
args = event.get("tool_input", {})
target = Path(args.get("file_path", "")).resolve()
try:
    rel = target.relative_to(root).as_posix()
except ValueError:
    sys.exit(0)  # outside the project: not our business

body = args.get("content", "") + args.get("new_string", "")
if rel.startswith("specs/"):
    if "status: approved" in body.lower():
        print("Only a human sets 'Status: approved'. Leave it as draft and ask the user to review.", file=sys.stderr)
        sys.exit(2)
    sys.exit(0)  # drafting specs is always allowed
if rel.startswith(".claude/"):
    print("The agent can't edit its own guardrails under .claude/.", file=sys.stderr)
    sys.exit(2)

allowed = set()
for spec in sorted((root / "specs").glob("*.md")):
    text = spec.read_text()
    if specs.status(text) == "approved" and not specs.problems(spec, root):
        allowed.update(p for p, _ in specs.file_entries(specs.sections(text).get("Files", "")))

if rel in allowed:
    sys.exit(0)
if allowed:
    print(f"{rel} is not listed under Files in any approved spec. Allowed: {', '.join(sorted(allowed))}. "
          "If it needs to change, stop and ask the user to update the spec.", file=sys.stderr)
else:
    print("No approved spec. Run /spec <feature>, then ask the user to review it and set 'Status: approved'.", file=sys.stderr)
sys.exit(2)

Then register it in .claude/settings.json, along with a permission rule for the checker (the reason is in the failure section):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/scripts/spec_gate.py\""
          }
        ]
      }
    ]
  },
  "permissions": {
    "allow": ["Bash(python3 .claude/scripts/spec_check.py *)"]
  }
}

The hook reads the tool call as JSON on stdin. Exit code 2 blocks the call and sends stderr back to the agent as the reason, per the hooks documentation. Any other non-zero exit is a non-blocking error and the edit goes ahead, which is why every deny path in the script ends in sys.exit(2) and nothing else. A crash in the script, such as a Python traceback, also exits 1 and fails open. Keep that in mind before adding clever code to it. On Windows, change python3 to python.

Three rules sit in the script, each closing a gap you’d otherwise discover the hard way:

  1. No approved spec, no edits outside specs/.
  2. With one, only files under Files. The agent’s message names the allowed list, so it knows what to ask you to change.
  3. Writing Status: approved into a spec is refused, and .claude/ is off limits, so the agent can’t approve its own work or switch the gate off.

Because the hook parses specs with the same module as the checker, a spec that fails the check grants nothing even if someone marks it approved.

Run it

Pipe fake tool calls into the hook first. It’s the fastest way to see every branch, and no agent is involved:

g(){ echo "$1" | python3 .claude/scripts/spec_gate.py; echo "-> exit $?"; }

With only a draft spec on disk, an attempt to create src/api/export.ts prints:

No approved spec. Run /spec <feature>, then ask the user to review it and set 'Status: approved'.
-> exit 2

Writing the draft spec itself is allowed (-> exit 0). An Edit that changes Status: draft to Status: approved is not:

Only a human sets 'Status: approved'. Leave it as draft and ask the user to review.
-> exit 2

After a human flips the status, writing src/api/export.ts exits 0, but an edit to src/reports/rows.ts, which the spec doesn’t list, prints:

src/reports/rows.ts is not listed under Files in any approved spec. Allowed: src/api/export.ts, src/api/reports.ts. If it needs to change, stop and ask the user to update the spec.
-> exit 2

Now the real thing, in a small scratch project with two TypeScript files. Run /spec CSV export of the current report from src/api/reports.ts and the agent reads the code, then asks before writing anything. Here’s the first round, condensed (the wording is shortened, the questions are the agent’s):

I have some questions the code can't answer:

1. Shape and entry point: Should the export be a new function in src/api/reports.ts,
   such as exportReportCsv(id): string? Or should it be a route or download endpoint?
2. Data volume: Reports are currently two rows. How big can a real report get?
   (a) up to about 10k rows, built as one in-memory string;
   (b) 100k or more rows, streamed.
3. Permissions and failure cases: ...
4. Format and verification: ... Spreadsheet-formula injection: should names starting
   with =, +, - or @ be prefixed with '?

The fourth question is the kind that justifies the exercise. Nobody put formula injection in the request, and a CSV export that writes =HYPERLINK(...) into a cell opened by a colleague’s spreadsheet is a bug that review tends to miss.

Answer the questions and it comes back with follow-ups on what counts as an “unknown id”, because the sample data function ignores its argument, and on an extensionless import that node --experimental-strip-types can’t resolve. Both were real gaps. Once those are answered it writes specs/csv-export-report.md and runs the checker, which prints specs/csv-export-report.md: ok. Then it stops and says:

The spec is ready for you to read. Edit anything that's wrong. When you're happy with it,
change the status line to `Status: approved` yourself. I haven't touched it.

That interview took three rounds. In -p mode, which is how it was run here, AskUserQuestion isn’t offered without a permission host, so the skill’s “plain numbered questions otherwise” clause did the work. In an interactive session the same skill uses the question tool.

Build in a fresh session

The interview filled the first session’s context with exploration. Execute the spec somewhere clean. Save .claude/skills/build/SKILL.md:

---
name: build
description: Implement an approved spec from specs/ in a fresh session.
disable-model-invocation: true
---

Spec to build: $ARGUMENTS

Spec check, run just now: !`python3 .claude/scripts/spec_check.py $ARGUMENTS`

If the check above did not say ok, or the spec does not say `Status: approved`, stop and say why.

Otherwise implement exactly what the spec says. Edit only the files under Files. If something else needs to change, stop and ask. Finish by running every command under Done when and report the real output.

The !`...` line is dynamic context injection: Claude Code runs the command before the agent sees the skill, so the check result arrives inlined. Run /build specs/csv-export-report.md against the draft and the agent stops: “The spec says Status: draft, not Status: approved, so I haven’t built anything.”

Ask it directly for the code instead, skipping the skill, and the gate answers for you. The agent reported that a project hook blocked the write with “No approved spec”, that it hadn’t tried to work around it by writing through Bash, and offered the two ways forward.

After approval, /build created src/api/export.ts and made the one-line import change the spec listed. Asked to add a comment to src/reports/rows.ts, which isn’t in Files, the agent reported the hook’s refusal and named the three files it was allowed to touch.

The finished script ran with node --experimental-strip-types src/api/export.test.ts and printed ok, exit 0, plus a Node warning about a missing "type": "module" in package.json. The agent noticed package.json wasn’t in Files, so it left it alone and reported the warning. That is the gate doing its job in a mildly annoying way, and it’s the right trade.

What goes wrong

The first end-to-end build got stuck, and the cause was the checker, not the agent. The spec said src/api/export.ts (new). Once the agent created that file, the “marked (new) but already exists” rule fired, the spec stopped validating, and the gate, which only trusts specs that pass the check, allowed nothing. The agent then couldn’t write its test file and asked the user to debug the hook. Restricting that rule to drafts fixed it. If you adapt the checker, keep the invariant that an approved spec still validates after the build has started.

Two permission surprises, both from the documentation and both reproduced:

  • The allowed-tools grant in a skill applies for the turn that invokes it and clears at your next message. In a multi-turn interview the spec gets written turns after the skill was invoked, and the checker run was refused with an approval prompt that nobody was there to answer. That’s why the settings file carries its own permissions.allow rule.
  • In a headless -p run, Claude Code ignored that settings rule until the folder was marked trusted, printing that the workspace had not been trusted. Interactive sessions get the trust dialog. For unattended runs, trust the folder deliberately first.

Then the limits of the gate itself. It matches Edit and Write only. An agent that is blocked there could in principle reach the same file with a shell command such as sed -i, and this hook doesn’t see it. In the runs above the agent didn’t try, and said it wouldn’t, but that is behaviour, not enforcement. If you need enforcement, add a Bash matcher and a deny-by-default permission set (see the guardrail hook piece ), or run the agent in a sandbox where files outside the spec aren’t writable.

The gate also makes small changes slower. A typo fix in a file the spec doesn’t list means updating the spec. For work you could describe in one sentence, skip all of this. The best-practices guide says the same about planning.

Last, the checker only proves a spec is shaped like a spec. It can’t tell a thoughtful Decisions section from a lazy one. Reading the spec before approving it is still your job, and it’s the cheapest review in the whole process.

Make it yours

  • Monorepo: have the gate resolve Files entries relative to the package named in the spec, or add a Package: line the checker requires.
  • Tests always allowed: add a glob list, such as **/*.test.ts, to the gate’s allowed set when you don’t want to list every test file.
  • Team approval: replace “a human flips the word” with a check that the spec’s last commit was made by someone other than the agent’s git identity.
  • Codex or Cursor: the spec format and checker carry over unchanged. The gate is Claude Code specific because it relies on PreToolUse hooks.

First 15 minutes

Copy the three scripts and the two skills into a repo you work in, add the settings entry, and commit them so the whole team gets the same gate. Then pick a feature you’d normally describe in two sentences and run /spec on it. Read the questions first. If you can answer all of them from memory without thinking, the feature was small enough not to need this.