Metadata-Version: 2.4
Name: nspec
Version: 3.20.0
Summary: Feature request and implementation specification management tool
License: MIT
License-File: LICENSE
Keywords: requirements,specifications,project-management,validation,cli
Author: Novabuiltdevv
Requires-Python: >=3.11,<4.0
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Software Development :: Quality Assurance
Provides-Extra: completion
Provides-Extra: falkordb
Provides-Extra: terminusdb
Provides-Extra: yaml
Requires-Dist: argcomplete (>=3.0,<4.0) ; extra == "completion"
Requires-Dist: cryptography (>=48.0.1)
Requires-Dist: falkordb (>=1.0,<2.0) ; extra == "falkordb"
Requires-Dist: idna (>=3.15)
Requires-Dist: jinja2 (>=3.1,<4.0)
Requires-Dist: jsonschema (>=4.20,<5.0)
Requires-Dist: mcp (>=1.0,<2.0)
Requires-Dist: pyjwt (>=2.13.0)
Requires-Dist: pymdown-extensions (>=11.0.0)
Requires-Dist: python-multipart (>=0.0.31)
Requires-Dist: pyyaml (>=6.0,<7.0) ; extra == "yaml"
Requires-Dist: starlette (>=1.3.1)
Requires-Dist: terminusdb-client (>=10.2,<11.0) ; extra == "terminusdb"
Requires-Dist: textual (>=7.4,<8.0)
Requires-Dist: urllib3 (>=2.7.0)
Project-URL: Documentation, https://novabuilt.dev/nspec/
Project-URL: Homepage, https://novabuilt.dev/blog/getting-started-with-nspec/
Project-URL: Repository, https://github.com/Novabuiltdevv/nspec-issues
Description-Content-Type: text/markdown

# nspec

[![PyPI version](https://img.shields.io/pypi/v/nspec.svg)](https://pypi.org/project/nspec/)
[![Python 3.11+](https://img.shields.io/pypi/pyversions/nspec.svg)](https://pypi.org/project/nspec/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Docs](https://img.shields.io/badge/docs-mkdocs-blue)](https://novabuilt.dev/nspec/)

> Specification-driven project management for AI-native development

**nspec** turns your backlog into structured markdown specs that AI coding assistants can read, execute, and update. It pairs every feature request (FR) with an implementation spec (IMPL), validates the entire graph, and exposes an MCP server so Claude Code (or any MCP-compatible agent) can autonomously pick up work, track tasks, run reviews, and advance specs through their lifecycle.

## Install

```bash
pip install nspec
```

Also available via `pipx`, `uv`, and `poetry`. See the [Getting Started guide](https://novabuilt.dev/nspec/getting-started/).

## Quick Start

```bash
nspec init              # Scaffold project (auto-detects your stack)
nspec mcp-config        # Generate MCP server config
nspec spec create --title "My feature" --priority P1
nspec validate          # Run 6-layer validation
nspec tui               # Interactive terminal UI
```

With the MCP server configured, Claude Code can autonomously work your backlog:

```
/ngo S001       — Work that specific spec
/ngo E006       — Scope to an epic: activate it, then work its next unblocked spec
/ngo            — Resolve from session state (active spec, else active epic, else backlog)
/nbacklog       — View the prioritized backlog
/nloop          — Autonomous mode: pick, execute, review, complete, repeat
```

**An argument always wins over session state, and an unrecognized one is an error.**
`/ngo E006` works E006 whatever epic happens to be active. Anything matching neither a spec
nor an epic id fails immediately, naming the accepted forms — it used to be silently
discarded, which meant `/ngo E006` quietly worked the wrong epic, or the whole backlog
unfiltered, and said nothing (S1052). A silently-wrong scope is worse than a refusal,
because the caller believes the run was scoped.

At loop entry, `/nloop` seeds itself once with epic-wide **FR intent** via a
loop-entry `seed_context` step (the `epic_fr_context` tool): it reads every
*non-terminal* member spec's Goal/Scope/Acceptance Criteria — so even un-started specs
contribute — and grounds the whole run in what the epic *intends*, the FR-side complement
to `epic_context`'s per-spec *delivered* outcomes. The payload is bounded (S1057):
Completed/Superseded members are excluded (their delivered outcomes are `epic_context`'s
job), each digest caps at `[loop] fr_intent_max_chars`, the response reports
included-vs-total membership, and a payload still over `[loop] fr_context_max_chars`
fails loudly rather than returning a silently shortened list — a mature epic's 65-of-71
terminal majority once produced a 175k-char payload that broke the seed step outright.

## Features

- **FR/IMPL Pairing** — Every feature request gets a matching implementation spec with hierarchical tasks and acceptance criteria
- **6-Layer Validation** — Format, pairing, dependencies, business logic, and ordering checks
- **MCP Server** — 99 tools over stdio, SSE, and HTTP transports for AI assistant integration
- **Interactive TUI** — Sortable table, detail panel, vim keybindings, search, follow mode, live reload, and in-terminal dependency-graph visualization (expand upstream/downstream, jump between linked specs)
- **Dependency Graph** — Circular reference detection, cross-epic ordering, priority inheritance
- **Skills System** — 29 slash commands for Claude Code (spec creation, review, triage, autonomous loops) plus Codex prompt templates
- **Multi-Agent Queue** — Parallel spec execution with git-worktree isolation, atomic claim/release, and lease-based recovery
- **Checkout & Reservation** — TTL-based spec leases prevent double-assignment across agents
- **GitHub Issue Integration** — Import issues as specs and sync status back to GitHub on completion
- **Code Review** — Automated review against spec criteria via configurable external agents (Codex, Gemini)
- **Engineering Metrics** — Velocity, quality, DORA metrics, and activity heatmap

### Priority Model

- **Epic priorities are global and unique.** Epics rank against each other in one
  contiguous order (P1, P2, P3, …). Changing an epic's priority cascade-inserts it —
  the others shift up or down to keep every slot unique.
- **Spec priorities (P0–P3) are scoped to their epic.** A spec's priority only orders it
  against its siblings *within the same epic* — it is contained there and has no meaning
  across epics. Two specs may share a priority; ties break deterministically by spec ID.
  IDs are `S`/`E` + a zero-padded number (`S001`); the 3-digit space is a floor, not a cap —
  once it fills, IDs widen to four digits (`S1000`–`S9999`) automatically, and the allocator
  fails loud rather than ever reusing an ID.
  Changing a spec's priority bumps its epic siblings to make room (best-effort: because
  the P0–P3 range is smaller than the number of specs an epic can hold, siblings pile up
  at the floor priority once the range is exhausted rather than overflowing past it).
- **Dependency order is the default sort, and it ignores P-numbers.** The TUI's default
  `sort:dependency` orders specs by a topological sort of their `deps`, not by priority.
  Press `o` in the TUI to cycle the sort mode — `dependency → pickup → priority → status
  → id → alpha` — and `O` to reverse the direction; the status bar shows the current mode
  with its key hint (`sort:dependency▲ (o)`).
  A spec with no dependency edges to its siblings is an *orphan* and falls to the end of
  its epic, ordered only by its (often default) priority. To make the dependency graph
  authoritative, enable the optional gate `[validation] enforce_epic_dependency_chain =
  true`: validation then fails unless every epic's active specs form one connected
  dependency chain (branches are fine; disconnected islands and orphans are not). Wire
  specs together with `add_dep` to satisfy it.
- **A priority write is durable, and the response tells you what moved.** The value
  `set_priority` reports is the value on disk — it survives a cache reload, and later
  unrelated mutations do not revert it. Because a single call can also shift epic siblings
  (the bump above) and pull dependents along, the response carries a `changed` list naming
  **every** spec it moved, not just the one you asked for:

  ```jsonc
  {
    "success": true, "spec_id": "S036", "priority": "P2",
    "changed": [
      {"spec_id": "S006", "from": "P2", "to": "P1"},   // sibling shifted to make room
      {"spec_id": "S036", "from": "P1", "to": "P2"}    // the spec you named
    ],
    "collateral_count": 1
  }
  ```
- **Spec `priority` is an authored input, never derived (S1048).** It used to be both: a
  post-write pass recomputed each dependency's priority from its dependents and wrote it
  back, so a value you set could be silently changed by a later write. That derivation is
  removed — `set_priority` writes only the spec you name and its epic siblings. The
  dependency constraint is still enforced, but by *refusal*: a downgrade blocked by a
  higher-priority dependent fails loudly rather than being silently repaired. Moving a spec
  never rewrites its priority either, on any path.
- **Epic stack rank leads the ordering (S1049).** The eligibility sort is
  `(epic_rank, spec_priority, in_progress, dep_order)`. Epic rank previously did not appear at
  all — it was only a gate — so a P9 epic's P1 specs sorted level with a P1 epic's P1 specs and
  the backlog could not answer "what should I work on now" across epics. Epic rank is the
  roadmap; spec priority is local policy within one epic. A spec with no epic sorts after every
  epic-owned spec regardless of its own priority — an unplaced spec has no roadmap position.
- **Downgrading a whole dependent chain: `cascade_dependents=True`.** A downgrade blocked by
  a higher-priority dependent is refused by default, naming the blockers. Undoing a chain then
  needs one call per link in exact leaf-first order. Pass `cascade_dependents=true` to do it in
  one call — the chain is walked and lowered leaf-first, and the whole plan is validated before
  anything is written, so a cascade that cannot succeed leaves nothing behind.

- **The model behind all of this is written up in [`docs/design/priority-and-deps-model.md`](docs/design/priority-and-deps-model.md)** (S1036): why `priority` is an authored input and not a derived value, where the `child <= parent` invariant is enforced, and the one-sentence rule for "what do I work on next".

### The CI watcher ships with nspec

`/ngo` and `/nloop` invoke `.novabuilt.dev/nspec/hooks/ci-watch.sh` after every push. It is
installed by `nspec init` (and any hooks sync) as a **utility script**: unlike the workflow and
guard hooks it has no triggering event, so it is placed on disk and synced to agent hook
directories but never registered in `settings.json`.

Exit codes matter, because a watcher that reports success on an unfinished run is worse than none:

| Code | Meaning |
|---|---|
| `0` | at least one run matched the pushed commit and **every** matched run concluded `success` |
| `1` | a matched run concluded non-success, or the repo is not gh-linked |
| `2` | timed out — no matched run ever concluded, including never appearing |

Runs are pinned to the pushed commit SHA rather than a sliding time window, so a run that stays
queued past the poll window stays visible; an empty result means "not created yet" and keeps
polling rather than short-circuiting to `0`.

`nspec hooks doctor` reports any hook a shipped skill references that is missing or unpackaged —
the check that would have caught `ci-watch.sh` being referenced everywhere and shipped nowhere.

### Choosing a spec or epic ID

IDs are normally handed out sequentially, but `create_spec(..., spec_number=47)` —
`nspec spec create --spec-number 47 --type epic` — places one at a **chosen** number:

```bash
nspec spec create --title "Process" --type epic --spec-number 47   # -> E047
```

This matters most for epics, because the ID an epic is born with is the one it keeps:
retitling is ID-stable and archived children cannot be reparented, so a mis-numbered epic
with completed work cannot be renumbered later. Choosing up front is the only cheap moment.

An explicit ID goes through the **same** gate as an auto-allocated one — range check, the
taken-set scan across active *and* archived specs, and the reservation ledger, all under one
lock — and **fails** rather than sliding to the next free number, so you never silently get a
different ID than you asked for. Omit the argument for unchanged sequential behaviour.

`nspec doctor` additionally warns when `[id_ranges]` blocks overlap or are inverted. It is
advisory and never fails the run; it stays quiet on the shipped defaults, where the epic range
nests inside the spec range on purpose.

### Epic FR contract

Every epic carries a PRD-style contract so a reader can tell what it is for and when it is
done without reverse-engineering it from child specs. `create_spec(is_epic=True)` scaffolds
the FR from a dedicated **epic template** (`src/nspec/resources/templates/epic/fr.md`) with
these sections: **Goal**, **Why It Matters**, **Scope** (In / Out), **Definition of
Complete**, **Child Specs**, and **Acceptance Criteria**. Definition-of-Complete items are
plain bullets describing the epic's end-state; only Acceptance Criteria are tracked
checkboxes, so per-epic progress accounting is unchanged. A project can override the
template by dropping its own copy at `.novabuilt.dev/nspec/templates/epic/fr.md`, which
wins over the builtin.

### Epic-level review

`/nreview-epic <epic>` batch-reviews every child spec of an epic. It **reuses the
single-spec review machinery** rather than re-implementing it: for each child it runs the
same chain `/ngo` and `/nreview` use — `review_spec` (mints the review prompt file) →
`execute_agent` (dispatches to the external review agent, Codex/Gemini) →
`write_review_verdict` (persists the verdict + a Review History row into the child's IMPL).
So an epic review leaves the same durable per-spec audit trail a single-spec review does —
not an ephemeral console table. It **does not advance any spec's lifecycle status** (it
records verdicts only) and has **no self-review path** — the external agent always reviews,
never the implementing agent. `--converge` wraps the per-spec chain in an iteration loop
that surfaces cross-cutting themes (issues spanning 2+ specs) and drives atomic multi-file
fixes until the epic's specs stop churning.

### Running without reviews

Projects with no reviewable build artifact — an infrastructure repo whose
changes are converged state rather than a diff — can turn reviews off honestly:

```toml
[review]
enabled = false
waive_reason = "infrastructure repo — no reviewable build artifact yet"
```

Completions then record a signed `WAIVED — <reason>` verdict instead of an
approval, so the audit trail says plainly that review was waived and why.
`waive_reason` is required: disabling reviews without one fails at config load.
A waiver excuses the *review*, not the work — acceptance criteria and tasks must
still be complete — and no agent can produce a `WAIVED` verdict, so it can't be
used to rubber-stamp. Delete the two lines to re-enable.

### Knowing what the reviewer actually saw — `range_covered_commits`

On `main`, `review()` does not diff against the branch — it resolves the spec's *own*
commits by their `Refs: <spec>` trailer and diffs that span. Every review response now
reports what that span covered:

- **`range_covered_commits`** — the short SHAs the reviewed diff actually contained.
- **`range_source`** — `refs` (trailer-backed, trustworthy) or `file-paths` (inferred, suspect).
- **`range_warning`** — set when the range is untrustworthy **or incomplete**.

The incomplete case is worth knowing about because it is invisible otherwise. A commit
whose trailer names two specs — `Refs: S1023, S1058`, exactly what you write when one
commit implements a spec and splits out a follow-up — used to match *neither*, so it was
dropped from both spans and the reviewer silently received a diff missing that work while
`range_source` still said `refs`. The reviewer then reports findings against code that is
already fixed, and since it re-derives them each round they regenerate forever. That cost
three review rounds on S1023 before anyone opened the prompt file (S1059).

Multi-spec trailers now attribute correctly (comma, semicolon or space separated), and if
any commit naming the spec still falls outside the computed range, `range_warning` names
those SHAs. **If you get a finding you believe is already fixed, check
`range_covered_commits` before arguing with the reviewer** — it may simply not have been
shown your fix.

### Scoping the review diff — `diff_exclude` / `diff_include`

The reviewer sees a filtered diff. Both keys are live configuration under `[review]`:

```toml
[review]
# Paths hidden from the reviewer. Default:
#   [".novabuilt.dev/", ".claude/", "poetry.lock", "work/"]
diff_exclude = [".claude/", "poetry.lock", "work/"]
# Paths the diff is restricted to. Empty (the default) means all paths.
diff_include = []
```

The default excludes `.novabuilt.dev/`, which is right for most repos — churn in nspec's own
state is noise. But it means **a spec whose deliverable *is* nspec configuration** (a template
override, a skill override, a `config.toml` change) shows the reviewer an empty diff, and the
reviewer is instructed to fail what it cannot see. Drop `.novabuilt.dev/` from `diff_exclude`
for those repos so the change is reviewable.

Both keys must be lists of strings; a scalar or a non-string element fails at config load
naming the key, rather than silently falling back to the default.

### Retry budgets — every gate has a cap and a terminal route

A retry counter that lives only in agent context is not a budget: a fresh `/ngo` restarts it at
zero, so the cap bounds attempts *per invocation* and nothing bounds the total. S1031 moved every
pipeline budget onto disk — `work/specs/<id>/gate-state.json`, read and written through the
`gate_attempts(spec_id, gate, action=...)` tool.

| Gate | Config key | Default | Route when exhausted |
|---|---|---|---|
| `review` | `[review] max_retries` | 5 | park, naming the remaining remediation tasks |
| `qa_diff` | `[loop] qa_diff_max_retries` | 2 | record the delta in the IMPL, submit it to the reviewer, proceed |
| `authoring` | `[loop] authoring_max_attempts` | 2 | park — FR still a skeleton |
| `refinement` | `[loop] refinement_max_attempts` | 2 | park — IMPL still a skeleton |

Three rules make these honest:

- **Increment before dispatch, not after.** A gate that hangs must still burn its attempt,
  otherwise the one failure mode that most needs a bound escapes it.
- **Clear on success.** The budget is for *consecutive* failures, so a success must not leave the
  spec one attempt from a park on its next unrelated failure.
- **No silent truncation.** Every cap emits a line naming the gate, the attempt count and the
  route taken.

`qa_diff` is the one gate whose route is *not* a park, deliberately: it compares against a moving
baseline, so an increase intrinsic to the change can never be satisfied by editing. Looping on it
is the failure, not the fix — the delta goes to the reviewer as a judgement call.

Relatedly, `/nreview`'s four error paths (missing CLI, timeout, unparseable verdict, empty
response) now all `park` before exiting. Previously they exited with the spec still at Testing and
no verdict, and since `eligible_next_specs` excludes only completed/ready/paused/exception,
`next_spec` re-offered it and the identical failure recurred.

### Disputing a reviewer finding — `dispute_finding`

Review rounds were asymmetric: a PASS carried forward as durable state while a FAIL was recomputed
from scratch. So a finding that was wrong about **the world** rather than about the code
regenerated identically every round, and the implementer had two options — comply and corrupt
correct code, or re-dispatch unchanged and burn the retry budget.

`dispute_finding(spec_id, criteria_id, evidence_command, evidence_output, claim)` records the
disagreement instead. The criterion moves to **contested**, and the next round's prompt presents
your command, its output and your claim, requiring the reviewer to answer:

- **`withdrawn`** — the evidence refutes the finding. It becomes a pass and stops blocking.
- **`upheld`** — with a reason that engages with the evidence. A verbatim re-issue of the original
  finding is rejected, because that is exactly what a stateless reviewer produces by default.

Three properties keep this honest rather than an escape hatch:

- **Contested still blocks.** An unanswered dispute is not a resolved dispute, so the
  approval-evidence gate keeps failing until the reviewer answers.
- **Evidence is mandatory.** All three of command, output and claim must be non-empty; you cannot
  contest a finding by assertion.
- **It is bounded.** A finding upheld twice over evidence escalates to the operator-only impasse
  path rather than consuming further rounds.

A finding whose remediation task named no file to change is flagged in the prompt: it cannot be
re-issued unchanged, since there is nothing to action. FAIL entries now also carry their
originating round (`first_failed_round`, `fail_count`), so a regenerating finding is
distinguishable from a fresh one.

### Why can't I proceed? — `next_spec`'s discriminant

`next_spec` always returns a `reason`, so "nothing to do" is never ambiguous:

| `reason` | `next` | Meaning |
|---|---|---|
| `ok` | the spec | Work it |
| `epic_complete` | `null` | Every member finished — safe to open a completion PR |
| `no_specs` | `null` | The scope holds no specs at all |
| `all_blocked` | `null` | Specs remain and **every one is blocked** |

On `all_blocked` the response carries a `blocked` list — one entry per unworkable spec with
`reason`, `blocked_by`, and `root_blockers`:

```jsonc
{
  "next": null,
  "reason": "all_blocked",
  "message": "3 spec(s) remain in E013; all are blocked. Root blocker(s): S030.",
  "blocked": [
    {"spec_id": "S031", "reason": "deps_unmet", "blocked_by": ["S030"], "root_blockers": ["S030"]},
    {"spec_id": "S033", "reason": "deps_unmet", "blocked_by": ["S032"], "root_blockers": ["S030"]}
  ]
}
```

`root_blockers` resolves transitively, and that is the field worth reading: S033's *immediate*
blocker is S032, but the one spec anyone has to act on is **S030**.

This distinction is load-bearing. A drained epic and a fully blocked one used to return
byte-identical responses, so `/nloop` took the "you're done, here's your PR" path for an epic
that was 40% done and halted — a silent failure that reported success. `/nloop` now opens a
completion PR only on `epic_complete`.

`blocked_specs` models the same category: entries carry `is_dependency_blocked`, `blocked_by`
and `root_blockers` alongside `is_paused` / `is_exception` / `blocked_tasks`. And `park()`
tells you what you just halted — `dependents`, `newly_blocked`, and a `warning` when the
closure is non-empty — because parking a leaf is routine while parking an upstream blocker
stops everything behind it.

All four read from one eligibility computation, so they cannot disagree about what "blocked"
means.

### Checkbox markers on acceptance criteria

`criteria_complete` accepts three markers, and what they mean depends on whether the
criterion is **functional** (in the Acceptance Criteria section, outside the
Quality/Performance/Documentation subsections):

| Marker | Functional AC | Quality / Performance / Docs AC |
|---|---|---|
| `x` complete | satisfied — still verified against the diff | satisfied |
| `~` obsolete | satisfied — no longer applies | satisfied |
| `>` deferred | **refused** | satisfied |

`>` is refused on a functional AC because it would move nothing. Two review lanes read
the FR: the approval-evidence gate and the conformance lane, and the latter re-verifies
functional criteria *regardless of checkbox state*. Deferring one used to clear the gate
and then fail conformance, with nothing saying the marker had been overridden. Both
lanes now share a single predicate, so they cannot disagree — and the marker fails
loudly instead, naming the real options.

**When should a spec be parked at all?** Park when the spec is gated on something
outside this session that a later run could plausibly clear — a pending human decision,
a missing credential or access, an upstream dependency not yet released. The spec is
fine; the world is not ready. Park is the *wrong* terminal for a reviewer disagreement
(`dispute_finding`, then `exception`), an unverifiable AC (re-scope or split, below), or
a stuck agent (`exception`) — retrying changes none of those. A park reason should name
what is being waited on and what would unblock it.

**When an AC genuinely cannot be met here**, the ladder is: re-scope it to something
verifiable in this session, split it into its own spec and add a dep, or
`force_archive` with a reason as an audited last resort. Parking is for criteria a
*later run* could satisfy; using it for one that no run can satisfy just re-parks the
spec forever.

Write acceptance criteria that the implementing session can verify from the committed
diff. An AC that depends on something outside that reach — a process restart, an
external service, a future release — is the shape that produces this dead end.

### When review reaches an impasse

Sometimes reviewer and implementer simply disagree, and more rounds won't fix it.
nspec splits that into two steps, and only one of them is an agent's to take.

**The agent halts.** After two rounds on a finding, it records both positions in the
IMPL and calls `exception(spec_id, reason="Impasse on …")`. The spec drops out of
the eligible set — `next_spec` stops offering it — and shows up in `blocked_specs`
with its reason. The loop moves on instead of re-running the same argument.

**A human archives it.**

```bash
nspec impasse S003 --reason "Reviewer finding was wrong (see #122); closing unresolved"
```

That mints a signed `IMPASSE` verdict, and `complete()` then accepts the spec,
reporting `"review": "impasse"`.

The distinction matters because an impasse must not claim the work got done. Unlike
every other completing verdict, `IMPASSE` leaves the **disputed acceptance criteria
and tasks unchecked** — the archived spec reads *stopped, unresolved*, with both
positions intact. Requiring that evidence would make the state unreachable in the
one situation it exists for, since the disagreement is *about* that evidence.

No agent can mint it: `write_review_verdict` refuses `IMPASSE` even with a valid
token, an unsigned one is rejected on completion, and the recorded reviewer is a
non-agent sentinel. Reach for `force_archive` here and you get the opposite of what
you want — it bulk-checks the disputed items and erases the disagreement.

### Reviewer model & effort

Review runs on the **top available model at moderate reasoning effort** —
`[review.codex]` defaults to `gpt-5.6-sol` at `effort = "medium"`,
`[review.claude]` to `opus` at `high`. The two knobs are not interchangeable:
model tier bounds whether a finding is judged correctly, while `effort` buys
depth of exploration within that ceiling. So `effort` is the dial to turn when
review cost or latency matters — lower it rather than downgrading `model`.
Cheap models remain the default for the non-judgement paths (`[handoff]`,
`[docs]`), where summarization is extraction rather than judgement.

Model ids retire without a CLI version bump and fail at *call* time, not
config-load time, so a stale value looks like a reviewer hang rather than a
config error — probe a new id before setting it. Rationale, role-by-role
policy, and the verified legality matrix:
[`docs/design/agent-model-selection.md`](docs/design/agent-model-selection.md).

### Rework telemetry

Every IMPL records a `### Review History` table — one row per review round —
which makes "how often does work need rework?" a measurable property rather
than an anecdote. `review_metrics` projects over those rows:

```
review_metrics(epic_id="E029", since="30d")
```

It reports, per spec, the round that first carried a completing verdict, plus
aggregates: mean/median/max rounds (with the spec owning the max),
first-round-approval rate, and waived count. `nspec digest` renders the same
numbers as a **Factory health** block, and `/nstandup` reports them as one line
with the direction of travel against the previous window.

The projection is read-only — it writes no new state — and reads the Review
History tables rather than `work/` artifacts, because history rows live in git
and survive archival to `completed/done/`.

Two reporting rules keep it honest. A window in which no spec reached a verdict
reports `null`, never `0` — "not measured" must not read as "converged
immediately". And specs are never silently dropped: one still awaiting a verdict
is listed under `in_flight`, and one whose history cannot be parsed under
`unparseable`, neither contributing to the aggregates.

### TUI display preferences are project state, not session state

Your sort mode, sort direction and view preset live in
`.novabuilt.dev/nspec/tui-prefs.json` — one file per project, independent of any work
session. So they survive parking a spec, switching specs, and having no session at all.

They used to be stored on the work-session record, which had two consequences worth
knowing if you see their residue (S1055): parking a spec released the session (S1029) and
took your sort order with it; and because saving a session also repoints the `active`
symlink, cycling sort with no session open *created* a spec-less work session and made it
active. Session files with a leading `-` in their id (`-<timestamp>-<pid>`) are that
residue. They are inert now — delete them from `.novabuilt.dev/nspec/sessions/` whenever
convenient; there is no migration (Rule 10).

A preference that cannot be read or written now says so in the TUI rather than silently
falling back, because a swallowed failure was indistinguishable from "no preference set",
which is itself indistinguishable from the `[tui] default_sort` config value.

### Where review artifacts land

Everything nspec generates while working a spec lives under **one root**,
`work/specs/<spec-id>/`, indexed by that spec's `manifest.json`:

```
work/specs/S314/
  manifest.json   prompts/   review/   output/   logs/
```

`review/` holds the curated verdict and its JSON sidecar; `output/` holds the raw
agent transcript; `prompts/` the review prompt; `logs/` the per-agent execution
logs. One directory therefore archives, hands off, or cleans up a spec's whole
evidence trail. Spec-less agent output goes to `work/logs/`.

> **Upgrading:** curated review artifacts used to be written to a second
> top-level root, `work/agent-output/<id>/`, which duplicated each round and left
> the curated copy out of the manifest. Nothing reads or writes that path now.
> Existing `work/agent-output/` trees are left inert — gitignored scratch, never
> migrated, safe to delete by hand. There is no compatibility shim.

### Parking a spec releases the session

`park`, `exception` and `hold` take a spec out of workable state — and each **clears the active
session** when the spec it is acting on is the one the session points at, reporting
`session_cleared: true`. You never call `session_clear()` yourself alongside them.

```
activate("S003")
park("S003", reason="blocked on operator action")
→ {"success": true, "spec_id": "S003", …, "session_cleared": true}

# a bare `nspec`/`/ngo` now picks the next workable spec instead of re-resolving S003
```

Release is conditional on identity: parking a *different* spec than the active one returns
`session_cleared: false` and leaves the session untouched, so parking something in the background
never detaches you from what you are working on.

Without this, parking was self-defeating — the spec went Paused but the session still named it, so
the next run resumed it, hit the same blocker and parked again. As a second layer, spec resolution
also declines to resume a session pointing at a Paused/Exception/Hold spec and falls through to the
next workable one.

### Un-park returns a spec to the un-started state (S1060)

`resume` sets IMPL `Planning` and FR `Proposed` — it does **not** restore the status the spec held
when parked. Status is a present-tense claim (`docs/design/unrequested-state-changes.md`): `Active`
means *someone is working this now*, which was true at park time and is not true at un-park time.
Restoring it meant un-parking five specs left five reading Active with nobody working any of them,
and "which spec is in flight" stopped being answerable.

Where work had reached is history, and it lives where history lives: the RESUMED execution note
("returned to Planning (work had reached Testing)") and the response's `prior_status` field
(`null` for a spec parked by older code). Whoever picks the spec up next calls `activate` — the
only path back to Active. Relatedly, `reset` now accepts an Active spec, as the tool-driven repair
for specs stranded at Active by the old behaviour; Testing and Ready are still refused, because
verified work is completed or parked, never silently restarted.

### Where specs live, and where project root comes from

`[paths] spec_root` decides where specs live. A **relative** value resolves against
the config file's directory (`.novabuilt.dev/nspec/`); an absolute one is used as-is.
All four layouts are supported:

| `spec_root` | Specs land in | Layout |
|---|---|---|
| `"../../docs"` (default) | `<root>/docs/` | Sibling of the state dir |
| `"specs"` | `<root>/.novabuilt.dev/nspec/specs/` | Inside the nspec dir |
| `"."` | `<root>/.novabuilt.dev/nspec/` | Alongside `config.toml` |
| `/abs/path` | `/abs/path` | Anywhere |

**Project root is derived from the config file, never from `spec_root`.** It is the
directory that *contains* `.novabuilt.dev/` — a fact about where state lives, not an
inference from where specs happen to live. Every state path (`locks/`, `sessions/`,
`id-reservations.json`) is built by appending `.novabuilt.dev/nspec/` to it.

That distinction matters because the two are only the same under the default layout.
Treating the spec root's parent as the project root is correct for `"../../docs"` and
wrong for the other three — under `spec_root = "specs"` it yields
`<root>/.novabuilt.dev/nspec`, and appending the state dir again produces a **doubled
state tree** (`.novabuilt.dev/nspec/.novabuilt.dev/nspec/`) holding a second
`id-reservations.json`, a second session, and a `state.lock` that no longer serializes
against the real one.

Two guards keep that unrepresentable: resolving a project root that ends in
`.novabuilt.dev/nspec` **fails loudly** rather than being silently corrected, and
config discovery **skips** any `config.toml` found inside another `.novabuilt.dev/`
tree (without that, a nested config makes the wrong root self-confirming).

Run `nspec doctor` to detect an existing doubled tree. It **reports and never merges** —
reconciling two divergent `id-reservations.json` files means deciding which IDs are
real, and getting that wrong silently reissues an ID, so it stays a human decision.
Take the superset of reserved IDs by hand, and untrack the nested `locks/`/`sessions/`
(top-level `.gitignore` patterns do not match the nested path).

## Documentation

Full documentation: **[novabuilt.dev/nspec](https://novabuilt.dev/nspec/)**

- [Getting Started](https://novabuilt.dev/nspec/getting-started/) — Install, init, MCP setup
- [CLI Reference](https://novabuilt.dev/nspec/user-guide/cli/) — Full command docs
- [Terminal UI](https://novabuilt.dev/nspec/user-guide/tui/) — Interactive backlog browser & dependency visualization
- [MCP Tools](https://novabuilt.dev/nspec/mcp/tools/) — Tool reference tables
- [Skills Reference](https://novabuilt.dev/nspec/user-guide/skills/) — Slash commands for Claude Code
- [Configuration](https://novabuilt.dev/nspec/user-guide/configuration/) — Config file, env vars
- [Contributing](https://novabuilt.dev/nspec/contributing/) — Dev setup, testing

## Development

```bash
poetry install
make test-quick    # Fast tests, fail-fast
make check         # Format + lint + typecheck
```

**Type-aware verify:** `/ngo`'s verify step runs the command appropriate to what a
spec changes — `make test-quick` for code, `mkdocs build --strict` for docs — instead
of always running the full test suite. A spec selects its profile with a
`verify_profile: docs` annotation in its FR (unannotated specs are `code`); projects
add or override profiles under `[verify.profiles]` in `config.toml`, or per-run via an
`NSPEC_VERIFY_<PROFILE>` env var (env > config > built-in default). Resolution fails
closed: an unknown profile falls back to the code command, so verification is never
silently skipped.

## License

MIT License - see [LICENSE](LICENSE) for details.

## Credits

Built with [Poetry](https://python-poetry.org/), [Textual](https://textual.textualize.io/), [Rich](https://rich.readthedocs.io/), and [MCP](https://modelcontextprotocol.io/).

