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
pip install nspec
Also available via pipx, uv, and poetry. See the Getting Started guide.
Quick Start
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:dependencyorders specs by a topological sort of theirdeps, not by priority. Pressoin the TUI to cycle the sort mode —dependency → pickup → priority → status → id → alpha— andOto 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 withadd_depto satisfy it. -
Epic progress is counted, not inferred. Every member spec falls into exactly one of five buckets — completed (Completed/Ready), active (Active/Testing/In Design), blocked (Paused/Hold/Exception), dropped (Superseded/Rejected) and pending (everything else) — and the five always sum to the epic's member count.
nspec epicsshows each as its own column.Two consequences worth knowing. Dropped specs leave both halves of the fraction: progress is measured against the live member count, so an epic whose remaining children were superseded reads 100% rather than being held down forever by scope you explicitly removed. And the numbers agree with what you can see — the count of rows left after the TUI's Hide Done/Superseded filter (
c) equals pending + active + blocked, because both read the same classifier. Previously "pending" was whatever was left after subtracting done and active, so it silently absorbed superseded, rejected and paused specs, and the header advertised work that the table did not show andnext_specwould not hand you. -
A priority write is durable, and the response tells you what moved. The value
set_priorityreports 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 achangedlist naming every spec it moved, not just the one you asked for:{ "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
priorityis 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_prioritywrites 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. Passcascade_dependents=trueto 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(S1036): whypriorityis an authored input and not a derived value, where thechild <= parentinvariant 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:
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 contract — an epic is a feature area, not a big spec
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
a dedicated, profile-independent template pair — specs keep the ceremony profiles
(quick/standard/full/formal); epics get their own.
The epic FR (templates/epic/fr.md): Goal, Architecture Area, 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.
The epic IMPL (templates/epic/impl.md) is a ledger, not a task list: Architecture
Area, Child Spec Roster, Decisions, Progress Log. It has no task section by
design — an epic's progress is counted from its members, and a second, hand-maintained
progress surface on the same object would drift from the first.
Override either half by dropping your own copy at
.novabuilt.dev/nspec/templates/epic/{fr,impl}.md, which wins over the builtin. These
templates resolve from files precisely so you can rewrite them for your project.
Every epic declares where it lives
An epic is a bucket for a feature, and a feature worth an epic is worth a durable design surface that outlives its child specs. So the default is one epic to one architecture area, declared in the epic's own FR as exactly one of:
| Declaration | When |
|---|---|
docs/architecture/NN-<slug>/DESIGN.md |
The default — this epic is a feature area |
Cross-cutting — <reason> |
No single area: bugs, process, release, docs |
Vertical — spans <areas>, <reason> |
Several areas, no new area directory |
The exceptions are real and stay legal — but they are declared, not defaulted into, because an undeclared exception is indistinguishable from an epic nobody placed. Declare it at creation time: an epic's ID cannot be changed once it has archived children, and in practice neither can its place in the architecture — retrofitting the mapping later leaves permanent exceptions.
nspec doctor reports an epic with no declaration as an advisory [warn] naming the epic
IDs. It never changes the exit code: the section postdates every epic already in a project,
so a hard failure would red the build for history rather than for a regression.
One change, one spec — even when it touches three areas
A spec is a unit of change, not a unit of file location. If a coherent change edits three areas, it is still one spec; splitting it along module boundaries produces a chain that cannot be reviewed, verified, or reverted independently.
Cross-epic spec dependencies are fully supported — add_dep across epics just works — but
prefer, in order: fold the work into one spec; place the spec in the epic that owns
the outcome, not the one that owns the most files; then a cross-epic dep. The reason
to keep them rare is legibility, not correctness: filtering the TUI by epic is how a human
reads a body of work, and a spec whose blockers sit outside that filter is a spec whose
readiness cannot be read off the screen. If an epic keeps needing edges out of itself, its
boundary is probably drawn in the wrong place.
Prompt templates and overrides
Every prompt nspec sends to an agent is a file-backed template resolved through the
resource registry (src/nspec/resources/registry.py), never a hardcoded string. A project
overrides any of them by dropping a copy at .novabuilt.dev/nspec/resources/prompts/<file>
or pointing a [resources] entry in config.toml at a custom path. The prompt handles:
| Handle | Used by |
|---|---|
review-prompt (+ review-prompt:{profile} variants) |
code review (/ngo Phase 6, /nreview) |
refine-fr-prompt / refine-impl-prompt |
FR authoring / IMPL refinement |
audit-decision-prompt / audit-inventory-prompt / audit-handoff-prompt |
audit_record summarization |
swarm-agent-prompt |
per-agent bootstrap in swarm mode |
contested-findings-prompt |
dispute-resolution block appended to conformance reviews |
Two prompts are deliberately not templates: the health-check ping (its expected answer
lives in code, and an override could make the preflight permanently unpassable) and the
execute_agent prompt-file pointer (transport around a runtime path, not content).
The registry is self-describing: nspec resources list [--json] enumerates every
resolvable resource — prompts, scaffold templates, schemas, and any config-only handles —
with its kind, override status (builtin / project-local / config), and the placeholder
variables its renderer substitutes; nspec resources show <handle> [--content] prints one
entry (and optionally the resolved file). The same data is available to agents via the
list_resources MCP tool, which is what /ntemplates renders. A drift test pins the file
tree to the enumeration, so a new resource cannot ship unenumerated.
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:
[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) orfile-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]:
[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:
{
"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.
Checkbox markers on IMPL tasks
task_complete accepts two markers — x (complete) and ~ (obsolete). A task has no
Quality/Documentation lane to escape into: every entry under ## Tasks is work, so >
(deferred) is refused outright, for the same reason it is refused on a functional AC.
It moves nothing, and the approval-evidence gate reads a [>] task as incomplete. Use ~
when the task no longer applies, or a follow-up spec plus add_dep when the work genuinely
moves elsewhere. is_task_satisfied is the predicate the task parser and the gate share,
so progress accounting and the gate agree on every marker.
Re-marking is supported. task_complete locates a task in any marker state, so
[x] → [~] is a normal call — this is how a mis-marked checkbox is repaired through the
tool rather than by hand-editing an IMPL the guard hooks protect. Re-marking with the marker
the task already carries fails with "already marked [x]", which is a different error from
"not found"; conflating the two is what made a wrongly-marked task unfixable.
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.
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.
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. Existingwork/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.
Reopening a completed spec — reopen
complete() had no inverse. resume needs a Paused spec, recover needs an Exception one,
and reset refuses Testing/Ready and moves no files — so putting an archived spec back meant
git mv plus hand-editing its status, which is the one thing nspec's own guidance tells you
not to do, because the tools own the side effects that come with a status change.
reopen is that inverse: nspec spec reopen --id S123 --reason "..." (or the reopen MCP
tool) moves the FR+IMPL pair out of completed/done/ and back into the active directories.
A reopened spec comes back un-started — FR Proposed, IMPL Planning — for the same
reason un-parking does: status says what is true now, and Completed is exactly the claim
you are retracting. Nothing is lost; the archived content is untouched and a REOPENED note in
the IMPL records why it was reopened.
A reason is required, because un-archiving reverses a recorded completion. Every check runs before anything moves, so a refusal never leaves the pair stranded between two directories: reopening a spec that was never completed, or one whose ID is already active, fails with the files where they were. Superseded specs are refused too, with a pointer to work the replacement instead — superseded means replaced, which is a different thing from finished.
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).
There is no cwd-relative docs/ fallback — an unresolvable root raises. When a
command or MCP tool is given no explicit root, nspec resolves [paths] spec_root from
the discovered config.toml; if no config is discoverable, or the one found cannot be
parsed, it fails with a message naming the directory it searched from. It does not
substitute <cwd>/docs. Earlier versions did, at twelve call sites, and the
substitution is wrong for three of the four layouts above — so the observable result
was an empty backlog rather than an error, and a malformed config.toml silently
resolved the default layout instead of the configured one. Ways out of the error:
run from inside the project, run nspec init, or pass the root explicitly
(--docs-root, the docs_root tool argument, or NSPEC_DOCS_ROOT for MCP). An
explicit root stays authoritative and consults no config at all.
Documentation
Full documentation: novabuilt.dev/nspec
- Getting Started — Install, init, MCP setup
- CLI Reference — Full command docs
- Terminal UI — Interactive backlog browser & dependency visualization
- MCP Tools — Tool reference tables
- Skills Reference — Slash commands for Claude Code
- Configuration — Config file, env vars
- Contributing — Dev setup, testing
Development
poetry install
make test-quick # Fast tests, fail-fast
make check # Format + lint + typecheck
make ngo-gate # The gate /ngo runs per spec — CI parity
The per-spec gate is defined as CI's gate set (S1092). make ngo-gate is
format-check lint typecheck shellcheck complexity-gate qa-ci validate — the
static-analysis job from .github/workflows/test.yml plus spec-integrity's
nspec validate. /ngo Phase 5 runs exactly this, so a green gate locally means a
green CI, for every gate except the two that are genuinely expensive
(coverage-gate, smoke), which stay CI-only.
It is deliberately not make qa. That target is the dashboard sweep, and four
of its steps gate nothing in a spec run: qa-coverage parses a coverage report that
make test-quick (-n auto, no coverage) never wrote, so it is structurally empty;
qa-outdated is network-bound and judges dependency freshness, which no spec's diff
can change; qa-render and qa-sloc feed the HTML dashboard. /nloop runs make qa
once at epic-finalize, where /nqa consumes that data, rather than once per spec.
The quality half also changed baseline. qa-ci diffs against the committed
qa-baseline.json; the old per-spec make qa-snapshot + qa-diff pair diffed against
a snapshot taken before each spec — a moving baseline that let a +1-per-spec ratchet
pass locally forever while CI caught the cumulative drift. Measurements and the full
argument: docs/design/ngo-gate-economics.md.
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.
Two tiers, and when each applies (S1093): the resolved verify command is not the only
test run /ngo has. spec_tests(spec_id) runs just the spec's own Tests: annotations and
## Test Files, and it is the gate for every inner iteration — task execution in Phase 4,
and the edit-and-check cycles inside a Phase 6 remediation round. The full verify command runs
once in Phase 5 and once at the end of each review round, where it produces the receipt the
reviewer reads. Measured in this repo the two differ by 20× (~108s vs ~5s), and Phase 6 used to
re-run the full suite every round — a three-round spec ran the whole suite four times for edits
touching two files. The cheap tier is never silently weaker: spec_tests reports
fallback: true and runs make test-quick when a spec has no annotations, so an unannotated
spec degrades to the full suite rather than to nothing.
License
MIT License - see LICENSE for details.
Credits
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nspec-3.25.1.tar.gz.
File metadata
- Download URL: nspec-3.25.1.tar.gz
- Upload date:
- Size: 923.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
poetry/2.4.1 CPython/3.12.3 Linux/6.1.0-44-amd64
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
209f74894e85a4a571304951c58a0510574ba658425fe6e21418ca551a5a15bb
|
|
| MD5 |
49dd411b213c609a5fb09ad09328213d
|
|
| BLAKE2b-256 |
df05aaa02e1ca4e4dc7608318eeb820a0f431e496b3aa1de9e758c567ca90cb3
|
File details
Details for the file nspec-3.25.1-py3-none-any.whl.
File metadata
- Download URL: nspec-3.25.1-py3-none-any.whl
- Upload date:
- Size: 1.0 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
poetry/2.4.1 CPython/3.12.3 Linux/6.1.0-44-amd64
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cc2e4211ea42074c8d6e4fbfaf3484c52065f986fcc616a49018662a903b8a5b
|
|
| MD5 |
eac3829c1b10f4f8d0b8d9fd96e34ac8
|
|
| BLAKE2b-256 |
06db0fc7bbda5ecbe79c763f40e83a94333ec4d758acf690f96c740eea2c048d
|