# Ralph Progress Log — spec-brief

## READ THIS FIRST, EVERY ITERATION

**Design source of truth:** `docs/superpowers/plans/2026-08-19-spec-brief-design.md`

You have no memory of the conversation that produced this plan. That document contains the
schema, the five provenance classes, the precedence order, the confidence policy, the reuse
targets, and the landmines. Read it before touching code. If the PRD and the design doc ever
disagree, the design doc wins — and say so in your progress entry.

**The one-sentence goal:** make spec authoring conform to the harness house rule that every
other subsystem already follows — *a judgment call must never halt an unattended run; default
conservatively and record it.*

---

## Codebase Patterns

<!-- Carried forward from the integration-counterweight run. These are still true. -->

- **Lint BOTH paths.** CI runs `uv run ruff check claw_forge/ tests/`. A narrowed local lint
  passes while CI fails on test-file E501s.
- **`docs/module-map.md` is NOT automatic.** It only tracks the curated `ANCHOR_SYMBOLS`
  tuple in `scripts/gen_module_map.py`. A new module does not appear on its own — add an
  anchor whenever CLAUDE.md starts naming a symbol's location.
- **Handbook `accept --chapter` takes the `book.yaml` id**, not the filename slug
  (e.g. `quality`, not `quality-gates`).
- **Never `return` in a `finally` block.** A static invariant gate fails the build; it also
  swallows the dispatcher's in-flight `CancelledError`.
- **`uv run` always** — never activate the venv manually.
- **Declare deviations honestly.** The previous run's most useful artifacts were its recorded
  plan-gaps ("the plan lists X but no step populates it"), not its code. If the plan is wrong
  or silent, implement the honest thing and write down the gap. Do not invent a step and do
  not silently skip one.

<!-- New patterns discovered during THIS run go here. -->

- **`docs/module-map.md` anchors now cover classes and module-scope constants**
  (US-014 extended `anchor_rows`). A function-local of the same name is
  deliberately *not* a definition — a location stated confidently and wrongly is
  worse than no entry. Anchor whatever CLAUDE.md names a home for; nothing else.

- **A handbook drift warning is a lead, not a chore.** `accept --chapter` is
  all-or-nothing across that chapter's watched sources, so adding one watch
  commits you to reading the chapter — and reading it is where you find the
  claim that went stale (ch.08 said `shape` rejects `integration`; it has not
  since the integration-counterweight run). Never accept a chapter you did not
  read.

- **Generated handbook chapters are written by your docstrings.** Ch.20 renders
  from the live Typer tree, so a new command's `"""docstring"""` and `help=`
  strings *are* its handbook page. Write them as user-facing copy the first time.

- **Verify docs against the module, not memory.** US-014's first draft of the
  `brief check` reference listed two checks that do not exist and misdescribed a
  third. Prose asserts behavior; hold it to the same evidence bar as an assertion.

- **A guard test that blocks legitimate work must be TIGHTENED, not deleted or widened.**
  US-009's purity guard grepped for the literal `"import anthropic"`; its own comment
  said the concern was a *module-level* import, which a substring search cannot express.
  US-011 rewrote it as an AST check and proved it non-vacuous against planted violations
  before committing. Ask what proposition the test was defending, then encode *that*.

- **ruff `B008` exempts `typer.Argument`/`Option` on immutable annotations only.**
  Every `typer.Argument(` in the repo annotates a `str` and passes; a `list[str]` one
  fails B008 and needs a module-level singleton default. Bites any new list-valued flag.

- **Fold narrow extractions ONE LEAF AT A TIME into the brief.** A brief section carries
  one `_provenance` across several leaves, so batching a whole pass into one incoming
  brief makes `_weakest_provenance` flatten every field onto the weakest one — destroying
  the per-field confidence the confirm gate needs. `merge_extractions` chains `merge_brief`
  over single-leaf briefs; keep it that way.

- **A "pre-existing failure" claim needs a scratch worktree, not a memory.** Earlier
  entries asserted `test_gate_subprocess_gets_toolchain_path` was environmental; US-010
  proved it by running that one test at `HEAD~1` in `git worktree add --detach
  /tmp/cf-check`. Cheap (one command), and it converts an inherited excuse into
  evidence. Root cause on macOS: `monkeypatch.setenv("HOME", str(tmp_path))` gives an
  unresolved `/var/folders/…` while `str(tmp_path / ".cargo/bin")` is the resolved
  `/private/var/folders/…` — the two sides of the `/var` symlink. Remove the worktree
  afterwards (`git worktree remove --force`).

- **Reuse before writing.** The design doc's "What to reuse" section names five existing
  things (`detect_layout`, `detect_brownfield`, the `SpecEvaluator` pure-prompt/pure-parser
  pattern, `run_llm_evaluation`'s no-API-key degradation, `PROFILE_REGISTRY`'s import-time
  self-check). Reaching for any of these is correct; reimplementing one is a review failure.
- **`claw_forge/spec/` already has `_invariant.py`.** These are new modules in an existing
  subsystem package — do NOT add a new invariant declaration.
- **New top-level CLI command ⇒ `telemetry/vocab.py:COMMANDS` in the SAME commit.**
  `tests/telemetry/test_vocab.py` walks the live Typer tree and fails closed. This bit
  `upgrade` already. US-004 adds `brief`.
- **⚠ NEVER end a turn waiting on a background task.** You run as `claude -p`; when the turn
  ends the process EXITS and no notification can reach you. This killed iterations 3 and 4 —
  both finished US-009's code green, started the full suite in the background, wrote a
  confident "I'll report when the monitor fires", and died with the work uncommitted. Run the
  suite in the FOREGROUND. Better still: commit after a scoped run + `ruff`/`mypy`, then
  confirm with the full suite in the same turn. See the new top section of
  `scripts/ralph/CLAUDE.md`.
- **A conservative default that is *also* the permissive value is a trap.**
  `Policies().manual_review` defaults to `default-and-record`, so "read the policy,
  then decide whether to block" makes the absent-brief case behave like the
  autonomous case — breaking the back-compat floor without a line of code that looks
  wrong. When a policy gates blocking behavior, let the **flag** select the mode and
  the **policy** only veto it; then "no brief ⇒ unchanged" holds by construction.

- **An AC naming one writer means the repo's pure/impure pair.**
  `dump_brief`/`save_brief`, `dump_decisions`/`write_decisions`,
  `dump_deferrals`/`write_deferrals`: formatting is pure and gets all the tests, the
  filesystem shim is three lines. A test that creates a file to assert on a string
  is testing the filesystem.

- **Absent and malformed are different facts for every artifact reader.** Absent →
  the back-compat floor (`None`/`[]`); malformed → raise naming the file. A reader
  that degrades a broken file to "empty" makes the artifact lie by omission, and for
  a queue of unanswered judgment calls that is the one failure it cannot afford.

- **A handoff instruction can carry a fatal bug forward.** The "run the full suite IN THE
  BACKGROUND" instruction that killed iterations 3–4 was written into `handoff.json` by
  iteration 2. When you write a handoff, you are writing *instructions a fresh agent will
  obey literally* — re-read them once as an adversary before emitting the handoff signal.

---

## Non-negotiables for this run

1. **Back-compat floor.** No `spec_brief.yaml` present ⇒ conservative defaults ⇒ *today's
   behavior, unchanged*. This mirrors `<layout>`'s absence yielding `default_layout()` and
   byte-identical legacy behavior. Any story that breaks an existing `/create-spec` or
   `/fix-spec` flow has failed, regardless of its own tests.
2. **The brief holds decisions, never content.** Competency questions and policies live in
   the brief; feature bullets live in the spec. If a story starts putting bullets in the
   brief, stop — that is the design going wrong.
3. **Nothing new may block.** Every new code path degrades and records rather than raising.
   Absent API key, malformed model output, unknown domain, unreadable brief — all of these
   return something usable and flag it. Follow `run_llm_evaluation`'s shape exactly.
4. **Confidence is derived from provenance, never from model self-report.** If a parsed
   response contains the model's own confidence number, ignore it. US-010 has a test that
   asserts this.

---

Started: 2026-08-19
Branch: ralph/spec-brief (from main @ 4df0c6b, v0.9.1)
Stories: 14 · estimated iterations: 21

## 2026-08-19 — US-001 · SpecBrief + Provenance dataclasses with YAML round-trip

Implemented `claw_forge/spec/brief.py`: `Provenance`, `Identity`, `Stack`,
`CompetencyQuestion`, `Constraint`, `Policies`, `SpecBrief`, plus
`load_brief`/`save_brief`/`dump_brief` and the pure `brief_to_dict`/`brief_from_dict`
pair. 31 tests, **100% branch coverage on the new module**.

Files: `claw_forge/spec/brief.py`, `tests/spec/test_brief.py`,
`tests/spec/fixtures/spec_brief_canonical.yaml`.

**Learnings:**

- **Canonical form is defined as emitter output.** "Round-trips byte-stably" is
  unimplementable against an arbitrary hand-written YAML file (quoting style, key
  order, `null` vs omitted are all free variables). Defining canonical = whatever
  `save_brief` emits turns it into a real invariant: known keys in declared order,
  extras appended after, block style, `span` omitted when `None`. The committed
  fixture was *generated* by the emitter, then asserted to round-trip — do not
  hand-edit `tests/spec/fixtures/spec_brief_canonical.yaml`; regenerate it.
- **PLAN GAP (resolved, declare it): `needs_review` had two homes.** US-001's AC puts
  it on `Provenance`; the design doc's sample YAML puts it on the *item* (`cq-7`).
  Resolved with one storage site (`Provenance.needs_review`), a read-only
  `needs_review` property on every section dataclass, and a loader fold that accepts
  an item-level flag. The fold is **sticky** — it can raise the flag, never lower it —
  so a stale `needs_review: false` beside a provenance that says `true` cannot clear
  a real review requirement. Emission is `_provenance`-only.
- **`PROVENANCE_ORDER` is declared in `brief.py`, not `brief_merge`.** US-002 must
  read `Provenance.rank` rather than re-encode the comparison. An **unknown** `by`
  value ranks *last*, so a hand-edited typo loses to every well-formed writer instead
  of silently winning every merge.
- **`Policies` field defaults ARE the conservative tier.** A bare `SpecBrief()` is
  therefore the back-compat floor by construction, not by remembering to fill it.
  US-003 must keep `POLICY_DEFAULTS["standard"]` in agreement with these defaults —
  add a test pinning the two together.
- **Unknown keys survive at every level, not just top-level.** The AC only demands
  top-level, but "nothing is dropped" is a cheaper rule to hold in review than a rule
  about which levels preserve. Every section dataclass carries `extra`.
- **`load_brief` splits absent from malformed.** Absent → `None` (back-compat floor,
  not a failure). Malformed → typed `BriefError`. Silently substituting defaults for
  a file the user deliberately wrote would hide their typo — the opposite of the
  "record the decision" goal.

**Environment notes (NOT regressions — do not chase these):**

- `tests/agent/test_toolchain.py::TestWiring::test_gate_subprocess_gets_toolchain_path`
  fails **on a clean tree** on this box (verified via `git stash -u`). This Mac's
  `PATH` is long enough that `run_test_command`'s output cap head-truncates the echoed
  value, so the asserted `.cargo/bin` prefix is gone. Environment artifact; passes in CI.
- `ui/package-lock.json` carries a stale `version: 0.9.0` against `package.json`'s
  `0.9.1` — drift left by the v0.9.1 bump commit (`4df0c6b`). Any `npm` invocation
  rewrites it and dirties the tree. **Reverted, not committed** — it is not this
  branch's story, and a dirty tree across ralph iterations gets bundled into unrelated
  commits.

**Full-suite state:** 5377 passed, 1 failed (the pre-existing toolchain env failure
above), 3 skipped. `ruff check claw_forge/ tests/` clean. `mypy` clean.
---

## 2026-08-19 — US-002 · Provenance precedence merge

Appended the merge layer to `claw_forge/spec/brief.py` (the design doc's module
layout puts "precedence merge" there, so no new module): `FieldValue`, `Conflict`,
`FieldMerge`, `MergeResult`, `merge_field`, `merge_brief`. 37 tests; `brief.py` still
**100% branch coverage**.

Files: `claw_forge/spec/brief.py`, `tests/spec/test_brief_merge.py`.

**Learnings:**

- **A merged section's provenance is its WEAKEST contributor.** The schema gives each
  section one `_provenance` but several leaves (`identity` holds `name` *and*
  `domain`). Once leaves merge independently, that single provenance cannot honestly
  describe them all. Reporting the lowest-ranked contributor (with `min(confidence)`
  and OR'd `needs_review`) means a merged section can *under*-claim its evidence but
  never over-claim it — the only safe direction for an artifact whose whole purpose is
  provenance integrity. If a later story needs per-leaf provenance, that is a **schema
  change**, not a merge change.
- **"Empty contributes nothing" is what makes narrow extraction passes safe.**
  `_is_present()` gates every leaf, so an elicited *blank* cannot wipe a detected
  layout profile. Without this, US-009/US-011's per-field passes would each clobber
  the fields they had no opinion about. Do not weaken it.
- **BUG CAUGHT IN OWN SMOKE TEST — `priority: int = 1` was a trap.** A default that is
  a *legal value* makes "unset" indistinguishable from "declared as 1", so a partial
  `CompetencyQuestion` reset an existing priority-2 question to 1 *and* recorded a
  spurious conflict. Fixed by `priority: int | None = None` plus `sort_priority` and
  `DEFAULT_PRIORITY` for the always-explicit serialized form. **General rule for the
  rest of this run: any new optional field whose default is also a legal user value
  must be `| None`.** Watch for this in `Policies` (its defaults are legal values —
  but they are *intended* as the conservative tier, so that one is correct as-is).
- **`defaulted` never manufactures a conflict.** A 0.0-confidence placeholder
  disagreeing with real evidence is noise, not a contradiction worth a human's time.
  Conflicts require: both present, values differ, ranks differ, neither side defaulted.
- **A conflict raises `needs_review` on its section.** Otherwise it would live only in
  a `MergeResult` return value that US-012's gate never reads. This is what routes
  "elicited contradicts detected" to the human.
- **Lint-both-paths bit again, as predicted.** `ruff check claw_forge/` was clean while
  `tests/` had an E501. Always run the full CI command.

**Gate:** `pytest tests/spec/ -q` → 465 passed. `ruff check claw_forge/ tests/` clean.
`mypy claw_forge/ --ignore-missing-imports` → 190 files, clean.
---

## 2026-08-19 — US-003 · POLICY_DEFAULTS by quality tier

Added `claw_forge/spec/brief_defaults.py`: `POLICY_CHOICES`, `POLICY_RANGES`,
`POLICY_DEFAULTS` (minimal | standard | strict), `check_policy_tier`,
`get_policy_tier`, `policies_for_tier`, `DEFAULT_TIER`. 38 tests, **100% branch**.

Files: `claw_forge/spec/brief_defaults.py`, `tests/spec/test_brief_defaults.py`.

**Learnings:**

- **Read the command docs before naming policy values.** The vocabulary is not free
  invention: `serialize`/`keep-parallel` is Phase 3.5 Step 2's `[s]`/`[k]`,
  `partition`/`keep-serialized` is Phase 3.25 Step 3.5's `[p]`/`[s]`, and `widen` is
  literally what `fix-spec.md`'s Gap 13 (mix) row already does. A policy table whose
  values drift from the prompt wording it replaces is a translation layer nobody can
  audit. **US-013 must reuse these exact strings in `fix-spec.md`.**
- **Phase 3.5's `[q] quit` is deliberately NOT representable.** A policy whose value is
  "abort the run" is precisely the failure mode this table exists to remove. `halt` IS
  representable for `manual_review` (so legacy behavior has a name) but is never a
  default.
- **A tier tunes review scope, not safety.** Only `confidence_floor` varies across the
  three tiers (0.5 / 0.75 / 0.9). The safety-shaped keys hold the conservative answer
  in *every* tier. This is written into the module docstring because a future editor
  will otherwise "fix" the table's apparent redundancy by varying them — which would
  make `--tier minimal` silently mean "dispatch colliding agents in parallel".
- **Import-time self-checks hide an uncoverable branch.** The `_POLICIES` loop's
  `raise` is unreachable once the table is correct, so it sat at 96% and would have
  dragged the 90% gate. Fix: move the assertion *into* `check_policy_tier` behind
  `require_self_label=True`. The flag is not a coverage trick — a brief's `policies`
  block is not a registry entry and genuinely has no self-label rule, so US-005 needs
  the flag off. **Pattern for US-007's `DOMAIN_REGISTRY`: put every rule in the
  callable checker, keep the import loop a single call.**
- **`policies_for_tier` sets `needs_review=False` on purpose.** A default is a decision
  the harness stands behind, not an open question. What routes an item to the confirm
  gate is a *field's* confidence falling under `confidence_floor` (US-012), not the
  mere fact that policies were defaulted. Getting this wrong would put all seven policy
  keys in every gate and defeat the design.
- **`test_standard_tier_equals_the_bare_dataclass_defaults` is the back-compat pin**
  promised in the US-001 notes. `Policies()` == `policies_for_tier("standard")`.

**Gate:** `pytest tests/spec/ -q` → 503 passed. `ruff check claw_forge/ tests/` clean.
`mypy` clean.
---

## 2026-08-19 — US-004 · `claw-forge brief init` (deterministic, zero LLM)

`brief.build_initial_brief()` + the `brief` Typer group in `spec/cli.py` + the
`telemetry/vocab.py` entry. 29 tests; `brief.py` and `brief_defaults.py` both at
**100% branch**; the only new `spec/cli.py` branch is covered too.

Files: `claw_forge/spec/brief.py`, `claw_forge/spec/cli.py`, `claw_forge/cli.py`,
`claw_forge/telemetry/vocab.py`, `tests/spec/test_brief_init.py`.

**Learnings:**

- **DESIGN CORRECTION — `detect_brownfield` must record a fact, not raise a flag.**
  My first version set `needs_review=True` when the project was brownfield but had no
  `brownfield_manifest.json`. Its markers are `.git` / `package.json` /
  `pyproject.toml` — i.e. **every real project** — and the manifest is a greenfield
  *post-run byproduct* most repos never have. So that rule would flag `stack` on
  essentially every repository forever: exactly the "a noisy template makes the confirm
  gate worse than the interview it replaces" risk the design doc calls the known risk.
  Now: no manifest + markers present ⇒ `existing_context = {"brownfield": true}`, a
  detected fact, no flag. **Apply the same test to every rule US-005/US-008 adds: if it
  would fire on almost every project, it is noise, not a check.**
- **A non-root layout detection is `detected` but flagged.** `detect_layout` returns
  zone bindings; if none is at the repo root, the marker genuinely matched but "one flat
  profile describes the whole tree" is a guess that really wants a `<layout>`. Confidence
  stays 1.0 (the marker *did* match) and `needs_review` carries the doubt — confidence
  and needs_review are separate dials; do not collapse them.
- **Function-local imports keep `brief.py` light.** `build_initial_brief` imports
  `validator` and `brief_defaults` inside the function: module-level would drag the
  parser chain into every light consumer, and `brief_defaults` imports *from* `brief`
  (circular). `detect_layout` itself uses this same in-function-import idiom.
- **`brief` is registered as `add_typer(name="brief")`, not top-level.** `init` is
  already taken by the project scaffolder. `claw-forge brief init` reads correctly.
- **Rich wraps CLI output at terminal width.** Assertions on `result.output` break on a
  line wrap in the middle of a phrase. Added a `flat()` helper (`" ".join(text.split())`)
  — **use it in every future CLI test in this run.**
- **Import-time-only coverage traps, again.** `spec/cli.py`'s `if flagged:` false branch
  needed an explicit "fully confident brief" test. New CLI branches need a test each;
  the 90% gate is on the whole file, so a new partial branch hides easily.

**Gate:** `pytest tests/spec/ tests/telemetry/ tests/test_cli_commands.py
tests/test_cli_shim_completeness.py -q` → 1201 passed. `ruff check claw_forge/ tests/`
clean. `mypy claw_forge/` → 191 files, clean.
---

## 2026-08-19 — US-005 · `claw-forge brief check`

`claw_forge/spec/brief_validate.py` (`check_brief`, `levenshtein`, `suggest`, five rule
functions) + the `brief check` subcommand. 49 tests, **100% branch coverage**.

Files: `claw_forge/spec/brief_validate.py`, `claw_forge/spec/cli.py`,
`tests/spec/test_brief_validate.py`.

**Learnings:**

- **There is no Levenshtein in this repo.** `/fix-spec` Step 0 specifies it *by name*,
  but only as a prompt instruction to the model — grep found zero Python. So
  `brief_validate.levenshtein` + `suggest` are new (stdlib, ~20 lines, no dependency).
  **US-013 note:** `fix-spec.md` still asks the *model* to compute it; if a later story
  wants that deterministic, this function is the one to call.
- **`suggest` breaks ties alphabetically.** Advice that changes between runs is not
  advice. Same reason the handbook renderers must stay deterministic.
- **Do not reuse `check_policy_tier` for user-facing validation.** It raises on the
  *first* bad key. Someone hand-editing a brief deserves every problem at once, so
  `check_policies` iterates `POLICY_CHOICES`/`POLICY_RANGES` directly. The *table* is
  the reuse target, not the function — the tables stay the single source of truth.
- **"Absent `_provenance`" is not detectable after loading, and that is fine.** The
  loader turns both absent and malformed into `defaulted`/empty-source, so the rule is
  "a populated field whose provenance is `defaulted` with no source, or whose `by` is
  unknown". Both mean the same thing to a reader: nothing recorded why. Do not add a
  round-trip-the-raw-mapping hack to tell them apart.
- **`test_a_freshly_initialised_brief_has_no_errors` is the coupling pin between US-004
  and US-005.** `brief init` output must never fail `brief check`. Keep it green when
  US-008 starts adding inferred questions.
- **Rule admission test held.** Every rule was checked against "would this fire on a
  normal project?". `brief init` output produces warnings (no competency questions yet)
  but **zero errors**, and exit is non-zero only on ERROR.

**Gate after US-005:** full suite `uv run pytest tests/ -q` → **5530 passed, 1 failed,
3 skipped**. The single failure is the pre-existing
`test_gate_subprocess_gets_toolchain_path` environment artifact documented under
US-001 — it fails on a clean tree on this box. `ruff check claw_forge/ tests/` clean.
`mypy claw_forge/` → 192 files, clean. New modules `brief.py`, `brief_defaults.py`,
`brief_validate.py` all at **100% branch coverage**.
---

## 2026-08-19 — US-006 · Non-interactive synthesis path + `spec_decisions.jsonl`

`brief.py` gains the decision record: `Decision`, `decision_to_dict` /
`decision_from_dict`, `dump_decisions`, `write_decisions` / `read_decisions`,
`DECISIONS_FILENAME`. `.claude/commands/create-spec.md` gains a leading
**Brief-Driven Mode** section. 25 tests; `brief.py` stays at **100% branch**.

Files: `claw_forge/spec/brief.py`, `.claude/commands/create-spec.md`,
`tests/spec/test_brief_decisions.py`.

**Learnings:**

- **PLAN GAP (declared): "ask the user NOTHING" cannot mean "the brief supplies
  the features."** The brief holds *decisions, never content* — there are no
  bullets in it — so a brief-present run still needs feature content from
  somewhere. Resolved the way the design doc's own "competency questions bookend
  the pipeline" section implies: CQs are the content carrier, expanded into
  bullets, and acquisition (`$ARGUMENTS`, supplied docs, the conversation already
  in context) stays "flexible and multi-modal. Untouched." What the brief kills is
  the *resolution* interrogation — Phases 2.5, 3.25 Step 2 + Step 3.5, 3.5 Step 2,
  and Phase 5's "Does this look right?". **US-012's gate doc must keep this
  distinction**; if a story starts putting bullets in the brief, stop.
- **The design doc maps Phases 2.5/3.25/3.5; it says nothing about Phases 1/2/4.**
  Rather than invent policy keys, the doc's table routes them to existing brief
  fields (`identity.*`, `constraints`) and states the fallback rule verbatim: a
  field the brief has no answer for takes the conservative option and is
  **recorded as `by: defaulted`**, never asked. Phase 2's depth defaults to
  **Quick** unless the invocation already supplied technical detail. That is a
  judgment call I made explicitly — it is not in the design doc.
- **Provenance is emitted FLAT on the wire, not nested.** `{"by": "defaulted"}`
  at the top level makes `grep '"by": "defaulted"' spec_decisions.jsonl` the whole
  audit; a record has exactly one provenance, so nesting would buy no
  disambiguation. The AC's phrase "provenance.by" names the *value*, not a nested
  path. `span`/`needs_review` follow `_prov_to_dict`'s conventions exactly
  (omit-when-None, always-emit) so the two artifacts stay readable side by side —
  and so the round trip is lossless, which a strict six-key record would not be.
- **`read_decisions` raises where `load_brief` raises, and returns empty where it
  returns `None`.** Absent ⇒ `[]` (the back-compat floor); malformed line ⇒
  `BriefError` naming the **line number**. Deliberate deviation from the
  "everything degrades" rule, and the justification is specific: this is a *reader*
  on no run path, and silently skipping a record would make an **audit artifact lie
  by omission** — the one failure mode a decision record cannot afford. Blank lines
  are skipped; they carry no claim to drop. **US-013's `write_deferrals` should
  mirror this pair exactly.**
- **`Decision.field` shadows `dataclasses.field` inside the class body.** The
  attribute keeps the name `field` because that is the key on the wire (an
  attribute that disagrees with its JSON key is a bug waiting to happen), so the
  factory is reached via a `_dc_field = field` alias. Annotation-only shadowing
  happens to work at runtime, but mypy resolves the later `field(...)` call to the
  `str` attribute and errors. **Any future record dataclass with a `field` key
  needs the alias.**
- **`dump_decisions([])` is the empty string, not `"\n"`.** Nothing decided is a
  legitimate outcome. A stray newline would make an empty record and a
  one-blank-record file indistinguishable to `wc -l`.
- **NOTHING IN-REPO CALLS `write_decisions` YET — declared, not an oversight.**
  The slash command is executed by an agent that writes the JSONL directly (a
  `python -c` in a prompt would be worse), so the doc names
  `write_decisions`/`read_decisions` as the *canonical format definition* and shows
  the shape. **US-012's `--yes` deferral-recording path is its first real caller.**
  If US-012 finds it needs a `decisions_from_policies(brief)` helper, that is a
  clean addition — I deliberately did not speculate one into existence here.
- **Hoisted `import json` to module level in `brief.py`.** The function-local
  import idiom in this file exists for *circular* (`brief_defaults`) and *heavy*
  (`validator`) imports; `json` is neither, and three local copies would have been
  noise. `_read_existing_context` updated to match.

**Gate after US-006:** full suite `uv run pytest tests/ -q` → **5555 passed, 1
failed, 3 skipped** (+25 = exactly the new file; the failure is the pre-existing
`test_gate_subprocess_gets_toolchain_path` environment artifact documented under
US-001 — do not chase it). `ruff check claw_forge/ tests/` clean. `mypy
claw_forge/` → 192 files, clean. `brief.py` at **100% branch coverage**.
---

## 2026-08-19 — US-007 · `DOMAIN_REGISTRY` with two domains

`brief_defaults.py` gains `Capability`, `DOMAIN_REGISTRY` (`saas-b2b`,
`crud-app`), `check_domain_template`, `register_domain_template`,
`domain_capabilities`, `QUESTION_PLACEHOLDERS`, `UNNAMED_PROJECT`. 44 tests;
`brief_defaults.py` and `brief_validate.py` both **100% branch**.

Files: `claw_forge/spec/brief_defaults.py`, `tests/spec/test_brief_domains.py`,
`tests/spec/test_brief_validate.py`.

**Learnings:**

- **THE CURATION RULE THAT MATTERS: no shipped `prior` may reach the standard
  tier's `confidence_floor` (0.75).** A prior becomes the *confidence* of an
  inferred competency question, and US-012's gate shows anything below the floor.
  A prior of 0.8 would therefore let a question **nobody asked for** slide
  silently into the spec under the default tier — the harness putting words in the
  user's mouth. Shipped priors sit in [0.5, 0.7]; only `minimal` (0.5) lets the
  strongest through, which is exactly what a minimal *review* tier should mean.
  Pinned by `test_no_prior_reaches_the_standard_tiers_confidence_floor`, which
  reads `POLICY_DEFAULTS` rather than a literal. **US-008 must not raise priors to
  make its gap check look more confident.**
- **Schema rules go in the checker; curation rules go in tests.** `[0.0, 1.0]` is
  a schema rule (a registered custom domain may legitimately use 0.9). "Under the
  standard floor" is a *curation* rule about the two we ship. Putting the latter in
  `check_domain_template` would forbid an operator a legal prior.
- **`{name}` is the only placeholder, enforced at import.** An unrecognized
  `{workspace}` is a `str.format` **KeyError three hours into an unattended run** —
  the exact failure class this table exists to prevent, so it fails at import
  instead. Same reasoning for "a template must end in `?`": a competency question
  that cannot be answered yes/no traces to nothing at validation time, so it is
  worthless as the bookend the design describes.
- **US-005's forward hook went live, and its test was pinned to an ERA, not a
  BRANCH.** `check_domain` reads `getattr(brief_defaults, "DOMAIN_REGISTRY", {})`,
  and `test_an_empty_registry_is_tolerated_and_never_warns` asserted no warning
  using the *real* (then-empty) registry — so landing this story broke it, exactly
  as predicted before writing the code. Fixed by monkeypatching `{}`: the guard is
  still reachable because `register_domain_template` makes the registry mutable at
  runtime. Added `test_the_shipped_registry_is_live_without_monkeypatching` so the
  activation is asserted rather than assumed. **Generalize: when a story leaves a
  forward hook, its test must patch the future state, never rely on the present
  one.** US-008/US-009 will leave more of these.
- **`domain_capabilities` returns `()` for an unknown domain; `get_policy_tier`
  raises.** Deliberate divergence, documented in both docstrings: a tier comes from
  a closed set the CLI validates, a domain is free text a human typed into a YAML
  file, and the gap check reading it runs unattended. `brief check`'s
  `check_domain` is where the typo is reported, with a Levenshtein suggestion.
- **Ruff `SIM300` treats an ALL_CAPS name as a constant.** `assert DOMAIN_REGISTRY
  == before` and `assert QUESTION_PLACEHOLDERS == frozenset(...)` are both "Yoda
  conditions" to ruff and fail CI — in `tests/`, which `ruff check claw_forge/`
  alone would never have caught. **Third time the lint-both-paths pattern has paid
  for itself this run.**
- **The registry is module-level mutable state, so every test that registers must
  restore it** (autouse fixture snapshotting/restoring `DOMAIN_REGISTRY`).
  Otherwise `test_a_builtin_may_be_replaced` leaks into `test_exactly_two_domains_ship`
  under `pytest-randomly`, which this repo runs by default.

**Gate after US-007:** full suite `uv run pytest tests/ -q` → **5600 passed, 1
failed, 3 skipped** (+45; the failure is the pre-existing
`test_gate_subprocess_gets_toolchain_path` environment artifact — do not chase
it). `ruff check claw_forge/ tests/` clean. `mypy claw_forge/` → 192 files,
clean.
---

## 2026-08-19 — US-008 · Domain gap check → inferred competency questions

`brief.py` gains `infer_missing_questions` (pure), `gap_check`,
`GAP_MATCH_THRESHOLD`, `_keywords`, `_covers`, `_next_question_id`, and calls
`gap_check` at the end of `build_initial_brief`. 37 tests; `brief.py` back to
**100% branch**.

Files: `claw_forge/spec/brief.py`, `tests/spec/test_brief_gap_check.py`,
`tests/spec/test_brief_init.py`.

**Learnings:**

- **The matcher's asymmetry is the whole design decision.** A *missed*
  suppression shows the user a question nobody asked for; enough of those make
  the review longer than the interview it replaced — the design doc's named known
  risk. An *over-eager* suppression only means the harness does not nag. So when
  the evidence is ambiguous, **suppress**. Everything else follows: a 0.5 overlap
  threshold rather than "all key words", and matching against the curated
  `capability.key` rather than the model-generated question text.
- **NO STOP-WORD LIST, and that is a considered choice, not laziness.** Overlap is
  only ever computed *against a capability key*, and a curated key never contains
  a stop word — so filtering stop words could only remove tokens from the
  *question* side, suppressing less and inferring more. Wrong direction. Deleting
  the machinery also deleted a dozen arbitrary choices nobody could defend later.
  (Singularization *is* kept: it applies symmetrically to both sides.)
- **`capability` is what makes the check idempotent.** An inferred question stamps
  the key it came from, so the exact-match arm suppresses it on the next run.
  Without it, `brief init && brief init` (or US-011's extract-then-init) would
  duplicate the whole checklist. `test_the_gap_check_is_idempotent` pins it.
- **An explicit `capability` also *blocks* the keyword arm.** A question that
  declares `capability="audit-log"` makes a precise claim; letting its words spill
  over and suppress `data-export` too would let one declaration silently swallow a
  neighbouring gap. Hence `if question.capability: return False` rather than
  falling through.
- **Precedence is satisfied by appending, not by merging.** US-002's `merge_brief`
  is not called at all: a covered capability is skipped, and an uncovered one has
  no higher-ranked value to lose to. Reaching for the merge here would have been
  the more "correct-looking" and strictly worse answer — it renumbers ids and
  reports conflicts that do not exist.
- **PREDICTED BREAK, SECOND TIME:** `test_a_fully_confident_brief_reports_no_review_line`
  passed `--domain saas-b2b` and asserted *no* flagged fields. Since every shipped
  prior sits below the standard floor (US-007's curation rule), that invocation now
  correctly flags seven. Moved to an uncovered domain and added a sibling asserting
  the covered path. **This is the same class of failure as US-007's registry test:
  a test that pins the current era rather than the branch it means to defend.**
- **`gap_check` returns the *same object* when there is nothing to add**
  (`dataclasses.replace` only on the non-empty path), so `gap_check(b) is b` is the
  documented no-op contract for an empty/unknown domain. The input is never
  mutated; sections other than `competency_questions` are shared.
- **Coverage trap: `_covers`'s empty-key guard.** `check_domain_template` rejects an
  empty key, so the guard looked unreachable — but `DOMAIN_REGISTRY` is a plain
  mutable dict and direct assignment bypasses `register_domain_template`. Left in
  (a `ZeroDivisionError` inside an unattended gap check is worse than a missed
  match) and pinned by a test that mutates the registry directly, which is the
  honest reachable path rather than a private-helper call.
- **Registry-mutating tests need the autouse restore fixture** — copied from
  `test_brief_domains.py`. Any future test file that calls
  `register_domain_template` needs it too, or it leaks under `pytest-randomly`.

**Gate after US-008:** full suite `uv run pytest tests/ -q` → **5638 passed, 1
failed, 3 skipped** (+38; the failure is the pre-existing
`test_gate_subprocess_gets_toolchain_path` environment artifact — do not chase
it). `ruff check claw_forge/ tests/` clean. `mypy claw_forge/` → 192 files,
clean. `brief.py` at **100% branch**.

**NOTE for US-009+:** the full suite now takes ~4.5 min and exceeds the 600 s
foreground tool timeout when run with `-p no:randomly`. Run it with
`run_in_background: true` and read the output file, rather than watching it
time out.
---

## 2026-08-19 — US-009 · brief_extract.py pure prompt-builders and parsers

Implemented `claw_forge/spec/brief_extract.py`: four pure prompt-builders and four pure
parsers (identity, competency_questions, constraints, layout), plus `render_documents`
and `decode_payload`. 152 tests, **100% branch coverage**. No module makes a network
call — the model call is injected by the caller (US-011).

Files: `claw_forge/spec/brief_extract.py`, `tests/spec/test_brief_extract.py`,
`tests/spec/fixtures/brief_extract/`.

**Learnings:**

- **`Extraction` is deliberately NOT a brief dataclass and has no `confidence` field at
  all.** That makes US-010's "model self-report is ignored" a *structural* guarantee
  rather than a filtering step someone must remember. US-010 scores these into brief
  provenance; US-011 merges. If US-010 wants parsers returning `Identity` /
  `CompetencyQuestion` directly, prefer the unscored intermediate — a brief dataclass
  would have to carry a placeholder confidence that means nothing.
- **The layout pass is a deliberate PRD-over-design-doc resolution.** The design doc
  assigns `layout` to the `detected` mechanism; the US-009 AC names it explicitly. Built
  it, and it is safe by construction: `detected` outranks `extracted` in
  `PROVENANCE_ORDER`, so a real filesystem always beats a document's claim. It serves the
  greenfield case — an empty tree whose architecture doc already says "pnpm workspace".
- **⚠ HOW THIS ITERATION DIED — read the Codebase Patterns entry.** The code above was
  finished, green, lint-clean and mypy-clean *inside iteration 3*. The iteration then
  started the full suite in the background and ended its turn to wait for a notification
  that cannot arrive in `claude -p`. Iteration 4 repeated it verbatim. The loop's
  anti-spin guard aborted the run with the work uncommitted on disk. The operator verified
  it independently (**full suite: 5791 passed, 3 skipped, 0 failed**) and committed it.
  Cost: two iterations and a run abort, for zero code defects.

**Gate (operator-verified, foreground):** `uv run pytest tests/ -q` → **5791 passed,
3 skipped, 0 failed** in 308s. `ruff check claw_forge/ tests/` clean.
`mypy claw_forge/ --ignore-missing-imports` → 193 files, clean.
Note: the `test_gate_subprocess_gets_toolchain_path` failure reported in earlier entries
does NOT reproduce here — it is PATH-shape dependent on this box, not a real failure.
---

## 2026-08-19 — US-010 · Span verification and provenance-derived confidence

Added to `claw_forge/spec/brief_extract.py`: `normalize_whitespace`, `verify_span`,
`verify_span_in_documents`, `confidence_for`, `score_extraction`, plus the six named
confidence constants. 51 new tests (203 total in the file), **100% branch coverage on
the module retained**.

Files: `claw_forge/spec/brief_extract.py`, `tests/spec/test_brief_extract.py`.

**Learnings:**

- **The confidence table has three states, but the AC's signature has two.**
  `confidence_for(kind, span_verified: bool, template_prior=None)` cannot express
  *span claimed and not found* — that is only knowable to a caller that saw whether a
  span was offered at all. So `confidence_for` implements the AC's table exactly
  (0.9/0.6) and the 0.3 hallucination penalty lives one layer up in
  `score_extraction`. Do **not** widen `span_verified` to a tri-state later; the split
  is what keeps the pure table pure. `CONFIDENCE_UNVERIFIABLE_SPAN < 
  CONFIDENCE_EXTRACTED_UNVERIFIED` is asserted as a policy test: claiming evidence
  that does not exist is worse than claiming none.
- **`verify_span("")` must return False, and this is the whole check's hinge.**
  `"" in text` is True for every text, so a naive containment implementation awards
  the *highest* confidence in the table to a model that offered no evidence at all —
  exactly inverting what the function exists to detect. A whitespace-only span is
  routed to the *paraphrase* branch (0.6, no flag), not the fabrication branch: the
  model offered nothing, it did not offer something false.
- **Documents are verified one at a time, never as a joined corpus.** Joining lets a
  span "verify" by straddling a file boundary — matching text that exists in no real
  document. `verify_span_in_documents` is an `any()` over per-document checks.
- **The `source` locator is deliberately not resolved to pick a document.** A locator
  is prose the model wrote (`"prd.md §2.1"`); resolving it would fail a quote that is
  genuinely present because the section reference was off by one, which punishes the
  wrong error. The question worth asking is "did the model invent this text?", and
  that is corpus-wide.
- **Normalisation is whitespace-only, on purpose.** Case, punctuation and typographic
  quotes stay significant. A model that smart-quotes a real quote will therefore score
  0.3 + `needs_review` — a false negative, which is the *safe* direction (a human
  looks). A false positive would silently launder a fabrication. If field data later
  shows quote-mangling is the dominant failure, widen it then, with evidence.
- **DEFERRED, deliberately: no confidence-floor parameter on `score_extraction`.**
  `brief.gap_check` applies `policies.confidence_floor` itself because it holds the
  brief; `score_extraction` does not, and adding a floor param would give the harness
  two places that apply the same threshold. **US-012 owns the floor** and should apply
  it once, over the whole brief, after merge. Flag this if US-012's design disagrees.
- **US-011 consumes this as:** `[score_extraction(e, docs) for e in parse_*(reply)]`,
  then merges each `(Extraction, Provenance)` pair into the brief via `brief_merge`.
  No batch helper was written — a speculative one would have guessed the pairing shape
  US-011 actually needs.
- **`brief.py`'s module docstring already forward-referenced `brief_extract.confidence_for`**
  (written in US-001). That reference now resolves. Import direction is
  `brief_extract → brief`, matching `brief_defaults → brief`; `brief.py` imports no
  sibling, so there is no cycle.

**Gate (foreground, this iteration):** `uv run pytest tests/ -q` → **5841 passed,
3 skipped, 1 failed** in 305s. The one failure is
`tests/agent/test_toolchain.py::TestWiring::test_gate_subprocess_gets_toolchain_path`,
**verified pre-existing**: it also fails at `HEAD~1` in a detached scratch worktree at
`/tmp/cf-check`. Cause is environmental and macOS-specific — `monkeypatch.setenv("HOME",
str(tmp_path))` seeds an unresolved `/var/folders/…` path while `str(cargo)` is the
resolved `/private/var/folders/…`, so the assertion compares the two sides of the
`/var` → `/private/var` symlink. Nothing to do with this story; it is the same failure
earlier entries described as "PATH-shape dependent on this box".
`ruff check claw_forge/ tests/` clean. `mypy claw_forge/ --ignore-missing-imports` →
193 files, clean. Module coverage 100% branch.
---

## 2026-08-19 — US-011 · claw-forge brief extract <docs...> CLI

Added the extraction orchestrator to `claw_forge/spec/brief_extract.py`
(`build_passes`, `extraction_to_brief`, `assign_question_ids`,
`merge_extractions`, `run_extraction`, `anthropic_caller`, `read_documents`)
and the `brief extract` command to `claw_forge/spec/cli.py`. 37 new tests;
**100% branch coverage on `brief_extract.py`**. Full suite: 5878 passed, 1
pre-existing failure (see below).

Files: `claw_forge/spec/brief_extract.py`, `claw_forge/spec/cli.py`,
`claw_forge/spec/brief.py`, `tests/spec/test_brief_extract_run.py`,
`tests/spec/test_brief_extract.py`.

**Learnings:**

- **PLAN GAP (resolved, declare it): the design doc's module layout gives
  `brief_extract.py` as "pure prompt-builders + pure parsers" and lists the
  orchestration nowhere but `cli.py`.** Putting a testable `run_extraction`
  in a Typer command body is not testable in the way the AC demands ("the
  model call is injected so tests can stub it"). Resolved by following the
  precedent the design doc *itself* names as the thing to copy: `validator.py`
  holds the pure `SpecEvaluator` **and** the impure `run_llm_evaluation` in
  one module. `brief_extract.py` now has the same shape — one impure factory
  (`anthropic_caller`), everything else pure. The module docstring was
  amended to say so; it previously claimed "Nothing here makes a network
  call", which would have become a lie.

- **A section's single `_provenance` forces a single-leaf fold.**
  `Identity` carries one `Provenance` across `name` *and* `domain`. Building
  one incoming brief per pass would make `_weakest_provenance` flatten a
  verified 0.9 name onto an unverifiable 0.3 domain — destroying exactly the
  per-field confidence the four-narrow-passes design exists to produce. So
  `merge_extractions` folds **one single-leaf brief per extraction**, chaining
  `merge_brief`. Each fold computes that section's honest provenance at that
  step. Anyone extending this (US-012's gate, US-013's repair) must keep the
  fold granular; batching by section silently degrades confidence.

- **Empty competency-question ids append unconditionally.**
  `brief.py:_merge_competency_questions` ends with
  `merged.extend(... for q in incoming if not q.id or q.id not in by_id)` —
  so an extracted question with no id is appended *every run*. Two runs over
  the same PRD would have produced duplicate questions. `assign_question_ids`
  matches incoming questions to existing ones by **normalized text** (the
  same rule `_merge_constraints` already uses, since a question has no other
  natural key) and allocates `cq-N` only for genuinely new ones. Idempotency
  is asserted by `test_rerunning_extraction_does_not_duplicate_questions`.
  `_next_question_id` was made public (`next_question_id`) because there are
  now two allocators and a second private copy is how they start colliding.

- **A guard test can be right about the proposition and wrong about the
  encoding.** US-009's `test_no_parser_imports_a_network_client` grepped the
  source for the literal `"import anthropic"`. Its own comment says the
  concern is a *module-level* import — but a substring search cannot tell a
  module-level import from a function-local one, so US-011 could only have
  deleted it. Rewritten as an AST check: no banned import at module scope,
  and no function except `anthropic_caller` may import one. Proven
  non-vacuous against two planted violations before committing. **When a
  guard test blocks legitimate work, tighten it to its real proposition;
  deleting it and widening it are both review failures.**

- **ruff B008 fires on `typer.Argument` in a `list[str]` default but not a
  `str` one.** Every other `typer.Argument(` in the repo annotates a `str`,
  which ruff treats as immutable and exempts. A variadic `docs: list[str]`
  argument needs a module-level singleton (`_DOCS_ARGUMENT`). This will bite
  again the next time someone adds a list-valued CLI argument.

- **Pre-existing failure re-verified, not assumed.**
  `test_gate_subprocess_gets_toolchain_path` fails on `main` in a detached
  `/tmp/cf-check` worktree with the identical `/var` vs `/private/var`
  assertion US-010 recorded. One command, and it keeps the "1 failed" line in
  the suite output from quietly covering a real regression.

---

## 2026-08-19 — US-013 · /fix-spec --autonomous, policies drive repair

Added the **deferral queue** to `claw_forge/spec/brief.py` — `Deferral`,
`deferral_to_dict`/`deferral_from_dict`, the pure `dump_deferrals`, the thin
`write_deferrals`, and `read_deferrals`, plus `DEFERRALS_FILENAME`,
`DEFERRALS_KEY` and `AUTONOMOUS_DEFERRAL_REASON` — and rewrote
`.claude/commands/fix-spec.md` around it: a **Modes** section, the
`manual_review` / `gap13_repair` policy tables, a widen-vs-complete-partition
explainer replacing the hardcoded Gap 13 (mix) rule, and **Step 7a** where the
Manual Review block is written to `deferred_decisions.yaml` and the command
continues. 40 new tests; the deferral section has no uncovered lines.

Files: `claw_forge/spec/brief.py`, `.claude/commands/fix-spec.md`,
`tests/spec/test_brief_deferrals.py`.

**Learnings:**

- **`Policies()` defaults would have broken the back-compat floor silently.**
  `manual_review` defaults to `default-and-record`, so the obvious reading of the
  AC — "check the policy, then decide whether to ask" — makes *plain* `/fix-spec`
  with no brief on disk stop asking, because the absent brief still yields that
  default. Non-negotiable #1 says no brief ⇒ today's behavior. Resolved by making
  **the flag select the mode and the brief only able to veto it** (`halt` outranks
  `--autonomous`): "no brief + no flag ⇒ unchanged" is then true by construction
  rather than by a conditional a later edit can drop. Any story that reads a policy
  to decide whether to block should check this shape first — a conservative default
  that is *also* the permissive value is a trap.

- **The pure/impure split is the only way to satisfy "the writer is pure".**
  A function that writes a file is not pure, so the AC is only readable as the
  repo's existing pair: `dump_brief`/`save_brief`, `dump_decisions`/`write_decisions`.
  `dump_deferrals` holds all the formatting and every formatting test calls it with
  no `tmp_path` at all; `write_deferrals` is three lines. Reach for this pair the
  next time an AC names one writer.

- **Two artifacts, two formats, one deliberate reason.** `spec_decisions.jsonl` is
  JSONL because it is machine-appended and grepped; `deferred_decisions.yaml` is
  YAML because a human reads it, edits the spec, and re-runs. Same subsystem — do
  not "unify" them later without answering who reads each one.

- **A queue that degrades to `[]` is worse than one that raises.** `read_deferrals`
  returns `[]` for an *absent* file (the `load_brief` floor) but raises `BriefError`
  for a malformed one, because "nothing deferred" and "I could not read the file"
  are indistinguishable to the caller, and the entries swallowed are exactly the
  judgment calls a human still owes. This is the one place in the run where
  non-negotiable #3 ("nothing new may block") does **not** mean "return something
  usable" — the empty-vs-unreadable distinction wins.

- **Write the empty queue.** `dump_deferrals([])` emits `deferrals: []`, not "".
  An absent file means *autonomous repair never ran*; an empty list means *it ran and
  deferred nothing*. `dump_decisions([])` returns "" for the opposite reason (an
  append-shaped file must not gain a stray newline) — the asymmetry is intentional.

- **Hand-written tolerance is scoped, not general.** `read_deferrals` accepts a bare
  list as well as the canonical mapping, and `candidates:` written as a bare string
  becomes one candidate rather than a list of characters — both because fix-spec.md
  documents the schema for an *agent writing YAML by hand*. It does not tolerate a
  scalar document or a non-mapping entry; those are typos, not shorthand.

- **DECLARED GAP: no CLI command writes the queue.** US-013's AC names the writer and
  the doc, not a command, and `/fix-spec` is a markdown command an agent executes — so
  the agent writes the YAML following the documented schema, and `read_deferrals` is
  the canonical way to read it back. If a future story wants `claw-forge brief defer`,
  note it also needs no `telemetry/vocab.py` change (`brief` is already registered).

- **PROCESS GAP (not mine, worth recording): US-012 shipped with no progress.txt
  entry.** Commit `05f5e14` landed the confirm gate and `c55192e` marked it passing,
  but neither appended to this log, so the gate's design reasoning exists only in
  `brief.py`'s docstrings and `create-spec.md`. US-013 read those to find
  `deferral_decisions`. If you are the next iteration and something upstream looks
  undocumented, read the module docstrings before assuming it is undone.

**Gate (foreground, this iteration):** `uv run pytest tests/ -q` → **5976 passed,
3 skipped, 1 failed** in 243s. The one failure is the same
`tests/agent/test_toolchain.py::TestWiring::test_gate_subprocess_gets_toolchain_path`
US-010 and US-011 both proved pre-existing in a detached scratch worktree
(macOS `/var` vs `/private/var` under `monkeypatch.setenv("HOME", tmp_path)`).
`ruff check claw_forge/ tests/` clean. `mypy claw_forge/ --ignore-missing-imports`
→ 193 files, clean.
---

## For US-014 (the last story) — what this run actually named

`CLAUDE.md`'s new "Spec Brief" subsection will name locations for symbols, and
`scripts/gen_module_map.py:ANCHOR_SYMBOLS` must gain an entry for each one it names
(then regenerate `docs/module-map.md`; `tests/test_module_map.py` pins it). The
symbols this run created, by module:

- `claw_forge/spec/brief.py` — `SpecBrief`, `Provenance`, `merge_brief`,
  `build_initial_brief`, `gate_items`, `deferral_decisions`, `write_decisions`,
  `write_deferrals`, `read_deferrals`
- `claw_forge/spec/brief_defaults.py` — `POLICY_DEFAULTS`, `DOMAIN_REGISTRY`,
  `register_domain_template`
- `claw_forge/spec/brief_extract.py` — `confidence_for`, `verify_span`,
  `score_extraction`, `run_extraction`
- `claw_forge/spec/brief_validate.py` — `check_brief`
- `claw_forge/spec/cli.py` — `brief init|extract|check|gate` commands

Artifacts to document by name: `spec_brief.yaml` (`BRIEF_FILENAME`),
`spec_decisions.jsonl` (`DECISIONS_FILENAME`), `deferred_decisions.yaml`
(`DEFERRALS_FILENAME`). Only anchor what CLAUDE.md ends up naming — the anchor list
is curated on purpose, and an anchor for a symbol no prose points at is noise.
---

## 2026-08-19 — US-014 · Documentation closure and full gate

Closed the docs surface for the spec brief across every place this repo requires,
and ran the full gate.

- **README.md** — new `### The spec brief — resolve decisions before synthesis`
  section (the five-step workflow, the provenance→confidence table, the tier
  floors, the degradation guarantees), Option 4 rewritten as a **three-mode**
  table (Brief-Driven / Greenfield / Brownfield) with the back-compat floor
  stated at the point a reader meets the command, and Quick Start step 4 grown
  a non-interactive Option C.
- **docs/commands.md** — `### claw-forge brief` before `plan`: a subcommand
  table, a flag table per subcommand, explicit exit codes, the five `brief check`
  validations *with severities*, the provenance table, and the three-artifact
  table (who writes it, who reads it, absent-vs-malformed).
- **CLAUDE.md** — `### Spec Brief` under Subsystems, ahead of Git Workspace.
  Ten bullets, each one a thing a change can break: the floor, absent-vs-
  malformed, `PROVENANCE_ORDER` as declared data, confidence-from-provenance
  including the 0.3 third state, decisions-never-content, the single-leaf fold,
  flag-selects/policy-vetoes, gate-prints-never-writes with the
  `dump_deferrals([])` / `dump_decisions([])` asymmetry, init-is-offline, and
  "do not add a second `_invariant.py`".
- **scripts/gen_module_map.py** — extended + 8 new anchors; `docs/module-map.md`
  regenerated.
- **handbook** — new bilingual section in ch.08 `spec-basics`, `book.yaml`
  watches `claw_forge/spec/brief.py`, chapter re-baselined; ch.20 regenerated
  (it picked up all four `brief` commands with no renderer change).

Files: `README.md`, `docs/commands.md`, `CLAUDE.md`, `docs/module-map.md`,
`scripts/gen_module_map.py`, `tests/test_module_map.py`, `handbook/book.yaml`,
`handbook/{en,zh}/08-spec-basics.md`, `handbook/{en,zh}/20-reference-commands.md`,
`handbook/tools/drift.lock.json`.

**Learnings:**

- **`ANCHOR_SYMBOLS` could not express half of what CLAUDE.md names.**
  `anchor_rows` matched only `ast.FunctionDef`/`AsyncFunctionDef`, so the
  symbols US-013 nominated — `SpecBrief` (a dataclass), `POLICY_DEFAULTS` /
  `DOMAIN_REGISTRY` (module constants) — would have *failed generation*, not
  merely gone unlisted. Extended to `ClassDef` and module-scope assignments.
  **Constants only at module scope**, deliberately: `ast.walk` over assignments
  resolves a name to every module that uses it as a local, which produces a
  confidently wrong location — the exact failure the map replaced. Proven by
  `test_a_function_local_constant_is_not_a_definition`.

- **A drift warning was hiding a real wrong claim.** `check` reported
  `spec-basics: claw_forge/spec/parser.py changed since "Spec Basics" was last
  confirmed`. Reading the chapter found it stating `shape` accepts `plugin` or
  `core` and that "any other non-empty value is a parse error" — but
  `parser.py:396` has accepted **`integration`** since the
  integration-counterweight run, and the synthetic e2e task ships as one. Fixed
  in both editions before accepting. The AC's "re-baseline only chapters a human
  would re-read" is not ceremony: accepting that chapter blind would have
  laundered a false statement into a confirmed one.

- **`accept --chapter <id>` is all-or-nothing across a chapter's sources.**
  Adding `brief.py` to `spec-basics`'s `watches:` made it `[unbaselined]`, and
  the only way to clear that also re-baselines `parser.py` and
  `app_spec.template.xml`. So adding a watch commits you to reading the whole
  chapter. The other 34 warnings were left alone on purpose — they are
  pre-existing drift on chapters this branch neither touched nor re-read
  (`layouts.py`, `token_meter.py`, `permissions.py`, the zh telemetry
  structure-mismatch), and clearing them would be a claim nobody made.

- **The generated reference chapter needed no work.** Ch.20 is built from the
  live Typer tree, so `brief init|extract|check|gate` — arguments, options,
  defaults, help text — appeared from `gen.py build` alone. The corollary for
  future CLI stories: **write the docstring and the `help=` strings as the
  user-facing copy**, because they *are* the handbook page.

- **Docs assert behavior, so verify them like code.** The first draft of the
  `brief check` section listed "unknown provenance kinds, out-of-range
  confidences, unrecognized tiers…" from memory. Reading `brief_validate.py`
  showed two of those do not exist (there is no tier check; confidences are not
  range-checked) and that the competency check fires on *no question at priority
  1*, not on a missing priority. Replaced with a severity table read off
  `_CHECKS`. Every number in the README (0.75 floor, tier values, domain names)
  was likewise checked against the module, not recalled.

- **`brief gate` exists and the US-014 AC does not name it.** The AC says
  `brief init|extract|check`; US-012 shipped a fourth subcommand. Documented all
  four — an undocumented command is the drift this story exists to close.

**Final gate (foreground, this iteration):**

| Check | Result |
|---|---|
| `uv run pytest tests/ -q --cov=claw_forge` | **5978 passed, 3 skipped, 1 failed** in 303.58s |
| Coverage (branch) | **93.48%** — gate is 90% ✓ |
| `uv run ruff check claw_forge/ tests/` | **clean** (both paths) |
| `uv run mypy claw_forge/ --ignore-missing-imports` | **clean**, 193 source files |
| `handbook/tools/gen.py build` | 0 files written (idempotent after commit) |
| `handbook/tools/gen.py check` | **exit 0**, 34 non-blocking warnings (was 35) |

The single failure is
`tests/agent/test_toolchain.py::TestWiring::test_gate_subprocess_gets_toolchain_path`,
recorded pre-existing at US-010, US-011 and US-013. Re-verified for this story by
a different route than the scratch worktree (which fails to resolve deps under a
bare `uv run` from `/tmp`): `git diff --stat main...HEAD` is **empty** for both
`tests/agent/test_toolchain.py` and `claw_forge/agent/toolchain.py`, so the code
under test is byte-identical to `main`. macOS `/var` vs `/private/var` under
`monkeypatch.setenv("HOME", tmp_path)`.

**ALL 14 STORIES PASS.**
---
