1. Summary
Spec-driven development means writing a specification - what the feature does and what counts as
done - before any planning or code. The spec is the contract. Plans and implementations are
evaluated against it, not against vague remembered intent.
A plan describes how. A spec describes what and why, independent of
implementation approach. The same spec can be satisfied by completely different plans. Writing
the spec first forces you to discover what you actually want before Claude commits to an approach.
Open questions surface before they are silently resolved the wrong way in code.
2. spec.md Structure
A spec.md is the artifact produced by the spec-writing step. Use this template as a starting point
and edit it for your feature:
# Feature Spec: [name]
## Goal
One sentence: what problem this solves and for whom.
## Behavior
Exact description of what the feature does. Include CLI syntax, API shape,
or interaction sequence as appropriate. Be specific enough that two different
developers would implement it the same way.
## Edge Cases
List each one explicitly. If the behavior for an edge case is undefined,
say so rather than leaving it implicit.
## Out of Scope
What this feature explicitly does not do. This is as important as what it does.
## Acceptance Criteria
Numbered list of checkable conditions that define "done." Each criterion
should be verifiable by running the code or reading its output.
## Open Questions
Anything you are unsure about. Leave these as questions rather than guessing.
They become discussion items before the planning phase starts.
The Open Questions section is the most important. Every implicit assumption that doesn't become
an explicit question becomes a surprise late in the work.
3. The Workflow Sequence
Spec-driven work runs in four stages, each gated on your review:
- Spec - write or draft spec.md. All open questions resolved before moving on.
- Plan - Claude proposes an implementation plan grounded in the spec. You review and approve.
- Implement - one step at a time, stopping after each for review.
- Verify - check each acceptance criterion explicitly.
The approval gates between stages are load-bearing. Skipping them collapses spec-driven work
back into free-form generation, where the model decides what "done" means.
4. Drafting the Spec with Claude
If writing the full spec yourself is slow, use Claude to draft it from a description - then
review every section carefully before treating it as authoritative.
I want to add multi-extension filtering to TextFinder - the ability to search
only files matching any of several extensions supplied on the command line.
Draft a spec.md for this feature using this template:
## Goal
## Behavior
## Edge Cases
## Out of Scope
## Acceptance Criteria
## Open Questions
Leave anything you're unsure about as an open question rather than guessing.
Do not propose a plan yet.
After Claude produces the draft, work through the Open Questions section before proceeding.
Each answered question closes a gap in the spec. Unresolved questions at this stage become
bugs in the implementation.
5. Prompting for a Plan
Once the spec is settled, ask for a plan grounded in it:
Read spec.md.
Propose a plan to implement this feature. Before the plan, flag any
spec ambiguities you see. Structure the plan as ordered steps, each
with a checkable output. Do not write any code yet.
Review the plan against the spec, not against your intuition. Ask: does each step move toward
the acceptance criteria? Does the plan handle every edge case the spec named? If not, resolve
it before approving.
6. Implementing Step by Step
After approving the plan, implement one step at a time:
The plan looks good. Implement step 1 only - stop after that so I can review.
After each step, check the output against the spec. If a step's result conflicts with an
acceptance criterion, correct it before moving to the next step. Mistakes caught locally cost
far less than mistakes discovered after five more steps of code have been written on top of them.
When all steps are complete, verify each acceptance criterion explicitly:
Read spec.md.
Walk through each acceptance criterion and confirm whether the current
implementation satisfies it. Show the relevant code or output for each one.
7. Case Study: RustDirNav
RustDirNav is the generic directory navigator at the core of rs_textfinder.
It dispatches file and directory events to a caller-supplied App type through the
DirEvent trait, so the same traversal logic works for any application that implements
the two required methods. Building it spec-first produced a clean separation between
the navigation contract, the implementation plan, and the code - each stage verifiable
against the previous one.
Directory tree
rs_textfinder/
├── Constitution.md -- language-agnostic governing document
├── Structure.md -- Rust-specific project layout
├── Notes.md -- development notes
├── docs/
│ └── Project_Spec.md -- top-level project specification
├── RustCmdLine/
│ ├── README.md -- usage and design
│ └── RustCmdLine_Spec.md -- CLI parser API specification
├── RustDirNav/
│ ├── README.md -- usage and design
│ ├── RustDirNav_Spec.md -- navigator API specification
│ ├── examples/
│ │ └── test1.rs -- demonstration program
│ ├── src/
│ │ └── dir_nav_lib.rs -- library implementation
│ └── test_dir/ -- test fixture
│ ├── test_file.rs
│ └── test_sub1_dir/
│ ├── test_file1.rs
│ └── test_file2.exe
├── EntryPoint/
│ ├── CLAUDE.md -- Claude Code context for this crate
│ ├── README.md -- usage and design
│ ├── Req_TextFinder.md -- requirements and assertions
│ └── EntryPoint_Spec.md -- text finder API specification
└── RustTfVerify/ -- verification harness (no md files)
md files
Two md files drive the workflow. README.md describes the design and build
steps at a high level. RustDirNav_Spec.md is the working document produced
in the spec phase - it defines the DirEvent trait, every DirNav method, edge cases, invariants,
and acceptance criteria. The spec was written before any code and served as the reference
for both the plan and the verification pass.
RustDirNav_Spec.md (condensed)
# RustDirNav_Spec.md - Directory Navigator
**Crate:** rust_dir_nav v1.1.0 **Source:** src/dir_nav_lib.rs
## Purpose
Walks a directory tree and dispatches file and directory names to a
caller-supplied App type. App implements DirEvent to define what to do
with each discovered file and directory.
## Trait DirEvent
fn do_dir(&mut self, d: &str) -- called for each selected directory
fn do_file(&mut self, f: &str) -- called for each file matching a pattern
## Struct DirNav<App: DirEvent + Default>
new() -> Self
-- pre-populates skip_dirs: bin, obj, target, .git, .vs, archive, ...
-- recurse: true, hide: true
recurse(&mut self, p: bool) -- enable/disable subdirectory recursion
hide(&mut self, p: bool) -- suppress dirs with no matching files
add_skip<S: Into<String>>(&mut self, s: S) -> &mut DirNav<App>
add_pat<S: Into<String>>(&mut self, p: S) -> &mut DirNav<App>
get_app(&mut self) -> &mut App -- access App state after traversal
get_dirs(&self) -> usize -- directories entered (including hidden)
get_files(&self) -> usize -- files matched (or all if no patterns)
clear(&mut self)
-- clears pats, resets counters, replaces app with App::default()
-- preserves recurse, hide, skip_dirs
visit(&mut self, dir: &Path) -> io::Result<()>
-- depth-first search; calls app.do_dir and app.do_file
-- returns Err only if root path is not a directory
## Edge Cases
- Empty pats: every file triggers do_file
- skip_dirs entries: silently excluded; never entered or counted
- hide(true): do_dir not called for dirs with no matching files
- clear() does NOT reset recurse, hide, or skip_dirs
## Acceptance Criteria
1. visit() finds all files matching any registered extension
2. hide(true): do_dir suppressed for dirs with no file matches
3. recurse(false): only the root directory is examined
4. add_skip("name"): dirs named "name" are never entered or counted
5. cargo test -- --test-threads=1 passes all three tests
Prompt sequence
Draft the spec before planning or code. The key constraints go in the initial prompt so
the model can surface open questions rather than guessing:
I want to build a generic directory navigator library in Rust named RustDirNav.
Before any planning or code, draft a spec covering:
## Goal
## Behavior
## Public API (DirEvent trait and DirNav<App> struct with all methods)
## Edge Cases
## Acceptance Criteria
## Open Questions
Key behavior to capture:
- Generic over App: DirEvent + Default; App handles do_dir and do_file events
- Depth-first traversal with optional recursion (default: enabled)
- Extension-based file filtering; no filter means all files pass
- Skip list of directory names to never enter (bin, obj, target, .git, etc.)
- Option to suppress directories with no matching files (hide, default: enabled)
- Counters for dirs entered and files matched; clear() resets them
Leave anything uncertain as an open question. Do not plan yet.
After reviewing the draft and resolving open questions, write it to
RustDirNav_Spec.md. Then prompt for a plan grounded in it:
Read RustDirNav_Spec.md.
Propose an implementation plan as ordered steps, each producing a checkable output.
Flag any spec ambiguities before the plan. Do not write any code yet.
After approving the plan, implement one step at a time:
The plan looks good. Implement step 1: DirEvent trait, SearchPatterns type alias,
and DirNav<App> struct definition with all fields. Stop after that.
Step 1 looks correct. Implement step 2: new(), recurse(), hide(), all accessor
and mutator methods, and clear(). Stop after that.
Good. Implement step 3: visit() with replace_sep() and in_patterns() helpers.
Stop after that.
When all steps are complete, verify against the spec:
Read RustDirNav_Spec.md.
Walk through each acceptance criterion and confirm whether the implementation
satisfies it. Show the relevant code section for each criterion.
Usage
cd rs_textfinder/RustDirNav
cargo run --example test1
Output
Searching path "...rs_textfinder/RustDirNav"
C:/github/JimFawcett/NewSite/Code/Projects/TextFinder/rs_textfinder/RustDirNav
Cargo.toml
New Text Document.txt
Output.txt
C:/github/JimFawcett/NewSite/Code/Projects/TextFinder/rs_textfinder/RustDirNav/examples
test1.rs
C:/github/JimFawcett/NewSite/Code/Projects/TextFinder/rs_textfinder/RustDirNav/src
dir_nav_lib.rs
C:/github/JimFawcett/NewSite/Code/Projects/TextFinder/rs_textfinder/RustDirNav/test_dir
test_file.rs
C:/github/JimFawcett/NewSite/Code/Projects/TextFinder/rs_textfinder/RustDirNav/test_dir/test_sub1_dir
test_file1.rs
test_file2.exe
C:/github/JimFawcett/NewSite/Code/Projects/TextFinder/rs_textfinder/RustDirNav/test_dir/test_sub2_dir
test_file3.txt
processed 9 files and 7 dirs
Searching path "./test_dir"
./test_dir
test_file.rs
./test_dir/test_sub1_dir
test_file1.rs
test_file2.exe
./test_dir/test_sub2_dir
test_file3.txt
processed 4 files in 3 dirs
The first pass searches the full crate tree from the working directory; the second
searches only the test fixture. The target/, archive/, and .git/ directories are absent
from the output because they appear in the default skip list built by new().
dir_nav_lib.rs
/////////////////////////////////////////////////////////////
// dir_nav_lib.rs
// Jim Fawcett, https://JimFawcett.github.io, 12 Apr 2020
/////////////////////////////////////////////////////////////
/*
DirNav<App> is a directory navigator that uses the generic
parameter App to define how files and directories are handled.
- displays only paths that have file targets by default
- hide(false) will show all directories traversed
- recurses directory tree at specified root by default
- recurse(false) examines only specified path.
*/
use std::fs::{self, DirEntry};
use std::io;
use std::io::{Error, ErrorKind};
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
/// trait required of the App generic parameter type
pub trait DirEvent {
fn do_dir(&mut self, d: &str);
fn do_file(&mut self, f: &str);
}
/////////////////////////////////////////////////
// Patterns are a collection of extension strings
// used to identify files as search targets
type SearchPatterns = Vec<std::ffi::OsString>;
/// Directory Navigator Structure
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct DirNav<App: DirEvent> {
/// file extensions to look for
pats: SearchPatterns,
/// directory names to skip during traversal
skip_dirs: SearchPatterns,
/// instance of App : DirEvent, requires do_file and do_dir methods
app: App,
/// number of files processed
num_file: usize,
/// number of dirs processed
num_dir: usize,
/// recurse ?
recurse : bool,
/// hide dirs with no targets ?
hide: bool,
}
impl<App: DirEvent + Default> DirNav<App> {
pub fn new() -> Self
where
App: DirEvent + Default,
{
let defaults = [
"bin", "obj", // C#/.NET
"target", // Rust
"build", "out", // C++
"__pycache__", ".venv", "venv", "dist", // Python
".git", ".vs", ".idea", // VCS / IDE
"archive",
];
let mut skip_dirs = SearchPatterns::new();
for name in &defaults {
let mut s = std::ffi::OsString::new();
s.push(name);
skip_dirs.push(s);
}
Self {
pats: SearchPatterns::new(),
skip_dirs,
app: App::default(),
num_file: 0,
num_dir: 0,
recurse: true,
hide: true,
}
}
pub fn recurse(&mut self, p: bool) { self.recurse = p; }
pub fn hide(&mut self, p: bool) { self.hide = p; }
pub fn get_app(&mut self) -> &mut App { &mut self.app }
pub fn get_dirs(&self) -> usize { self.num_dir }
pub fn get_files(&self) -> usize { self.num_file }
pub fn get_patts(&self) -> &SearchPatterns { &self.pats }
pub fn add_skip<S: Into<String>>(&mut self, s: S) -> &mut DirNav<App> {
let mut t = std::ffi::OsString::new();
t.push(s.into());
self.skip_dirs.push(t);
self
}
pub fn add_pat<S: Into<String>>(&mut self, p: S) -> &mut DirNav<App> {
let mut t = std::ffi::OsString::new();
t.push(p.into());
self.pats.push(t);
self
}
pub fn clear(&mut self) {
self.pats.clear();
self.num_dir = 0;
self.num_file = 0;
self.app = App::default();
}
pub fn visit(&mut self, dir: &Path) -> io::Result<()>
where App: DirEvent
{
self.num_dir += 1;
let dir_name: String =
self.replace_sep(dir).to_string_lossy().to_string();
let mut files = Vec::<std::ffi::OsString>::new();
let mut sub_dirs = Vec::<std::ffi::OsString>::new();
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skip = match path.file_name() {
Some(name) => self.skip_dirs.contains(&name.to_os_string()),
None => false,
};
if !skip {
sub_dirs.push(self.replace_sep(&path));
}
} else {
if self.in_patterns(&entry) || self.pats.is_empty() {
self.num_file += 1;
files.push(entry.file_name());
}
}
}
if !files.is_empty() || !self.hide {
self.app.do_dir(&dir_name);
}
for fl in files {
self.app.do_file(&fl.to_string_lossy().to_string());
}
for sub in sub_dirs {
let mut pb = std::path::PathBuf::new();
pb.push(sub);
if self.recurse { self.visit(&pb)?; }
}
return Ok(());
}
Err(Error::new(ErrorKind::Other, "not a directory"))
}
pub fn replace_sep(&self, path: &Path) -> std::ffi::OsString {
let mod_path = path.to_string_lossy().replace("\\", "/");
let mut os_str = std::ffi::OsString::new();
os_str.push(mod_path);
os_str
}
pub fn in_patterns(&self, d: &DirEntry) -> bool {
match d.path().extension() {
Some(extn) => self.pats.contains(&extn.to_os_string()),
None => false,
}
}
}