Skip to main content

nspec

PyPI version Python 3.11+ License: MIT Docs

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 (S1000S9999) 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.

  • 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 epics shows 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 and next_spec would not hand you.

  • 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:

    {
      "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 (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:

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_sourcerefs (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]:

[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. 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.

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

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

Built with Poetry, Textual, Rich, and MCP.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nspec-3.25.1.tar.gz (923.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nspec-3.25.1-py3-none-any.whl (1.0 MB view details)

Uploaded Python 3

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

Hashes for nspec-3.25.1.tar.gz
Algorithm Hash digest
SHA256 209f74894e85a4a571304951c58a0510574ba658425fe6e21418ca551a5a15bb
MD5 49dd411b213c609a5fb09ad09328213d
BLAKE2b-256 df05aaa02e1ca4e4dc7608318eeb820a0f431e496b3aa1de9e758c567ca90cb3

See more details on using hashes here.

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

Hashes for nspec-3.25.1-py3-none-any.whl
Algorithm Hash digest
SHA256 cc2e4211ea42074c8d6e4fbfaf3484c52065f986fcc616a49018662a903b8a5b
MD5 eac3829c1b10f4f8d0b8d9fd96e34ac8
BLAKE2b-256 06db0fc7bbda5ecbe79c763f40e83a94333ec4d758acf690f96c740eea2c048d

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

3.25.1 This release

2 files

3.25.0

2 files

3.24.0

2 files

3.23.3

2 files

3.23.2

2 files

3.23.1

2 files

3.23.0

2 files

3.22.0

2 files

3.21.0

2 files

3.20.0

2 files

3.19.0

2 files

3.18.0

2 files

3.17.0

2 files

3.16.0

2 files

3.15.3

2 files

3.15.2

2 files

3.15.1

2 files

3.15.0

2 files

3.11.0

2 files

3.10.1

2 files

3.10.0

2 files

3.9.0

2 files

3.8.8

2 files

3.8.7

2 files

3.8.6

2 files

3.8.5

2 files

3.8.4

2 files

3.8.3

2 files

3.8.2

2 files

3.8.1

2 files

3.8.0

2 files

3.7.0

2 files

3.6.0

2 files

3.5.3

2 files

3.5.2

2 files

3.5.1

2 files

3.5.0

2 files

3.4.3

2 files

3.4.2

2 files

3.4.1

2 files

3.4.0

2 files

3.3.0

2 files

3.2.2

2 files

3.2.1

2 files

3.2.0

2 files

3.0.1

2 files

3.0.0

2 files

2.4.0

2 files

2.3.1

2 files

2.3.0

2 files

2.2.0

2 files

2.1.0

2 files

2.0.2

2 files

2.0.1

2 files

1.1.1

2 files

1.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page