Skip to main content
Glama

nightward

A regression gate for AI-made changes. Your AI agent can run the gate; opening it takes a human-approved baseline diff.

15-second demo: an AI fixes one line, every test passes, nightward shows three customer-facing behaviors moved, the gate stops CI, and opening it takes a human-approved baseline diff

When an AI agent fixes A, it often moves B, C and D too, and the test suite stays green because no test pins down B, C or D. You find the side effects later, fix them, and the fixes move something else. An agent loop has no reliable way to know when it is done.

nightward gives that loop a definition of "done" that the agent can check but not change:

  1. Your tests capture what the code does today with a behavior(...) pytest fixture. You don't write expected values.

  2. A human reviews those captures and approves them once. That baseline is a set of JSON files committed next to your code.

  3. After every change, nightward captures again and lists everything that moved, grouped by feature (the blast radius). The gate stays closed until a human approves each change as intended or the code is fixed.

The agent gets run and status, on the command line or as MCP tools. Approving is a human CLI command, and its result is a file diff that goes through code review.

nightward run       # capture behaviors and compare them with the approved baseline
nightward review    # a human reads the diffs
nightward approve   # the human accepts what review showed (intended changes only)
nightward gate      # exit 0 only when nothing unapproved remains (CI, agent loops)

nightward is not a test generator, and it has no idea what is correct. It only makes sure that no change to a captured behavior passes silently.

Compared with

How nightward differs

NL→test generators

nightward gates changes, it doesn't author tests

snapshot libraries (syrupy, inline-snapshot, ApprovalTests)

approval is a separate, review-gated step that never removes anything in bulk, and one run lists every moved behavior by feature with a verdict an agent can read. Detailed comparison

ordinary regression tests

no hand-written expected values, and a semantic judge can gate nondeterministic AI text

What's new in 0.3, and what changes for 0.2 users: see the CHANGELOG.

How a project uses it

Setup happens once. After that, every change goes around the loop, and only a human can take an "approve" edge:

flowchart TD
    subgraph setup["Once per project"]
        A["Write capture tests<br/>behavior(name, value, group=)"] --> B["nightward run"]
        B --> C{"A human reads the first capture<br/>nightward review"}
        C -- "a value is wrong" --> D["Fix the code"] --> B
        C -- "this is what it should do" --> E["nightward approve<br/>commit .nightward/baseline/"]
    end
    E --> F
    subgraph loop["Every change, by an agent or a human"]
        F["Edit the code"] --> G["nightward run<br/>(MCP: nightward_run)"]
        G --> H{"boundary"}
        H -- "intact" --> I["Done: open the PR"]
        H -- "breached, not intended" --> F
        H -- "breached, intended" --> J["The agent stops and lists the changes"]
        J --> K{"A human runs nightward review"}
        K -- "approve" --> L["Baseline diff in the PR,<br/>reviewed by code owners"]
        K -- "reject: a regression" --> F
    end
    I --> M["CI: nightward gate<br/>exit 0 only when intact"]
    L --> M

The same loop as a timeline, for an agent change that moves something it shouldn't:

sequenceDiagram
    actor Human
    participant Agent
    participant NW as nightward
    participant CI
    Human->>Agent: "add a coupon to checkout"
    Agent->>Agent: edits the code
    Agent->>NW: nightward_run
    NW-->>Agent: breached: checkout_total (intended), loyalty_points (not intended)
    Agent->>Agent: fixes the loyalty side effect
    Agent->>NW: nightward_run
    NW-->>Agent: breached: checkout_total only
    Agent-->>Human: "checkout_total changed as asked - please review"
    Human->>NW: nightward review, then nightward approve
    Human->>CI: push (the baseline diff is in the PR)
    CI->>NW: nightward gate
    NW-->>CI: exit 0 (intact)

Related MCP server: agent-invariants

Install

nightward needs Python 3.10+. Installing it also registers its pytest plugin, so the behavior fixture is available in every test.

pip install nightward
pip install "nightward[mcp]"      # + MCP server for AI agents

Other extras: [judge] (the Anthropic SDK, for the LLM judge) and [docs] (the PDF, Word and Excel adapters).

Quickstart (5 minutes)

In an empty directory, create a tiny app and a test that captures its behavior. app.py:

def checkout_total(items):
    subtotal = sum(i["price"] * i["qty"] for i in items)
    tax = round(subtotal * 0.1, 2)
    return {"subtotal": subtotal, "tax": tax, "total": round(subtotal + tax, 2)}


def user_login(username):
    return {"user": username, "status": "ok", "roles": ["member"]}

test_app.py writes no expected values. It captures what the code returns (for anything bigger than this, shape the payload so a human can review it: payload design):

from app import checkout_total, user_login

CART = [{"price": 10.0, "qty": 2}, {"price": 5.5, "qty": 1}]


def test_checkout(behavior):
    behavior("checkout_total", checkout_total(CART), group="billing")


def test_login(behavior):
    behavior("user_login", user_login("alice"), group="auth")

A test that captures one value can return it instead. @capture names the behavior after the test (or name=), and each parametrized case keeps its id:

from nightward import capture


@capture(name="checkout_total", group="billing")
def test_checkout():
    return checkout_total(CART)

1. Approve the current behavior as the baseline.

git init                    # skip in an existing repo
nightward init              # creates .nightward/ and git-ignores its per-run files
nightward run .             # runs pytest and captures; lists what moved (names only)
nightward review            # shows each diff; approve covers only what review showed
nightward approve --all
git add .gitignore .nightward   # commit baseline/, rejected/, judge/ (init ignores per-run files)
git add app.py test_app.py && git commit -m "nightward baseline"

run finds two NEW behaviors, review prints their JSON, and approve makes them the baseline:

Boundary: breached (2 unapproved)
unchanged=0 changed=0 new=2 removed=0
...
approved checkout_total (c9ff6ed9)
approved user_login (b86be5a5)

Boundary: intact
unchanged=2 changed=0 new=0 removed=0

2. Make the kind of change an agent makes. In app.py, change the tax rate 0.1 to 0.2 (in bash: sed -i 's/subtotal \* 0.1/subtotal * 0.2/' app.py). Then:

nightward run .             # the tests still pass
nightward gate              # exit code 1: not done
nightward status --json     # the same verdict, for a script or an agent
nightward approve --all     # refused: nobody has reviewed this change yet
nightward review            # the diff a human decides on
Boundary: breached (1 unapproved)
unchanged=1 changed=1 new=0 removed=0

group: billing
  - [CHANGED] checkout_total

boundary breached (1 unapproved) (as of the last run, ...)

{"boundary": "breached", "unapproved": 1, "changes": [{"name": "checkout_total", "kind": "CHANGED", "group": "billing"}], ...}

error: checkout_total changed since your review (the capture now in the store was taken by `nightward run` at ...). Run `nightward review checkout_total`, then approve what it shows.

[CHANGED] checkout_total
--- approved
+++ received
@@ -1,5 +1,5 @@
 {
   "subtotal": 25.5,
-  "tax": 2.55,
-  "total": 28.05
+  "tax": 5.1,
+  "total": 30.6
 }

3. Decide. If the change is a regression, fix the code: put 0.1 back, run nightward run . again, and it reports Boundary: intact, so nightward gate exits 0. If the change is intended, run nightward approve --all (it now succeeds, because review showed you this diff) and commit the updated .nightward/baseline/checkout_total.approved.json.

4. Optional: see the blast radius in a browser. nightward view builds a static, read-only dashboard and serves it on localhost. nightward view --no-serve only builds nightward-site/, which init already git-ignored.

For a bigger cascade (one tax tweak moves three customer-facing behaviors), run the petshop example from a clone of this repo.

What to commit

path

commit?

what it is

.nightward/baseline/

yes

the approved behaviors: the boundary

.nightward/rejected/

yes

changes a human confirmed as regressions

.nightward/judge/

yes

the semantic judge's rulings (review them like the baseline)

pending/, report.json, run_meta.json, reviewed.json, .lock

no

per-run state, ignored by nightward init

nightward-site/

no

the dashboard build: it contains your captured data

In CI: the GitHub Action

Set up Python and your project's dependencies, then gate the PR with one step. It runs your tests, writes the verdict and the diffs that moved to the job summary, and fails the job unless the boundary is intact. It never approves: intended changes are approved locally (nightward review, nightward approve) and arrive in the PR as a baseline diff.

- uses: actions/checkout@v4
- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
- run: pip install -e .          # your project and its test dependencies
- uses: LunenLabs/nightward@v0.3.4
  with:
    path: tests                  # what pytest runs; default: [tool.nightward] path, else "."

Inputs: path, dir (default .nightward), pytest-args (one per line), allow-not-run, version, install, summary; output: boundary. A full workflow with the dashboard artifact is in the CI recipe.

Commands

command

what it does

nightward init

create the store and the .gitignore rules (once per repo, and again after upgrading)

nightward run [PATH] [-- PYTEST ARGS]

run pytest, capture, compute the blast radius. Exit 1 if a capture test failed (the run is incomplete)

nightward review [NAME...] [--group G] [--summary]

show the diffs, or with --summary each changed path once with its old -> new values counted. Only review and view mark changes as reviewed

nightward approve --all / NAME... / --group G

promote reviewed NEW/CHANGED behaviors into the baseline. Bulk approval never removes anything

nightward approve --remove NAME... / --remove-group G

drop REMOVED behaviors: removal is always this explicit act

nightward reject NAME

confirm a reviewed change as a regression. The gate stays breached

nightward gate [--allow-not-run] [--run]

the verdict as an exit code: 0 intact; 1 breached, partial, stale or incomplete; 2 no store, no report, or an error. --run captures first, so the verdict covers the code as it is now

nightward status [--json]

the verdict with the change list. --json is the machine signal

nightward doctor [NAME...]

explain what moved, and suggest scrub rules only for values that are volatile by evidence

nightward report

the verdict from a capture that pytest --nightward-record already wrote in your CI job

nightward view

build and serve the read-only dashboard

nightward mcp

stdio MCP server for AI agents: run and status, never approve

nightward --version

the installed version

gate and status report the last run, so run nightward run again after every code edit (or use nightward gate --run). The full reference (removals, stale reports, merge conflicts, locking, and using the plugin inside an existing pytest job) is in docs/commands.md.

AI agents (nightward mcp)

An agent loop needs a definition of "done" that it can check but not change. nightward mcp is a stdio MCP server with two tools: nightward_run (run the tests, capture, recompute the verdict) and nightward_status (read the last verdict). approve and reject are not exposed: if the agent that makes a change could also approve it, the gate would turn into a changelog. The judge and the store are the humans' committed choices, so the agent can't pick a lenient judge or point the server at another store.

In Claude Code, install the plugin. It adds the MCP server and a skill that tells Claude to run the gate after its edits and to hand intended changes to you instead of approving them. A second skill, onboard, puts new code under the gate: Claude reads the code without changing it, writes a domain map and the capture tests, and hands you the suspected defects and the first review. nightward itself must be installed in the environment you start claude from (your project's virtualenv):

pip install "nightward[mcp]"                      # in the project's environment
/plugin install nightward --marketplace LunenLabs/nightward

/nightward:status then shows the verdict and what you need to review. Any other MCP host can run the server directly:

claude mcp add nightward -- nightward mcp         # or your host's equivalent; run it in the project root

Both tools return the status --json shape. The agent is done only when boundary is intact and stale is false:

boundary

meaning

what the agent should do

intact

no unapproved change

done

breached

unapproved changes

fix the code, or stop and ask a human to approve

partial

nothing unapproved among what ran, but approved behaviors were not checked (deselected, see not_run)

run them: remove the -k/-m selection (it can't be waived over MCP)

incomplete

nothing unapproved, but capture tests failed or errored

fix the failing tests (see incomplete and pytest_output_tail)

stale

the baseline, capture or rejections moved since the report

call nightward_run again

unknown

no report yet

call nightward_run

Setup for other MCP hosts, every argument and returned field, and the rules for the loop are in docs/mcp.md.

Gating AI text: the semantic judge

Free-text AI output changes its wording on every run, so an exact comparison breaches every time. Mark such a behavior semantic=True and commit a judge in pyproject.toml: [tool.nightward] judge = "anthropic:claude-haiku-5-5". The key-free persona:* judges are deterministic stand-ins for tests and CI: they forgive changes of case, whitespace and punctuation in prose, not a reworded sentence, so reworded AI text needs the LLM judge. The judge only decides whether two outputs mean the same thing; approval stays human. Every ruling is recorded in the committed ledger .nightward/judge/, and a judge that can't rule falls back to the exact comparison. Details are in docs/semantic-judge.md.

How it compares with snapshot testing

nightward is snapshot testing underneath: capture, approve, compare. If you already use syrupy, inline-snapshot or ApprovalTests and people make your changes, they may be all you need. nightward is built for the loop where an agent makes the change:

snapshot libraries

nightward

a mismatch

fails the test; a test stops at its first failing assertion

is recorded; one run lists every moved behavior, grouped by feature

accepting changes

an update flag rewrites snapshots (syrupy's --snapshot-update also deletes unused ones), or you edit the approved file

approve accepts only what review showed you, and never removes a behavior in bulk

a machine verdict

the pytest exit code

status --json, MCP tools, a GitHub Action; never intact when tests were deselected (partial), failed (incomplete) or the store moved since (stale)

reworded AI text

a mismatch every run

optional semantic judge with a committed ledger

Where they're better: inline-snapshot keeps the value next to the assertion, syrupy has serializers for many types and needs no extra command, and ApprovalTests exists for many languages. They can run side by side with nightward in one suite.

What "intact" means

"Boundary intact" means no captured behavior changed, nothing more:

  • The gate sees only what behavior() captures, so capture the behaviors you care about.

  • The gate is a convention, not a sandbox. An agent with a shell could run approve or edit the baseline, but every bypass leaves a trace in git. Make that trace need a human: own the store with CODEOWNERS, require code-owner review on main, and let CI run the gate from source (recipe).

  • A semantic judge can be wrong, so its rulings are recorded for review.

  • Normalization defines "the same": timestamps and UUIDs are masked by default (opt out with scrub=False), and dict key order is ignored.

Read the threat model and what nightward normalizes before you rely on a green gate.

Documentation

Scope

In scope: pytest capture, the blast-radius diff, the gate, the loop signal, scrubbing and doctor, the static dashboard, the MCP agent gate (run and status, never approve), and the semantic judge. Out for now: PR-comment summaries, call-graph grouping, and other languages. See CONTRIBUTING.md.

MIT licensed.

Available Tools

2 tools
nightward_runA

Run the tests, capture behaviors, and return the boundary signal.

Call this after every code edit: nightward_status only reports the last run.
path: what pytest runs (relative to the server's working directory); omit it to
run the project's capture suite ([tool.nightward] path, else ".");
dir: omit it - the server gates the store a human started it with
(`nightward mcp --dir`), and any other store is refused; timeout: seconds
before pytest is stopped (the store is then left untouched and the last
report invalidated).
Returns {boundary, unapproved, changes: [{name, kind, group, judged,
judge_model, judge_reason, judge_replayed, rejected, rejected_by, depth,
ask_human}], judged_same,
not_run (approved behaviors whose test was deselected: not checked), narrowed,
stale, incomplete, generated_at, judge, scrub_rules (custom scrub rules of the run
and what they replaced), env_mismatch (changed behaviors approved on another OS or
Python than this run, or null: tell the human - it never makes a breach
acceptable), store (absolute path of the gated
store), path (what pytest ran), warnings: {skipped, failed, errors, deselected,
xfailed, scrubbed,
scrub_unmatched (custom scrub rules that matched nothing), scrub_rules (per custom
rule: values and behaviors it replaced, and whether that changed since the last run),
judge_added (judge ruling files this run added to the store's judge/ dir - a human
commits them with the baseline), pytest_returncode, pytest_output_tail}}.
boundary is one of:
  "intact"     done: no unapproved change;
  "breached"   unapproved changes: fix the code, or ask a human to approve;
  "partial"    nothing unapproved among what ran, but approved behaviors were
               not checked (their test was deselected, see not_run): not done -
               run them (remove the -k/-m selection); you can't waive this;
  "incomplete" nothing unapproved, but capture tests failed or errored
               (see incomplete and pytest_output_tail): fix the tests;
  "stale"      the baseline, capture or rejections moved since the report: run again;
  "unknown"    no report yet: run.
Done means boundary == "intact" and stale is false. Behaviors approved with
semantic=True are judged by the judge the humans committed
([tool.nightward] judge in pyproject.toml, or `nightward mcp --judge`);
"judge" says which, and why it was unavailable if it was. This tool cannot
approve changes: a human does that with the nightward CLI.
NameRequiredDescriptionDefault
dirNo
pathNo
timeoutNo

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Since no annotations are provided, the description must carry the full burden. It excels by explaining complex behaviors not obvious from the schema, such as the data integrity implications of the `timeout` parameter or the precise meaning of the six different `boundary` states.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

While incredibly detailed, the length is necessitated by the extreme complexity of the tool (running a full suite, generating a massive report, and defining six boundary states). The critical information, however, is well-front-loaded.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Despite the lack of an output schema, the description serves as a complete functional specification, defining the purpose of every returned field and the various conditions under which the tool will respond.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

With 0% schema description coverage, the description is crucial. It elaborates on the operational meaning of each parameter: `path` defines the pytest scope, `dir` defines the storage security context, and `timeout` defines the data integrity risk.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The purpose is clearly stated in the first sentence: to 'Run the tests, capture behaviors, and return the boundary signal.' It further distinguishes itself from the sibling tool, nightward_status, by specifying that it must be called after every code edit.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It provides explicit usage context ('Call this after every code edit') and guidance on which parameters to omit based on desired behavior (e.g., omitting `dir` or `path` determines the scope of the test run).

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

nightward_statusA

Read the boundary signal of the LAST nightward_run, without running tests.

It does not see code edits made since that run: after changing code, call
nightward_run for a fresh verdict. Same shape as nightward_run, without
warnings. boundary is "intact" (done), "breached" (unapproved changes),
"partial" (approved behaviors not checked: their test was deselected),
"incomplete" (capture tests failed or errored; see incomplete), "stale"
(the baseline, capture or rejections moved since that run, so its verdict can't be
trusted: run again) or "unknown" (no report yet). generated_at says when
the run happened; store names the store read (dir: omit it, as for
nightward_run).
NameRequiredDescriptionDefault
dirNo

TDQS

A4.5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full behavioral burden and does so well. It discloses that the tool is a read operation, does not run tests, does not see subsequent code edits, and explains each boundary state including stale and unknown verdicts.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The main purpose and sibling distinction are front-loaded, and the detailed boundary-state explanations earn their place because there is no output schema. The description is somewhat dense with parentheticals and could be structured more cleanly, but it is not padded.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the absence of annotations and an output schema, the description does substantial work by explaining boundary values, generated_at, store, and the stale/incomplete conditions. It still leans on 'same shape as nightward_run' for some return details, but the core semantics an agent needs to call and interpret the tool are present.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

There is one optional parameter, dir, and schema description coverage is 0%. The description adds partial guidance by saying 'dir: omit it, as for nightward_run,' but it does not explain what dir means when supplied or what value to use, so it compensates only partially for the schema gap.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: 'Read the boundary signal of the LAST nightward_run.' It also distinguishes this tool from its sibling by saying it works 'without running tests,' so an agent can tell it apart from nightward_run without opening either schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives explicit usage guidance: read this tool for the last run's signal, but after changing code 'call nightward_run for a fresh verdict.' It also explains that this tool does not see edits made since that run, which directly tells the agent when not to rely on it.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 2 tool updatesv0.1.0
    • First observednightward_run
    • First observednightward_status

TDQS

A4.4/5.0

Scored across 2 tools

Disambiguation5/5

nightward_run executes pytest and produces a fresh verdict, while nightward_status only reads the last persisted report; the two are mutually exclusive in intent. Both descriptions explicitly warn against the classic confusion (status does not see newer code edits), which removes any residual ambiguity.

Naming Consistency4/5

Both tools share the same `nightward_` prefix with snake_case suffixes, so the set reads as one family. The only minor wrinkle is that `run` is a verb while `status` is a noun, so the pattern is prefix+action rather than a strict verb_noun scheme.

Tool Count3/5

Two tools is genuinely thin, even for a narrow boundary-gating server; the surface could plausibly expose behavior listing or store inspection. It is defensible because the toolset is deliberate and minimal, and approval is intentionally delegated to a human using the CLI.

Completeness3/5

The core loop (run, then re-read the last verdict) is covered end to end, including rich diagnostics about unapproved and unchecked behaviors. However, there is no way to enumerate stored behaviors, inspect a single behavior, initialize or refresh a baseline, and no approval path at all — the agent must fall back to asking a human for any state-changing operation.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables deterministic behavioral regression review of pull-request diffs by checking changed code against approved incident-derived contracts. It exposes read-only tools to assess diffs and pull requests, list and fetch contracts, and report outcomes over stdio without mutating repositories.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI coding assistants to scan a repository for code that cannot prove its usefulness, attribute it to AI-assisted or human commits, prove candidate deletions against the project's own tests, and pull a payoff report or run history, all without ever writing to the repository. The test command and any model judging stay under a person's control through environment configuration.
    1
    MIT