AI Workflows: Spec-Driven Implementation

locking in what before agreeing to how

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:
  1. Spec - write or draft spec.md. All open questions resolved before moving on.
  2. Plan - Claude proposes an implementation plan grounded in the spec. You review and approve.
  3. Implement - one step at a time, stopping after each for review.
  4. 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,
        }
    }
}