spec-driven-docs 0.8.0

Spec-driven documentation: current specs, immutable decision records, and executable gates kept coherent for people and coding agents.
Documentation
# Gates

Every rule this framework states is either checked by a hook or declared unenforced. This chapter holds the wiring and the honest list of what no command can decide.

## Principle

- A rule that no command can check MUST be listed as unenforced rather than presented as gated.
- A rule a project decides not to gate MUST be listed there too.
- Every tool a gate runs beyond POSIX and git MUST be in the project's devshell and in the hook.
- A new gate MUST be demonstrated failing against an intentional violation before it is trusted.

[Rules](./rules.md) requires the `Verify:` line. This chapter wires it. A rule presented as binding but never checked teaches readers that specs describe intentions. The devshell rule makes a gate testable: a tool reachable only inside pre-commit cannot be exercised against a violation.

A check whose file set is empty exits zero. As a result, a renamed directory or a drifted `files:` pattern turns every gate below into a green light over nothing. Assert the set before checking it.

```bash
set -- _docs/specs/SPEC-*.md
[ -e "$1" ] || { echo 'FAIL no specs matched; the pattern or the layout moved'; exit 1; }
```

## Heading shapes

`MD043 required-headings` holds the fixed heading lists. It takes one `headings` array, so each shape needs its own configuration file and hook entry. First remove every mention of `MD043` from the project's `.markdownlint-cli2.jsonc`, including `"MD043": false`. That file merges over the `--config` base. A mention left there disables every shape below, and the hooks keep reporting success.

Each configuration is `{"config": {"MD043": {"headings": [...]}}}` with one array. For a spec that array is `["?", "## Purpose", "## Requirements", "+"]`. For a record it is `"?"` followed by the five section headings in order, with no trailing wildcard. Scope the record hook to `ADR-` alone. A template holds the shape inside a fence so an author can copy it, and MD043 counts a fenced heading as no heading at all.

MD043 checks every heading level, so the array covers requirement and scenario headings too. Tokens are `?` for exactly one, `+` for one or more, `*` for zero or more. `+` fails an empty spec. Set `match_case: true`. Its default is false, and without it a record headed `## status` passes.

```yaml
- id: markdownlint-cli2
  alias: md-spec
  name: markdownlint (spec heading shape)
  files: '^_docs/specs/SPEC-[a-z0-9-]+\.md$'
  args: ['--config', '.markdownlint/spec.markdownlint-cli2.jsonc']

- id: markdownlint-cli2
  alias: md-adr
  name: markdownlint (decision record heading shape)
  files: '^_docs/decisions/ADR-[a-z0-9-]+\.md$'
  args: ['--config', '.markdownlint/adr.markdownlint-cli2.jsonc']
```

Reuse the id of the project's existing markdownlint hook so its settings carry over.

## Filenames

Scope the hook to the directory, not the prefix. A `files:` pattern of `^_docs/decisions/ADR-` never sees the file someone named `0001-use-postgres.md`. That is the one filename the rule exists to reject.

```yaml
- id: adr-filename-shape
  name: decision records are ADR-<slug>.md with no digit
  language: system
  files: '^_docs/decisions/.*\.md$'
  entry: sh -c 'for f; do case "${f##*/}" in
      TEMPLATE-adr.md) ;;
      ADR-*[0-9]*) echo "FAIL $f: a record filename carries no digit"; exit 1 ;;
      ADR-?*.md) ;;
      *) echo "FAIL $f: a record filename starts with ADR-"; exit 1 ;;
    esac; done' --

- id: ki-filename-shape
  name: known-issue records are KI-<slug>.md with no counter
  language: system
  files: '^_docs/reference/known-issues/.*\.md$'
  entry: sh -c 'for f; do case "${f##*/}" in
      KI-[0-9]*) echo "FAIL $f: a case id is a slug, not a counter"; exit 1 ;;
      KI-?*.md) ;;
      *) echo "FAIL $f: a known-issue filename starts with KI-"; exit 1 ;;
    esac; done' --
```

## Case ids

A suppression over a defect this project does not own names its case. A name that resolves to no record is worth nothing. This is the coverage grep again: every `KI-` token cited outside the docs root, against the records that exist.

```bash
rg -o '\bKI-[a-z0-9-]+' --glob '!<root>/**' . | sed 's/.*://' | sort -u > /tmp/cited
ls <root>/reference/known-issues/ | sed 's/\.md$//' | sort -u > /tmp/recorded
comm -13 /tmp/recorded /tmp/cited | grep . && exit 1 || exit 0
```

The check runs one way only: a cited case with no record is a fabrication and fails. A record no suppression cites is ordinary, because the workaround can live in a configuration file or a dependency pin. The scan reads the `KI-` token alone and recognizes no suppression syntax, so a gate delivers no model of another tool's grammar. Whether a suppression that names no case states a reason belongs to the linter that owns the language, where that linter has a rule for it. [Spec to Code](./spec-to-code.md) names the lints that do and the case where none exists.

Wire it as `always_run`, not behind a `files:` filter. The commit this check exists to catch deletes a record while a suppression still cites it. Pre-commit selects staged files with `--diff-filter=ACMRTUXB`, which omits deletions. As a result, a filter hands the hook an empty list on exactly that commit. The same reasoning binds every gate that compares two sets.

## Companion directories

A companion directory with no spec beside it is an orphan. An empty one is a scaffold nobody filled.

```bash
for d in _docs/specs/*/; do
  n=$(basename "$d")
  [ -f "_docs/specs/$n.md" ] || { echo "FAIL orphan companion: $d"; exit 1; }
  [ -n "$(ls -A "$d")" ]     || { echo "FAIL empty companion: $d"; exit 1; }
done
```

## Requirement parts

Uniqueness across the corpus, then one identifier and one verification per requirement. Because the ID now lives on the heading, one pattern checks the heading shape and the ID's presence together.

```bash
rg -o --no-filename '^### `([a-z0-9-]+:[a-z0-9-]+)`' -r '$1' _docs/specs \
  | sort | uniq -d | grep . && exit 1

for f in _docs/specs/SPEC-*.md; do
  reqs=$(rg -c '^### `[a-z0-9-]+:[a-z0-9-]+` — .' "$f" || echo 0)
  [ "$reqs" = "$(rg -c '^### ' "$f" || echo 0)" ] \
    && [ "$reqs" = "$(rg -c '^Verify: ' "$f" || echo 0)" ] \
    || { echo "FAIL $f: heading shape or verify count"; exit 1; }
done
```

## Statement grammar

A full EARS parser is not worth building. Two properties catch most breaches. A requirement statement carries an RFC 2119 keyword, and a conditional statement opens with one of the four conditional keywords.

```bash
rg -UIo -r '$1' '^### `[a-z0-9-]+:[a-z0-9-]+`[^\n]*\n\n([^\n]+)' _docs/specs \
  | rg -v '\b(MUST|MUST NOT|SHOULD|SHOULD NOT|MAY)\b' \
  | grep . && exit 1 || exit 0
```

Whether the sentence names an actor that can act is a judgment call and stays with the reviewer.

## Prohibition cap

```bash
for f in _docs/specs/SPEC-*.md; do
  n=$(rg -c '\bMUST NOT\b' "$f" || echo 0)
  [ "$n" -le 5 ] || { echo "FAIL $f: $n prohibitions, cap is 5"; exit 1; }
done
```

## Sizes

```bash
find . -name AGENTS.md -exec sh -c \
  'c=150; [ "$1" = ./AGENTS.md ] && c=100; [ "$(wc -l < "$1")" -le "$c" ] || echo "FAIL $1"' _ {} \; \
  | grep . && exit 1

for f in _docs/specs/SPEC-*.md; do
  n=$(sed '/^<!--TOC-->$/,/^<!--TOC-->$/d' "$f" | wc -l)   # authored lines only
  [ "$n" -le 300 ] || { echo "FAIL $f: $n authored lines, cap is 300"; exit 1; }
  [ "$n" -le 100 ] || rg -q '<!--TOC-->' "$f" || { echo "FAIL $f: over 100 lines, no TOC"; exit 1; }
done

for f in _docs/decisions/ADR-?*.md; do w=$(wc -w < "$f"); [ "$w" -le 350 ] || { echo "FAIL $f: $w words, cap is 350"; exit 1; }; done

{ find method comparison-docs -maxdepth 1 -name '*.md' ! -name AGENTS.md; find . -type d \( -name .git -o -name node_modules -o -name vendor \) -prune -o \
  -type f \( -name glossary.md -o -name README.md \) -print; } | sort -u |
while IFS= read -r f; do
  case "${f##*/}" in gates.md | checklist.md | glossary.md | README.md | SOURCES.md) cap=300 ;; *) cap=200 ;; esac
  [ "$(wc -l < "$f")" -le "$cap" ] || { echo "FAIL $f: cap is $cap"; exit 1; }
done
```

The chapter loop is what stops a shelf from absorbing a subject by growing. A catalog takes the larger number for the reason [Format](./format.md) states. A chapter is selected by the directory it sits in, because a slug says nothing about its kind, and a catalog is matched by name because no command can tell an argument from an inventory. The walk prunes what a project vendors rather than authors, because a cap nobody can satisfy is a cap they switch off. Where a budget lands over an older corpus, the loop skips a list of named paths and fails when a listed path fits or disappears. The exemption then shrinks on its own.

## Tables of contents

`md-toc` owns the TOC: generated, never written, and gated rather than trusted. Depth 3 stops at the requirement headings. The default of 6 adds an entry per scenario, which nobody navigates to.

```yaml
- repo: https://github.com/frnmst/md-toc
  rev: 9.0.0
  hooks:
    - id: md-toc
      args: [-p, -c, --skip-lines, '1', github, -l, '3']
```

The depth flag belongs to the parser subcommand, so it follows `github`. At the top level `-l` means `--no-links` and `-l 3` is an error. In CI, check instead of write:

```bash
md_toc -d -c -s 1 github -l 3 _docs/specs/SPEC-*.md   # 0 fresh, 128 stale
```

## Fence languages

A closing fence is always bare, so matching every bare fence reports one false failure per correctly closed block. Track the open state and check the opening fence only.

````bash
awk '/^```/{ if(!inf){ inf=1; if($0=="```"){ printf "%s:%d: bare opening fence\n", FILENAME, FNR; bad=1 } }
             else inf=0; next }
     END{ exit bad }' "$@"
````

## Prose checks

One rule matches on prose, and it must strip fences and inline code first. A document stating the rule quotes the words it forbids. An unstripped match then reports the definition as a breach.

````bash
strip() { sed '/^```/,/^```/d' "$1" | sed 's/`[^`]*`//g'; }
sdd gate no-self-narration "$f"
````

The emphasis rule is stated without a gate. The table below carries it. The wrap rule is the third prose rule. `sdd gate prose-stays-unwrapped` reports the continuation line a hard wrap leaves behind, and it exempts the blocks that own their line structure. Those blocks are fences, tables, definitions, and hard breaks.

## Personal paths

`sdd gate no-personal-path` reports an absolute path into a home directory: `/home/<name>`, `/Users/<name>`, and the Windows spelling. It judges the whole project. Whether a string is a real person's home directory does not depend on which conventions you follow. The check therefore has no register to collide with, and its value is entirely in breadth. To keep a path out of it, reserve that path in `.spec-driven-docs/config.yaml`.

This is the one prose check that does not strip code first. A fenced command carrying a real home directory is the leak, not a quotation of it. As a result, a document teaching the shape writes a placeholder segment: `<user>`, `$USER`, `~`. Two exemptions are by purpose rather than path. The first is a file whose job is one person's environment: `.env`, `.envrc.local`, and their sample copies. The second is any file git ignores, which never reaches a hook at all.

## Typed clauses

`sdd gate spec-change-is-typed` reads the plan zone the project declared, so the wiring carries no path. What it judges is a clause line: a line that names an owning spec by path, in inline code. A plan zone holds whatever the planning tool writes there. A narrative sentence using one of the three words is not a citation.

```yaml
- id: spec-change-is-typed
  name: spec changes are typed
  entry: sdd gate spec-change-is-typed
  language: system
  always_run: true
  pass_filenames: false
```

The gate takes the path `SDD_PLAN_ZONE` names, else the recorded zone where the project tracks it. A declared directory it cannot read fails, because a check over an empty set is a green light over nothing. Where neither resolves, the gate reports nothing and the table below carries the case.

## Comment citations

Two checks, and the second is the one that matters. Scope both to the code and exclude the docs root. A chapter stating either rule writes the strings it forbids.

```bash
rg -n --glob '!<root>/**' -e '^[[:space:]]*(#|//|--|\*)[[:space:]].*\bADR-' . \
  && { echo 'FAIL a comment names a decision record'; exit 1; } || true

rg -o '(SATISFIES|VERIFIES) ([a-z0-9-]+:[a-z0-9-]+)' -r '$2' --glob '!<root>/**' \
  | sort -u > /tmp/cited
rg -o '^### `([a-z0-9-]+:[a-z0-9-]+)`' -r '$1' <root>/specs | sort -u > /tmp/agreed
comm -13 /tmp/agreed /tmp/cited | grep . \
  && { echo 'FAIL a comment cites a rule no spec defines'; exit 1; } || true
```

The second is the orphan check: a citation resolving to nothing is a fabrication, and catching it is what makes every other citation site worth trusting.

## Clarification markers

The cap and the form are one loop. Whether the marker blocks enactment is a set intersection against the enacted list from [Spec to Code](./spec-to-code.md).

```bash
for f in <root>/specs/SPEC-*.md; do
  n=$(rg -c '\[NEEDS CLARIFICATION' "$f" || echo 0)
  [ "$n" -le 3 ] || { echo "FAIL $f: $n markers, cap is 3"; exit 1; }
  rg -q '\[NEEDS CLARIFICATION:?[[:space:]]*\]' "$f" \
    && { echo "FAIL $f: a marker carries no question"; exit 1; }
done

awk '/^### `/{id=$2} /\[NEEDS CLARIFICATION/{print id}' <root>/specs/SPEC-*.md \
  | tr -d '`' | sort -u > /tmp/marked
comm -12 /tmp/marked /tmp/enacted | grep . \
  && { echo 'FAIL work enacts a rule carrying an open question'; exit 1; } || true
```

The `awk` attributes a marker to the requirement heading above it, not to one in a fixed window.

## Unenforced

These rules are real and no command decides them. A reviewer does.

| Rule                                                               | Why no command                            |
| ------------------------------------------------------------------ | ----------------------------------------- |
| A requirement names a subject that can act                         | requires reading the sentence             |
| A requirement statement is one sentence                            | requires reading the sentence             |
| A reference is one level from the entry document                   | requires knowing the entry document       |
| A scenario names the contested case, not a restatement             | requires knowing the ambiguity            |
| A deferral's reopening condition is checkable                      | requires domain knowledge                 |
| Prose is spent only on a decision, hazard, or constraint           | requires judging necessity                |
| A document contains no bold or italic text                         | stated without a gate by decision         |
| One term for one concept                                           | requires knowing which terms are synonyms |
| A fact has exactly one owner                                       | requires knowing what the fact is         |
| A document owns what it governs                                    | requires knowing the project's own domain |
| A spec introduces no section outside its shape                     | its trailing wildcard admits any heading  |
| A run of records about one domain means a missing spec             | requires reading the corpus               |
| A seeded rule states an obligation its adopter can violate         | requires reading the rule's subject       |
| A seeded rule's verification is one the adopter can perform        | requires knowing the adopter's tools      |
| A spec change is declared as a typed clause                        | a command cannot see an omitted clause    |
| A typed clause's type matches the diff                             | requires reading both sides               |
| A typed clause outside a tracked plan zone                         | the project declined to gate that zone    |
| A step is one action, and an unprinted outcome is a step           | requires reading the step                 |
| Every step carries a check a reader can judge                      | requires reading the step                 |
| A manual step enumerates every field and value                     | requires knowing the interface            |
| A step's inputs are produced by an earlier step                    | requires tracing the procedure            |
| A guide opens with preconditions and closes with a check           | requires reading the guide                |
| A divergent result states its condition and destination            | requires knowing the tool's behavior      |
| An upstream-owned fact is verified against its source              | requires fetching the source              |
| An upstream citation is dated and lives in reference               | requires reading the reference zone       |
| An artifact token names one artifact, never a step                 | requires judging the name                 |
| A comment holds only what the code cannot express                  | requires reading the code beside it       |
| A claim about code quotes the code that shows it                   | requires reading the code beside it       |
| A report leads with a run before its supporting detail             | requires reading the document             |
| A pointer carries only what orients the reader                     | requires knowing what the target owns     |
| An operational document carries every part of its shape            | requires knowing which shape it is        |
| A destructive step shows its dry run and its loss                  | requires knowing the tool's forms         |
| A suppression naming no case states its reason                     | the language's own linter owns the syntax |
| A digit inside a document's slug names its subject, not a position | requires knowing the subject              |

[Checklist](./checklist.md) is where these are asked at review time.