“Porque mis armas son los pinceles y mis municiones son las pinturas” — Peter de los Polvorines
espectacular
In brief: ah is a CLI that enforces a contract between your specs and your tests. You write behavioral specs in Markdown, attach a TOML contract to each scenario declaring which tests cover it, then run ah check in CI to catch drift.
espectacular is a behavioral verification tool for Rust CLI projects. It lets you write machine-readable specs that describe what your tool does, then continuously check that behavior with ah check. Each spec scenario is paired with a sidecar TOML contract that lists the tests verifying it — when a test is missing, unconfigured, or failing, ah check exits non-zero and blocks the merge.
- Installation & Quick Start — get
ahon your PATH and run your first check in minutes - Command Reference — all
ahsubcommands with flags, exit codes, and example output - Concepts — understand specs, scenarios, contracts, archetypes, and the gate model
Ecosystem
espectacular is part of the charly-vibes Tool Ecosystem — the map shows how it relates to its sibling tools.
Documentation provenance
Rendered docs are generated artifacts; the repository sources are authoritative — edit the repo sources, not the rendered output.
Release Status
Generated artifact (docs.yml, from Cargo.toml + git ref). Do not hand-edit — the repo source files remain authoritative.
- Version: v0.12.0
- Built from: main @ d362dfa
Installation & Quick Start
In brief: install ah, run ah init in your repo, then ah check to validate. The worked example below walks through writing a spec scenario, creating its contract, and seeing a passing check. Both ah and espectacular are installed — they are the same binary under two names.
Install
From source (requires Rust toolchain):
cargo install --path .
Or clone and build:
git clone https://github.com/charly-vibes/espectacular
cd espectacular
cargo build --release
# Binary is at target/release/ah
Verify:
ah --version
# ah 0.1.0
espectacular --version
# espectacular 0.1.0
Prerequisites
espectacular requires OpenSpec to manage your spec files. OpenSpec is the directory structure and tooling that stores specs under openspec/specs/ and staged changes under openspec/changes/. Run openspec init once in your repo to set it up before running ah init.
Set up espectacular
Run ah init once in the root of your repo:
ah init
This creates (or refreshes):
.espectacular/config.toml— runner and capability config.espectacular/AGENTS.md— guidance block for AI agents- Hook integration for
lefthookorprekif detected
If ah init reports concerns, use ah doctor to diagnose.
Run your first check
ah check
Output is always a JSON envelope:
{
"scope": { "deployed": true, "changes": [] },
"summary": { "structural": 0, "execution": 0, "passed": 2, "counts_by_kind": {} },
"findings": []
}
Empty findings and structural: 0, execution: 0 means everything is green. If you have specs without contracts yet, you’ll see no-toml findings instead — see When check finds something below.
Worked example
This walks through the full loop: open a change → write a scenario → create a contract → run the check.
Step 1 — Open a change
In OpenSpec, work-in-progress lives under a “change.” Create one:
openspec new my-feature
This creates openspec/changes/my-feature/specs/ where your staged spec lives.
Step 2 — Write a scenario
Edit (or create) the spec for the component you’re working on, e.g. openspec/changes/my-feature/specs/parser/spec.md:
### Requirement: Input validation
#### Scenario: Empty input is rejected
- **GIVEN** an empty string is passed to the parser
- **WHEN** the parser runs
- **THEN** it exits non-zero with a descriptive error message
Step 3 — Create the contract
Generate the contract stub:
ah scenario new my-feature parser \
--requirement "empty-input-is-rejected" \
"Empty input is rejected"
This creates .espectacular/changes/my-feature/parser/empty-input-is-rejected.toml. Open it and add your test:
id = "empty-input-is-rejected"
description = "Empty input is rejected before parsing."
archetype = "PF"
status = "active"
superseded_by = ""
authored_with = "0.1.0"
[[tests.pytest]]
flags = "tests/test_parser.py::test_empty_input_rejected"
timeout_seconds = 60
For archetype choices (PF, SA, BP, CE, NR) see Concepts — Archetypes or run ah type.
Step 4 — Check with the change in scope
ah check --changes my-feature
A passing run exits 0 with passed: 1 in the summary.
Step 5 — Archive when merged
Once the change merges, promote the contract to deployed:
ah archive my-feature
After this, ah check (without --changes) will include the scenario in its scope.
When check finds something
A failing check exits 1 and includes findings in the JSON. For example, if a scenario has no contract yet:
{
"scope": { "deployed": true, "changes": [] },
"summary": { "structural": 1, "execution": 0, "passed": 0, "counts_by_kind": { "no-toml": 1 } },
"findings": [
{
"kind": "no-toml",
"category": "structural",
"spec": "parser",
"spec_path": "openspec/specs/parser/spec.md",
"scenario": { "id": "empty-input-is-rejected", "title": "Empty input is rejected" },
"suggested_action": "run_ah_scenario_new",
"playbook_command": "ah explain run_ah_scenario_new"
}
]
}
Each finding includes a suggested_action and a playbook_command you can run directly:
ah explain run_ah_scenario_new
Use ah explain --list to see all explainable topics.
What’s next
- Command Reference — all
ahsubcommands with flags and examples - Concepts — understand specs, contracts, archetypes, and the gate model
Command Reference
In brief: ah is the espectacular CLI — espectacular is an alias for the same binary. The primary commands are ah check (run in CI to enforce specs) and ah init (run once per repo to set up). Use ah explain <topic> to get guidance on any finding or error.
ah init
Create or refresh .espectacular/ files and hook integration.
ah init
Idempotent: safe to re-run after updating specs or changing hook frameworks. Stubs contract files for any scenarios that have no existing contract. Installs ah check into lefthook or prek if detected.
Exit codes: 0 on success, non-zero if the OpenSpec directory is missing.
ah check
Validate deployed specs and run declared tests. Prints a stable JSON envelope to stdout.
ah check [--changes <id>]
Flags:
| Flag | Description |
|---|---|
--changes <id> | Include one or more staged change overlays (repeat for multiple) |
Exit codes: 0 when findings contains no structural or execution findings; 1 otherwise. Quality findings (quality-*) never cause a non-zero exit.
Note: All JSON examples below show the data field of the genesis envelope — actual output wraps these fields in {ok, envelope_version, cli_version, envelope_kind, data, warnings, hints, meta}.
Example — clean run:
ah check
{
"ok": true,
"envelope_version": "0.1",
"cli_version": "0.4.0",
"envelope_kind": "ok",
"data": {
"scope": { "deployed": true, "changes": [] },
"summary": { "structural": 0, "execution": 0, "passed": 3, "counts_by_kind": {} },
"findings": []
},
"warnings": [],
"hints": [],
"meta": { "duration_ms": 0, "tx": null, "request_id": null, "author": null }
}
Example — with a staged change:
ah check --changes add-parser-validation
{
"scope": { "deployed": true, "changes": ["add-parser-validation"] },
"summary": { "structural": 0, "execution": 0, "passed": 4, "counts_by_kind": {} },
"findings": []
}
Example — with a finding:
{
"scope": { "deployed": true, "changes": [] },
"summary": { "structural": 1, "execution": 0, "passed": 0, "counts_by_kind": { "no-toml": 1 } },
"findings": [
{
"kind": "no-toml",
"category": "structural",
"spec": "parser",
"spec_path": "openspec/specs/parser/spec.md",
"scenario": { "id": "empty-input-is-rejected", "title": "Empty input is rejected" },
"suggested_action": "run_ah_scenario_new",
"playbook_command": "ah explain run_ah_scenario_new"
}
]
}
Run the playbook_command from any finding to get step-by-step guidance:
ah explain run_ah_scenario_new
Finding kinds:
| Kind | Category | Meaning |
|---|---|---|
no-toml | structural | scenario has no matching contract file |
orphan-toml | structural | contract exists without a matching scenario |
slug-collision | structural | two scenarios in one spec have the same id |
id-mismatch | structural | scenario slug, filename, and TOML id disagree |
no-tests-declared | structural | contract has no runnable test entries |
missing-runner | structural | a non-shell test type has no configured runner |
malformed-contract | structural | TOML cannot be parsed or validated |
missing-replacement | structural | superseded contract points to an absent replacement |
overlay-conflict | structural | selected changes define conflicting staged scenarios |
test-failing | execution | a declared test timed out or exited non-zero |
quality-mutation | quality | mutation score meets threshold (informational) |
quality-property | quality | property-based tests passing (informational) |
quality-snapshot | quality | snapshot tests passing (informational) |
ah lint
Statically analyze spec files for authoring-quality findings. Heuristic checks plus a specodelic (spk lint) bridge.
ah lint [<root>] [--changes <id>] [--check <kind>]
Arguments:
| Argument | Description |
|---|---|
<root> | Optional repo root to lint (defaults to the current directory) |
Flags:
| Flag | Description |
|---|---|
--changes <id> | Lint a change’s spec overlay in addition to deployed specs |
--check <kind> | Run only a single check category (e.g. vague-qualifier) |
Checks: vague-qualifier, imperative-step, conjunctive-bloat (configurable max and-steps, default 5), missing-negative-scenario, missing-non-goals, unresolved-ambiguity, entangled-spec, verifies-dangling, scenario-unlinked, property-untraced, plus spk.<rule_id> relays from the specodelic linter and spk.graph.typing / spk.graph.dangling relays from spk graph.
A spec file that cannot be parsed emits an error-severity finding instead of aborting the walk. A kind: intent file the dual-format bridge would skip emits spk-frontmatter-mismatch naming the expected id.
Exit codes: 0 always — lint findings are advisory; they do not gate.
ah sync
Create or refresh property-derived contracts from lint-clean specodelic Properties rows (design D1/D3 of derive-contracts-from-specodelic). See dual-format authoring for the coverage convention.
ah sync [--check] [--json]
Flags:
| Flag | Description |
|---|---|
--check | CI mode: write nothing; report missing and stale contracts; exit non-zero on drift |
--json | Machine-readable envelope |
ah sync runs spk lint first and refuses to derive from a lint-dirty file; missing spk fails with spk-unavailable. Sync-owned fields (id, description, archetype, falsifiability_class, derived_from) are authoritative and rewritten; human-owned fields (tests, status, superseded_by) are never overwritten.
Exit codes: 0 on success; 1 on refusal, unavailable spk, or drift/missing in --check mode.
ah doctor
Detect configured frameworks and diagnose config, path, hook, and archetype issues.
ah doctor [--enable <capability>]
Flags:
| Flag | Description |
|---|---|
--enable <capability> | Write the config block for a detected-but-unconfigured capability |
Capabilities for --enable: pytest, cargo, vitest, mutation, property, snapshot
Example output:
framework: pytest (configured)
recommendation: vitest detected via manifest — run: ah doctor --enable vitest
Example — enable vitest:
ah doctor --enable vitest
Writes the [runners.vitest] entry to .espectacular/config.toml. If already configured, exits 0 with an “already enabled” message.
Exit codes: 0 when no problems are found (recommendations do not affect exit code); 1 when structural problems exist. --enable exits 0 on success, non-zero for unknown capabilities.
ah doctor --json
Emit diagnostic output as JSON, with each recommendation appearing as a structured finding.
ah doctor --json
{
"findings": [
{
"kind": "recommendation",
"suggested_action": "enable_capability",
"playbook_command": "ah explain enable_capability",
"apply_command": "ah doctor --enable cargo",
"detail": "cargo detected via manifest",
"capability": "cargo"
}
]
}
Each recommendation finding carries playbook_command and apply_command for agent-consumable remediation.
Exit codes: same as ah doctor.
ah report
Display a conformance coverage matrix across all deployed specs and archetypes.
ah report [--json]
Flags:
| Flag | Description |
|---|---|
--json | Emit the matrix as JSON |
Text output (default):
spec archetype covered missing failing total
compiler 0 0 0 3
adapters 0 0 0 1
parser 0 1 0 1
covered: 3 | missing: 1 | failing: 0 | total: 5
JSON output (--json):
{
"matrix": [
{
"spec": "compiler",
"archetype": "",
"covered": 3,
"missing": 0,
"failing": 0,
"total": 3
}
],
"summary": {
"total_scenarios": 5,
"total_contracts": 4,
"covered": 3,
"missing": 1,
"failing": 0
}
}
Exit codes: 0 when all scenarios are covered by contracts; 1 when any scenarios are missing or failing.
ah explain
Print guidance for a finding kind or suggested action.
ah explain [<topic>] [--list] [--json]
Arguments:
| Argument | Description |
|---|---|
<topic> | Finding kind or action slug to explain |
Flags:
| Flag | Description |
|---|---|
--list | List all available topics |
--json | Emit the topic list as JSON (use with --list) |
Example — explain a finding:
ah explain no-toml
## no-toml — Missing contract file
A scenario declared in a spec file has no corresponding contract .toml file
in .espectacular/<component>/.
How to fix: run `ah scenario new` with the spec, scenario id, and heading
to generate the contract stub, then populate the test entries.
ah scenario new <change> <spec> --requirement <scenario-id> <heading>
Example — list all topics:
ah explain --list
Exit codes: 0 on success; 1 for unknown topics (includes “did you mean” suggestions).
ah signals
Read dont rejection events and emit drift signals as JSON.
ah signals
dont is a companion tool that tracks epistemic claims made by AI agents. When an agent makes a claim that is later rejected, dont records it as an event. ah signals reads those events from .dont/events/*.json and re-emits them as structured DriftSignal JSON that CI or the wai project-context tool can consume to surface spec-behavior gaps.
Exit codes: always 0; returns an empty JSON array if no events are found.
ah type
List built-in archetypes, or print full documentation for one.
ah type [<code>]
Example — list all:
ah type
PF — Pure Functional: Deterministic behavior where outputs are a function of explicit inputs.
SA — Stateful API: Behavior involving state transitions, persisted data, or ordered operations.
BP — Boundary Protocol: Behavior at an external boundary or protocol seam.
CE — Contract/Event: Behavior expressed as emitted events, messages, claims, or cross-tool signals.
NR — Non-Regression: Behavior asserting existing guarantees remain true while nearby changes land.
Example — full docs for one archetype:
ah type PF
## PF — Pure Functional
Deterministic behavior where outputs are a function of explicit inputs.
Use for:
- parsers
- formatters
- validators
- pure transformations
- deterministic calculations
Typical test shapes:
- unit examples for representative inputs
- property-based tests for invariants
- boundary input examples
Exit codes: 0 on success; 1 for unknown archetype codes (includes “did you mean” suggestions).
ah scenario new
Append a new scenario to a spec and stage its TOML contract.
ah scenario new <change> <spec> --requirement "<scenario-id>" "<heading>"
Arguments:
| Argument | Description |
|---|---|
<change> | Change id (the change directory must exist under openspec/changes/) |
<spec> | Spec name (e.g. parser) |
--requirement | Requirement grouping name — must match a ### Requirement: heading already in the spec; the new #### Scenario: is appended under it |
<heading> | Human-readable scenario heading to append |
Appends the scenario under the named requirement block and creates the contract stub at .espectacular/changes/<change>/<spec>/<scenario-id>.toml.
Exit codes: 0 on success; 1 if the change or spec is missing, or the requirement block is absent.
ah scenario supersede
Stage a supersession update for an existing contract.
ah scenario supersede <spec> <old-id> --with=<new-id> --in-change=<change>
Marks the old contract status = "superseded" and sets superseded_by to the new scenario id. The replacement scenario must already exist in the named change overlay.
Exit codes: 0 on success; 1 if the deployed contract or replacement is missing.
ah archive
Move staged change contracts into deployed .espectacular/ locations.
ah archive <change>
Run after a change merges. Moves TOML files from .espectacular/changes/<change>/ into .espectacular/<spec>/, failing if any collision would overwrite an active contract without a supersession in place.
Exit codes: 0 on success; 1 on collision or missing staged change.
ah upgrade
Report tool-version drift and update .espectacular/config.toml.
ah upgrade
Compares tool_version in .espectacular/config.toml with the running binary version. Updates the config if they differ, then exits non-zero so CI can detect compatibility changes.
Exit codes: 0 when versions match; 1 when drift is detected (even after updating the config).
Implementation Status
This page maps the deployed behavioral specs to the commands and capabilities that implement them. Each row is a requirement in a spec (its scenarios under it); if ah check passes, that behavior is verified in CI.
Last refreshed from the deployed corpus at v0.11.0 (spec scenario counts re-derive any time via openspec/specs/*/spec.md).
Deployed specs
gate — Core verification engine (91 scenarios)
Covers what ah check does: scenario discovery, contract correspondence, test execution (including batched structured-output runners and range-based selection), JSON output, agent-actionability, quality signals, and property-derived contracts with ah sync.
| Requirement | What it verifies |
|---|---|
| Scenario Discovery | ah check finds all scenarios from spec headings; mirrored headings deduplicate; same-id/different-body collisions fail |
| Sidecar Contract Correspondence | every scenario has a contract; every contract has a scenario |
| Contract Schema | TOML contracts validate against the schema; superseded status requires a replacement |
| Test Runner Execution | declared tests run per-binding with exit-code verdicts; eligible structured-output bindings batch into one invocation above the threshold (with per-binding attribution, matched-zero, fallback, timeout semantics) |
| JSON Findings | output is a stable JSON envelope with scope/summary/findings |
| Change Overlay Scope | --changes adds staged scenarios; illegal overlays are rejected |
| Non-Regression Archetype | NR contracts run in overlay scope |
| Deterministic Scope Boundary | the gate never inspects test internals or hashes prose |
| Agent-action fields | every finding carries suggested_action, playbook_command, and conditionally apply_command |
| Quality measurement capabilities | quality-* findings are informational; tool failures fail the gate |
| Quality contract schema | quality config is not a test entry; property/snapshot are runnable |
| Conformance coverage matrix | per-spec/per-archetype coverage, machine-readable |
| Property-Derived Contracts | lint-clean dual-format specs derive one contract per Properties row (ah sync, with stale-drift detection and human-owned field preservation) |
| Dual-Format Spec Corpus | dual-format spec discovery and gating behave byte-identically for plain specs and for dual-format specs without Properties rows |
| Range-Based Test Selection | ah check --run-tests --base/--head selects by commit range with total precedence and conservative fallback |
(All gate scenarios are verified — run ah check --run-tests for the live count.)
cli — Command surface (53 scenarios)
Covers the full ah command interface: init, check, doctor, report, explain, type, scenario, archive, upgrade, lint, signals, feedback, sync, completions — plus JSON-envelope exit semantics (OK agrees with the exit code) and recommendation findings.
| Requirement | What it verifies |
|---|---|
| CLI Command Name | binary is named ah |
| Project Initialization | ah init creates .espectacular/ and hook integration |
| Correspondence Check Command | ah check validates specs and runs tests |
| Spec Lint Command | ah lint analyzes spec files for authoring-quality findings |
| Sync Command | ah sync derives contracts from Properties rows (--check mode for CI) |
| Health Check Command | ah doctor diagnoses config, paths, hooks, archetypes |
| Archetype Documentation Commands | ah type lists and explains archetypes |
| Scenario Lifecycle Commands | ah scenario new and ah scenario supersede |
| Archive Companion Command | ah archive promotes staged contracts |
| Upgrade Command | ah upgrade detects and reports tool-version drift |
| Doctor enable flag | ah doctor --enable <capability> writes config blocks |
| Explain subcommand | ah explain prints guidance for finding kinds and actions |
| Coverage report command | ah report displays a conformance coverage matrix |
| Recommendation findings | findings that need human review are flagged as such |
lint — Spec-quality heuristics (32 scenarios)
Covers the ah lint checks: vague qualifiers, imperative steps, conjunctive bloat, missing negative scenarios, missing non-goals, unresolved ambiguity, entangled specs, finding schema, and the specodelic bridge (dual-format relay + relay completeness + property trace).
| Requirement | What it verifies |
|---|---|
| Vague Qualifier Detection | vague-qualifier precision (bounded qualifiers not flagged) |
| Imperative Step Detection | imperative-step detection |
| Conjunctive Step Bloat Detection | conjunctive-bloat with configurable limit |
| Missing Negative Scenario Detection | happy-path-only requirements flagged |
| Missing Non-Goals / Unresolved Ambiguity / Entangled Specification Detection | structural authoring checks |
| Lint Finding Schema | findings validate the lint schema |
| Dual-Format Lint Bridge / Relay Completeness / Scenario Property Trace | specodelic rule findings relay through ah lint; scenario/property trace advisories |
adapters — Language adapter dispatch (27 scenarios)
Covers how ah check maps test types to runners and normalizes output.
| Requirement | What it verifies |
|---|---|
| Adapter detection precedence | config > manifest > binary on PATH |
| Python pytest adapter | pytest detection, invocation, failure normalization |
| Rust cargo test adapter | cargo detection, invocation, failure normalization |
| TypeScript vitest adapter | vitest detection, invocation, failure normalization |
| No-adapter-configured path | missing-runner finding when type has no runner |
| Custom runner plugin protocol | custom runners emit JSON envelopes parsed by ah check |
explain — Playbook system (19 scenarios)
Covers the ah explain topic system and its compile-time completeness guarantee.
| Requirement | What it verifies |
|---|---|
| Playbook is compile-enforced | every finding kind has an ah explain topic at compile time |
| Topic coverage | all finding kinds and suggested actions are covered |
| Structured JSON output | --json emits a machine-readable topic list |
| Topic listing / Quality finding topics / Adapter topics | listing shape; quality and adapter topics ship with their features |
| Unknown topic handling | unknown topics exit 1 with “did you mean” suggestions |
config, cli-core, spec-authoring — Infrastructure (16 scenarios)
Shared config management (4), CLI scaffold guide + ah sync command (7 across 2 requirements), dual-format spec authoring (5).
How to read this page
- Deployed means the scenario has a passing contract in
ah checkonmain. - Planned means the scenario is staged in an OpenSpec change but not yet implemented (none currently — all active changes are fully implemented and archived).
- Run
ah checklocally to see current pass/fail state. - Spec source lives in
openspec/specs/.
Concepts
In brief: A spec declares what your tool should do. A contract says which tests verify one scenario in that spec. ah check is the gate — it exits non-zero when a contract is missing or a test fails. See the worked example in Installation to see these pieces together.
The mental model
espectacular enforces a contract between what you said your tool does (specs) and whether it actually does it (tests). Each behavioral claim lives in a spec file; each claim has a sidecar TOML contract that says how to verify it. ah check validates that every claim has a contract and that every contract’s tests pass.
Specs
A spec is a Markdown file under openspec/specs/<name>/spec.md. It describes the intended behavior of one component using ### Requirement: groupings and #### Scenario: headings nested under them.
### Requirement: Input validation
#### Scenario: Empty input is rejected
- **GIVEN** an empty string is passed to the parser
- **WHEN** the parser runs
- **THEN** it exits non-zero with a descriptive error message
Specs are checked into version control. They are append-only: you never rewrite a deployed scenario’s intent — instead you add new scenarios or supersede old ones. The git log becomes a traceable record of when each behavior was declared, extended, or superseded.
Scenarios
A scenario is one #### Scenario: heading nested under a ### Requirement: grouping in a spec file. It has:
- A slug — derived from the scenario heading (by convention, lowercased with hyphens, e.g.
empty-input-is-rejected). This slug must match the contract filename and theidfield inside it. - A body in Given/When/Then form describing the behavior
ah check discovers scenarios by parsing #### Scenario: headings from spec files. Each scenario must have a corresponding contract file, or ah check emits a no-toml finding. Run ah explain no-toml for the fix.
Contracts
A contract is a TOML file at .espectacular/<spec>/<scenario-id>.toml. It is the machine-readable pairing between a scenario and the tests that verify it.
id = "empty-input-is-rejected"
description = "Empty input is rejected before parsing."
archetype = "PF"
status = "active"
superseded_by = ""
authored_with = "0.1.0"
[[tests.pytest]]
flags = "tests/test_parser.py::test_empty_input_rejected"
timeout_seconds = 60
Required fields: id, description, archetype, status, superseded_by, authored_with, tests.
The filename, the id field, and the scenario slug must all agree — ah check emits id-mismatch if they disagree.
Falsifiability class
Contracts may declare an optional falsifiability_class:
"safety"— the scenario asserts a safety claim: a bad state never occurs. A single deterministic test can falsify it. Annotation-only in v1; it records the author’s judgment and gives downstream tooling a hook."liveness"— the scenario asserts a liveness claim: a good state eventually occurs. A liveness claim is only falsifiable under bounded execution, so if every test entry omitstimeout_seconds,ah checkemits amissing-liveness-timeoutwarning suggesting an explicit timeout. Warnings do not fail the gate.
The field is optional and absent by default — contracts without it validate exactly as before. Invalid values emit an invalid-falsifiability-class structural finding and fail the gate.
Test entry types
| Entry | How it runs |
|---|---|
[[tests.shell]] | Runs command directly via /bin/sh -c |
[[tests.pytest]] | Prepends the configured pytest runner to flags |
[[tests.cargo]] | Prepends the configured cargo runner to flags |
[[tests.vitest]] | Prepends the configured vitest runner to flags |
[[tests.custom]] | Invokes a custom runner and parses its JSON envelope |
[[tests.<type>]] | Any other type, looked up in [runners.<type>] in config |
Staged contracts
During a change in progress, contracts live under .espectacular/changes/<change>/<spec>/. After the change merges, ah archive <change> moves them into .espectacular/<spec>/. See Installation — Step 5.
Archetypes
Every contract declares an archetype, a short code that classifies the kind of behavior being verified. Archetypes guide test design: a PF scenario should have deterministic unit tests; an SA scenario needs to cover state transitions.
| Code | Name | Description |
|---|---|---|
PF | Pure Functional | Deterministic behavior; outputs are a function of explicit inputs |
SA | Stateful API | State transitions, persisted data, or ordered operations |
BP | Boundary Protocol | Behavior at an external boundary or protocol seam |
CE | Contract/Event | Emitted events, messages, claims, or cross-tool signals |
NR | Non-Regression | Existing guarantees remain true while nearby changes land |
Run ah type <code> for full documentation on any archetype, or ah type to list all.
The gate
ah check is the enforcement gate. It:
- Discovers all scenarios from deployed specs (and staged change overlays if
--changesis passed) - Checks structural correspondence: every scenario has a contract, every contract has a scenario, no collisions, no orphans
- Runs every declared test
- Emits a JSON envelope and exits 0 if clean, 1 if any structural or execution finding exists
Local pre-commit (ah init installs this): catches structural findings before git push instead of in CI.
CI (ah check in a workflow step): the enforcement gate, source of truth.
Quality findings (quality-mutation, quality-property, quality-snapshot) are informational — they appear in the output but never cause a non-zero exit. See Command Reference — ah check for the full finding kind table.
Adapters
An adapter maps a test type name to the right runner invocation and normalizes failure output into a common shape. espectacular ships built-in adapters for pytest, cargo, and vitest.
Detection signals (highest-priority source wins):
| Adapter | Explicit config | Manifest detection | Binary detection |
|---|---|---|---|
pytest | runners.pytest in config | pyproject.toml [tool.pytest], pytest.ini | pytest on PATH or .py with import pytest |
cargo | runners.cargo in config | Cargo.toml present | — |
vitest | runners.vitest in config | package.json devDependency | — |
ah doctor shows which frameworks are detected and their source. Use ah doctor --enable <framework> to write the config entry for a detected-but-unconfigured adapter.
Custom runners
A [[tests.custom]] entry invokes a configured runner and expects a JSON envelope on stdout:
{ "exit_code": 0, "passed": true, "findings": [] }
Non-zero exit_code or passed: false maps to a test-failing finding. The findings array lets custom runners emit structured findings directly into ah check output.
Quality signals
Quality signals are optional capabilities that surface test health metadata as findings:
| Signal | What it tracks |
|---|---|
quality-mutation | Mutation testing kill rate vs. threshold |
quality-property | Property-based testing is active and passing |
quality-snapshot | Snapshot testing is active and passing |
Enable them in .espectacular/config.toml or via ah doctor --enable mutation (etc.). Quality findings are informational and never cause ah check to exit non-zero.
File layout
openspec/
└── specs/<spec>/spec.md # deployed spec source
.espectacular/
├── config.toml # runner and capability config
├── AGENTS.md # AI agent guidance
├── <spec>/
│ └── <scenario-id>.toml # deployed contracts
└── changes/
└── <change>/
└── <spec>/
└── <scenario-id>.toml # staged change contracts
schemas/
├── check-output.schema.json # ah check JSON envelope
├── config.schema.json # .espectacular/config.toml
├── scenario-contract.schema.json # contract TOML files
└── custom-runner.schema.json # custom runner JSON envelope
Agent Workflow: ah check as Specification Gate
This document describes how AI agents discover and use ah check — the
espectacular verification gate — in the standard development cycle. It is
extracted from observed patterns across the charly-vibes project ecosystem.
How Agents Discover ah check
Agents learn about ah check through the ah:managed block in a
project’s AGENTS.md (or CLAUDE.md). This block is deployed by ah init
and refreshed by ah init.
<!-- ah:managed:start -->
## espectacular
Run `ah check` to verify spec-test correspondence before committing.
- `ah check` — validate all deployed specs
- `ah check --changes <name>` — validate with a change overlay
- `ah init` — set up or refresh espectacular project files
- `ah doctor` — diagnose setup issues
- `ah explain <topic>` — playbook guidance for finding kinds
- `ah doctor --enable <adapter>` — write adapter config into config.toml
- `ah signals` — emit dont drift signals
<!-- ah:managed:end -->
Key design decisions:
- The block is compact (fits in ~15 lines of agent context) — agents scan it
early in a session and recognize
ah checkas a commit gate. - It lists only the most common commands —
ah explainprovides the full playbook for less frequent tasks. - The
ah:manageddelimiter signals to agents that this section is auto-generated and authoritative.
Typical Usage Patterns
1. Pre-commit gate (most common)
The standard loop when working on OpenSpec projects:
edit → ah check → fix → ah check → commit
Concrete example:
This pattern was observed in testaruda pi sessions, where
ah checkwas the primary feedback loop during contract wiring and spec changes. Source sessions:2026-07-08T15-35-34-510Z,2026-07-16T01-38-10-034Z.
# 1. Make changes to spec or code
# 2. Run the gate
ah check
# 3. If findings appear, fix them
# 4. Re-run to verify
ah check
# 5. Commit when green
git add -p
git commit -m "fix: ..."
When to run ah check:
| Situation | Whether to run |
|---|---|
| Modified spec files | Always |
| Modified contract TOML files | Always |
| Modified production code tracked by contracts | Always |
| Edited tests only | Optional (contracts pass/fail) |
| Edited docs only | Usually not needed |
| Changed a change overlay | ah check --changes <name> |
2. Change-overlay validation
When a staged OpenSpec change is in progress:
ah check --changes add-parser-validation
This validates the base spec plus the overlay’s additional scenarios, without requiring the overlay to be archived first.
Multiple overlays can be validated together by repeating the flag:
ah check --changes add-parser-validation --changes fix-dangling-contracts
Useful when interdependent changes are being developed simultaneously.
3. Fresh project setup
ah init # Scaffold .espectacular/, stub contracts
ah doctor # Verify setup is complete
ah doctor --enable cargo # Configure runner
ah check # Run initial validation
4. Post-ah init verification
After bootstrapping a new project, ah init stubs contracts for every
scenario missing one. The pattern is:
ah init # Stub missing contracts
ah check # Verify — expect structural findings for no-tests-declared
# Fill in test commands for each stub contract, then:
ah check # Green — all contracts wired
git add -A
git commit -m "chore: init espectacular and stub contracts"
How Findings Are Interpreted
ah check emits a JSON envelope with a findings array. Each finding has a
kind, category, and suggested_action that agents can act on.
Finding categories
| Category | Meaning | Exit code impact |
|---|---|---|
structural | Spec/contract mismatch (missing contract, orphan, duplicate ID) | Non-zero |
execution | Contract test failed or timed out | Non-zero |
quality-* | Mutation score below threshold, property coverage gap | Zero (advisory) |
Common findings and agent responses
| Finding kind | What it means | Agent action |
|---|---|---|
missing-contract | A scenario has no contract file | ah explain run_ah_scenario_new → ah scenario new |
orphan-contract | A contract file has no matching scenario | Remove or archive the contract |
no-tests-declared | Contract exists but has no test commands | Fill in [[tests.shell]] or [[tests.flags]] |
collision | Two specs declare the same scenario ID | Rename one scenario heading |
test-failing | A contract test exited non-zero | Inspect test output, fix code |
no-tests-ran | Shell test ran no tests (exit 0, no output) | Run ah check --json to inspect the full captured output. The contract’s [[tests.shell]] command may not match any test files, or the adapter may not be detecting the test framework. Check config and test command. |
duplicate-id | Same scenario ID appears in two specs | Deduplicate |
mutation-below-threshold | Mutation kill rate too low | Add or improve test cases |
Before starting work, ensure ah is available in PATH:
which ah || cargo install --git git@cv:charly-vibes/espectacular.git
Quick reference: interpreting output
{
"summary": { "structural": 0, "execution": 0, "passed": 3, "counts_by_kind": {} },
"findings": []
}
→ Green. All checks pass. Ready to commit.
{
"summary": { "structural": 1, "execution": 0, "passed": 0, "counts_by_kind": { "missing-contract": 1 } },
"findings": [ { "kind": "missing-contract", "category": "structural", ... } ]
}
→ Structural finding. A scenario has no contract file. Run ah scenario new
to create one, then ah check again.
{
"summary": { "structural": 0, "execution": 1, "passed": 2, "counts_by_kind": { "test-failing": 1 } },
"findings": [ { "kind": "test-failing", "category": "execution", ... } ]
}
→ Execution finding. A contract test failed. Fix the code, then ah check
again.
For full guidance on any finding kind:
ah explain <finding-kind>
CI/script usage
In CI pipelines or automated scripts, use --json for machine-readable
output:
ah check --json
# Exit code: 0 if no structural/execution findings, 1 otherwise
The --run-tests flag forces contract test execution even when no spec
changes are detected (useful in CI when dependencies may have changed).
Test selection (avoiding full contract runs)
By default ah check --run-tests executes every declared contract test.
When a changed-file set is known, pass it to narrow the run:
# Explicit file list (repo-relative, comma-separated)
ah check --run-tests --files src/check.rs,openspec/specs/compiler/spec.md
# Omit --files to default to the worktree git diff
ah check --run-tests
# Escape hatch: ignore changes and run everything
ah check --run-tests --all-tests
Selection is layered and conservative — it never produces a hidden green:
- Capability selection (always on): changed files are mapped to
capabilities via spec provenance (deployed specs, change-overlay specs,
and contract trees). Only affected capabilities’ contracts run. Files
that map to no capability trigger a conservative run-all, reported as
source: "bypassed"in the JSONselectionobject. - testaruda refinement (when a
.testaruda/store exists): within the run, contract bindings whose runner filter matches nothing intestaruda select --agent’s selected test set are pruned. Guards keep this safe: an EMPTY selection (exit 20 — no code affected) skips the refinement but still runs the capability set; unresolved changed files or a selection where no binding matches disable pruning entirely. Pruning is reported asselection.testaruda.prunedin JSON.
The JSON selection object summarizes what ran:
"selection": {
"selected": ["compiler"],
"skipped": 3,
"source": "spec-provenance",
"unmapped_files": 0,
"testaruda": { "pruned": 1 }
}
source is spec-provenance (capability selection narrowed the run),
all-tests (escape hatch), or bypassed (conservative run-all — e.g. the
changed set contains files that map to no capability).
Forcing a commit through findings
On a WIP branch or when findings are acceptable, you can commit despite non-zero exit. This is common during staged change development:
ah check --changes my-change
# If findings are scoped to the change and expected, proceed
git commit -m "wip: my change"
For CI-only enforcement, run ah check in CI and allow local commits to
pass — the managed block in AGENTS.md should still instruct agents to
resolve findings.
Commit Workflow with ah check as Gate
Standard workflow
1. bd claim <ticket> # Claim work
2. git pull --rebase # Sync
3. [edit code / specs] # Make changes
4. ah check # Verify spec-test correspondence
5. [fix findings] # Iterate until green
6. git add <files> # Stage specific files (never git add -A)
7. git commit -m "..." # Commit with descriptive message
8. ah check # Verify pre-push (catches regressions from merge)
9. bd close <ticket> # Close ticket
When findings are expected
Some tickets produce findings that are expected and resolved within the
same ticket. Example: a ticket that adds a new scenario creates a
missing-contract finding until the contract file is written.
In those cases, the workflow is:
1. ah check # Baseline — note existing findings
2. [edit code / specs] # Make changes
3. ah check # Verify — expected findings should match ticket scope
4. [fix unexpected findings]
5. ah check # Green — all expected findings resolved
6. git add && git commit
Pre-push hooks (optional)
Projects with lefthook or prek can integrate ah check as a pre-push
hook. This is configured by ah init and runs automatically:
# .lefthook.yml (auto-generated by ah init)
pre-push:
commands:
ah-check:
run: ah check
skip: false # Block push on failure
If you want ah check to run but not block the push (advisory mode), set
skip: true. This lets you push while still seeing the output.
The actual config generated by ah init uses skip: false by default.
---
## Agent Workflow Diagram
┌─────────────────────────────────────────────────────────┐
│ Session Start │
│ │
│ 1. Agent reads AGENTS.md → discovers ah:managed block │
│ 2. Agent runs ah check to establish baseline │
│ 3. Agent reads any existing findings │
└─────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Development Loop │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Edit │───▶│ah check │───▶│ Fix │ │
│ │ code/ │ │ │ │ findings │──┐ │
│ │ specs │ └──────────┘ └──────────┘ │ │
│ └──────────┘ ▲ │ │
│ │ findings remain │ │
│ └────────────────────────┘ │
│ │ │
│ green │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ ah check passes │ │
│ └────────┬──────────┘ │
└─────────────────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Commit │
│ │
│ 1. git add
---
## Adding `ah check` to a New Project
For agent operators who want to replicate this pattern:
```bash
# 1. Install ah
cargo install --git git@cv:charly-vibes/espectacular.git
# 2. Ensure the project has an openspec/ directory
# (the root AGENTS.md or CLAUDE.md will be updated
# automatically by ah init)
# 3. Run init
ah init
# 4. Verify
ah doctor # Should show healthy
ah check # Should have findings from stub contracts
# 5. Verify the ah:managed block was written to AGENTS.md
# (ah init does this automatically — no manual edits needed)
Sources
This document is extracted from observed agent workflow patterns in the following pi sessions:
2026-07-08T15-35-34-510Z— Heavyah checkusage during contract wiring for the v0.2.0 release (espectacular project)2026-07-16T01-38-10-034Z— Recent workflow showing the edit → ah check → fix → ah check → commit cycle (testaruda project)
Both sessions demonstrate the ah check feedback loop in practice.
Testaruda was the first OpenSpec project to adopt ah check as a regular
development gate, and its patterns were replicated across the charly-vibes
ecosystem.
Audit: Spec-Validation Patterns Across Charly Projects
Ticket: espectacular-892
Scope: All charly-vibes OpenSpec projects (2026-04 through 2026-07)
Source session: 019f72d1-e2bf-7d6f-a707-403228d4ee3b
Executive Summary
Before ah check was deployed to all 13 charly projects on 2026-07-20,
agents had no standard tool for spec-test correspondence validation.
They relied on a mix of openspec validate --strict (structure-only),
cargo test / just test (test-only), and manual inspection. This audit
documents those patterns as evidence for the pi extension ticket
(espectacular-c9v).
Patterns Observed
Pattern 1: openspec validate --strict (primary pre-espectacular tool)
Used consistently from April through July 2026 across all projects with OpenSpec specs. Validates spec structure (headings, scenarios, IDs) but does not verify contracts or tests.
openspec validate --strict
openspec validate --all --strict
openspec validate --specs --strict
openspec validate add-change-name --strict
Found in: 22+ log entries across 10 projects (atril, fabbro, fotos, khipu, miblioteca, nayra, paranoid, pretender, wai, tRAGar).
Pain points:
--strictflag required every invocation (easy to forget)- No test execution integration — agents still needed to run
cargo testseparately - No contract file validation — only spec structural checks
- No JSON output for CI/script consumption
- Exit code behavior was unclear
Pattern 2: cargo test / just test as spec-validation proxy
Agents frequently used test runners as a proxy for spec verification,
often paired with openspec validate:
just test && openspec validate --all --strict
Found in: 15+ log entries across 7 projects.
Pain points:
- Two separate commands for one validation intent
- No feedback that tests correspond to specs — only that tests pass
- Projects without
openspec validateintegration just rancargo testand assumed spec alignment
Pattern 3: Manual spec-to-test reading (no tool)
Agents read spec files and test files manually to verify correspondence:
"read spec" → "read test file" → "compare scenarios" → "fix" → "commit"
Found in: Occasional entries where agents reviewed scenarios alongside test files (2026-04-20 atril session, 2026-04-30 REPLy.jl).
Pain points:
- Entirely manual — no automation
- High cognitive load for contract wiring
- Easy to miss scenarios or leave orphans
Pattern 4: ah check (current — espectacular)
After espectacular v0.2.0, the intended tool:
ah check # Fast static validation
ah check --changes <name> # With staged change overlay
ah check --json # Machine-readable
Deployment history:
- 2026-06-18: First dogfooded in espectacular itself
- 2026-07-08: v0.2.0 — all espectacular contracts wired, 0 findings
- 2026-07-15: v0.2.2 — human-readable output (silent JSON before)
- 2026-07-20: Deployed to all 13 openspec projects
- 2026-07-21: Performance fix (
--run-testsflag, fast default)
Gap (pre-deployment): Only 2/27 projects had ah check available.
Agents didn’t use it because it wasn’t there to discover.
Pain Points (Evidence for espectacular-c9v)
| # | Pain Point | Before | After | Severity |
|---|---|---|---|---|
| 1 | Two-step validation | openspec validate --strict + cargo test | ah check does both | HIGH |
| 2 | No contract verification | openspec validate only checked spec structure | ah check validates contracts + runs tests | HIGH |
| 3 | No CI integration | openspec validate JSON output unclear | ah check --json has stable schema | MEDIUM |
| 4 | Agent discovery | No AGENTS.md block → agents didn’t know to validate | ah:managed block in AGENTS.md | HIGH |
| 5 | Manual spec-test mapping | Agents read spec + test files by hand | ah check reports missing/orphan contracts | MEDIUM |
| 6 | Forgotten --strict | Easy to validate without the right flags | ah check defaults to comprehensive | LOW |
| 7 | No test output in findings | openspec validate had no test execution | ah check captures test output in findings | MEDIUM |
Timeline
April 2026
└─ openspec validate --strict (sole tool, all projects)
└─ just test / cargo test (as spec proxy)
June 2026
└─ ah check dogfooded in espectacular
└─ openspec validate still used elsewhere
July 10
└─ Last openspec validate usage (tRAGar project)
└─ ah check v0.2.0 in espectacular
July 15
└─ ah check v0.2.2 (human-readable output)
July 18
└─ espectacular-892 ticket created (this audit)
└─ Deployment begins
July 20
└─ ah check deployed to all 13 openspec projects
July 21
└─ Performance fix, --run-tests flag
└─ This audit completed
Recommendations for Pi Extension (espectacular-c9v)
-
Discovery is the primary gap. After deployment, agents still only use
ah checkif theah:managedblock is in their context window. A pi custom tool makes discovery unconditional. -
Pain point #1 (two-step validation) is the strongest motivator. Agents ran two commands (
openspec validate+cargo test) where one should suffice.ah checkalready solves this — the pi extension just needs to expose it. -
Pain point #4 (agent discovery) is the blocker. The
ah:managedblock works when an agent reads it, but agents don’t always readAGENTS.mdin every session. A pi tool bypasses this entirely. -
Manual spec-test mapping (#5) is the rarest but most expensive pattern — it wastes agent context on mechanical comparison.
Authoring Dual-Format Specs
espectacular supports the specodelic dual-format protocol as an opt-in
authoring mode: a single spec.md can carry both the openspec grammar
(## ADDED|MODIFIED|REMOVED Requirements + #### Scenario: headings) and
the specodelic four-layer grammar (frontmatter intent, Constraints, Model,
Properties), each linted by its own tool.
- openspec half → validated by
openspec validate <change-id> --strict - specodelic half → linted by
spk lint <file> - espectacular’s gate (
ah check) reads only the openspec half; the specodelic layers are inert decoration it happens to understand.
A worked example lives in
openspec/changes/adopt-dual-format-specs/specs/gate/spec.md — this page
explains its anatomy.
Anatomy of a dual-format file
---
id: spec ← 1. YAML frontmatter (intent layer)
kind: intent
statement: "WHEN … THE … SHALL …"
---
# <capability heading>
Prose (optional). Never parsed by the gate.
## Constraints ← 2. Constraints table (invariant layer)
| id | kind | expr | traces_to |
|----|------|------|-----------|
| C-mirror-dedupe | invariant | two `#### Scenario:` headings … | |
## Model ← 3. Model (states + transitions)
### States
- `scanning`
### Transitions
| id | from | to | guard |
|----|------|----|-------|
| t-scan | scanning | deduplicating | [[spec.C-tables-inert]] |
## Properties ← 4. Properties table
| id | kind | derives_from | generator | predicate |
|----|------|--------------|-----------|-----------|
| P-dedupe | unit | [[spec.C-mirror-dedupe]] | … | … |
## MODIFIED Requirements ← openspec half (unchanged grammar)
### Requirement: Scenario Discovery
…
#### Scenario: Reject duplicate scenario ids
- **GIVEN** …
- **WHEN** …
- **THEN** …
Section-sync mirror
In change deltas, the specodelic protocol mirrors requirement text into a
trailing ## Requirements section so deployed-spec tooling sees a stable
section spelling. The mirror must be textually identical to the delta
section (specodelic section-sync).
The gate understands this: two #### Scenario: headings in the same file
with the same slugified id and identical bodies are treated as one
logical scenario and discovered exactly once. It emits no slug-collision
finding for the mirror.
Table derivation
Scenario prose may be derived into structured tables (e.g. a Properties
generator/predicate column quoting a scenario). This is safe by
construction: the gate only discovers scenarios from lines that start
with #### Scenario: . YAML frontmatter, Constraints rows, Model states
and transitions, and Properties rows never yield scenarios — no matter
what scenario-like text they quote.
Enforcement is mandated in this repo (opt-in for adopters)
espectacular deliberately does not make its product require the
two-grammar format: plain openspec repos remain fully valid, and ah check
treats both grammars as valid (C-adopters-unaffected).
This repository, however, dogfoods the mandate (change
migrate-specs-to-dual-format, capability spec-authoring):
- pre-commit —
spk lint openspec(deep lint of dual-format files) plus a staged-file frontmatter mandate (spec-corpus-staged) that catches an archive strip at commit time; - corpus gate —
just spec-lintfails while any non-archivedspec.mdlacks the specodelic half, and runs in CI; - CI — the spec-gate job installs specodelic from crates.io and runs the same mandate.
The migration recipe for converting a plain file lives in
openspec/changes/migrate-specs-to-dual-format/design.md.
In-gate section-sync drift signal
Because mirrored sections must be textually identical, a same-id,
different-body collision in a dual-format repo is not just a naming
accident — it is section-sync drift: one of the two mirrored copies was
edited without the other. ah check catches this as a slug-collision
structural finding without running spk lint, which makes the gate a
cheap first-line drift detector for dual-format repos. (spk lint remains
the authoritative section-sync linter; the gate’s signal is a byproduct of
collision detection, not a replacement.)
Linting both halves
# specodelic half
spk lint openspec/changes/<change-id>/specs/<capability>/spec.md
# openspec half
openspec validate <change-id> --strict
# gate (both grammars in scope)
ah check --changes <change-id>
All three must exit zero before the change is deployed; the gate additionally proves every discovered scenario has a contract.
Archive round-trip
openspec archive merges the openspec half of a delta into the deployed
spec but strips the specodelic half (frontmatter and four-layer tables
do not survive). The strip is not silent: just spec-lint (pre-commit
staged mandate, corpus gate, and CI) fails until the half is re-derived.
Re-derivation recipe (not a blind copy — archive merges):
- Copy the frontmatter block from the archived delta
(
openspec/changes/archive/<change>/specs/<capability>/spec.md). - Carry over the surviving constraint/property rows from the pre-archive deployed spec (git history).
- Add rows for any newly merged requirements.
- Re-run the gates:
spk lint,openspec validate --all --strict, section sync,ah check.
An issue is filed against specodelic (specodelic#7) asking for the dual-format corpus to survive the archive round-trip natively; once fixed, the recipe becomes a no-op.
Scenario-to-property coverage (VERIFIES) and derived contracts
A Properties row is test intent; a scenario contract is executable intent.
The VERIFIES bullet links the two. When a scenario body carries
#### Scenario: Ship invalid token
- **WHEN** a token fails validation
- **THEN** it is rejected
- **VERIFIES** [[spec.P-not-shipped]]
the gate (ah check) redirects the scenario at the property’s derived
contract — the .toml that ah sync generated from the Properties row —
and runs its tests. Coverage is declared once, in the spec, instead of
restated in a hand-written contract.
ah sync — deriving contracts from Properties rows
ah sync # create-or-refresh derived contracts
ah sync --check # CI mode: report missing + stale contracts, write nothing
ah sync refuses to derive from a lint-dirty file (spk lint runs first —
a file that fails specodelic invariants is never derived from) and fails
with spk-unavailable when the binary is missing. It is idempotent:
re-running after a spec edit refreshes the derived fields of already-
generated contracts (that drift detection is the feature).
Field ownership (design D3)
| Fields | Owner | Rule |
|---|---|---|
id, description, archetype, falsifiability_class, derived_from | sync | overwritten on every sync; hand edits make the contract stale (contract-stale finding + ah sync --check exit 1) |
tests, status, superseded_by | human | never overwritten by sync |
derived_from = "<property-id>@<hash>" pins the exact Properties row the
contract was derived from; editing any row cell (id, kind, derives_from,
generator, predicate) changes the hash and marks the contract stale until
the next sync.
Test naming rule (design D6)
A derived contract binds to a human-owned test named after the property
id: property P-not-shipped → test function p_not_shipped, bound via
tests.cargo.flags = ["p_not_shipped"]. The test body is yours; the name
is the contract’s binding convention, so the gate can prove the binding
exists structurally and execute it with ah check --run-tests.
Governance: contracts are committed artifacts
Derived contracts live beside hand-written contracts
(.espectacular/<capability>/<id>.toml) and are committed —
lockfile-like artifacts refreshed by sync, not disposable tool state.
Only .espectacular/state/ and other runtime state stay uncommitted.
Advisory trace findings
ah lint reports the coverage convention’s gaps as advisory findings
(never exit-non-zero): verifies-dangling (a VERIFIES bullet cites a
property with no row), scenario-unlinked (a scenario without a bullet in
a file that has Properties rows — legitimate under the D1 fallback to
per-scenario contracts), and property-untraced (a row no scenario cites).
They are the adoption TODO list, not gate failures.
id: gate kind: intent statement: “WHEN ah check validates a spec corpus THE gate SHALL discover scenarios, require one schema-valid sidecar contract each, execute declared tests with exit-code verdicts, emit stable JSON findings with agent-action fields, support deterministic change overlays and opt-in quality measurement plus an NR archetype and a conformance coverage matrix, never semantically evaluate test quality or scenario prose, derive one scenario contract per Properties row in a lint-clean dual-format spec, cover scenarios carrying a VERIFIES link via that property’s contract, and detect derived-contract drift against its source row hash.”
gate Specification
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-scenario-discovery | invariant | scenarios are discovered from #### Scenario: headings in spec.md files including dual-format files; identical mirrored headings deduplicate to one; same slugified id with different bodies emits a slug-collision finding and exits non-zero; frontmatter and structured table layers contribute no scenarios | [[gate]] |
| C-sidecar-correspondence | invariant | each discovered scenario in scope requires exactly one TOML sidecar contract; missing, orphaned, mismatched-id, or empty-test-set contracts emit the corresponding structural finding and fail the gate | [[gate]] |
| C-contract-schema | invariant | contract metadata (id, description, archetype, status, authored_with) is validated before tests run; unknown status emits invalid-status and exits non-zero; superseded requires a non-empty superseded_by and still runs declared tests | [[gate]] |
| C-runner-execution | invariant | each declared test command runs with its exit code as the verdict: unit tests map through config runners, shell tests run inline, timeouts are enforced, output tails are bounded, missing runners / invalid TOML / malformed test entries are structural findings, and a non-zero declared test fails the check | [[gate]] |
| C-json-findings | invariant | ah check emits stable JSON: success with empty findings, findings in stable order, actionable scenario context with verbatim body boundaries, checked scope, and command details for execution findings | [[gate]] |
| C-overlay-scope | invariant | ah check --changes <id> overlays selected changes on deployed specs: added scenarios, staged metadata updates, and signaled modified scenario text apply; unsignaled redefinition, supersession with missing replacement, conflicting overlays, and conflicting staged updates for one scenario are rejected; resolution is deterministic | [[gate]] |
| C-nr-archetype | invariant | the NR (Non-Regression) archetype is a valid contract archetype, runs in change overlay scope, and ah upgrade reports it as an archetype addition | [[gate]] |
| C-scope-boundary | invariant | the gate never semantically evaluates test quality or scenario prose — it does not inspect test internals and does not hash scenario prose | [[gate]] |
| C-agent-actions | invariant | every finding carries suggested_action and playbook_command, scenario_prose is verbatim and untruncated, findings sort deterministically, and the summary counts by kind | [[gate]] |
| C-quality-measurement | invariant | opt-in quality measurement capabilities run during ah check emitting measurement findings without failing the gate on below-threshold scores in v1; property/snapshot command failure and mutation tool execution failure fail the gate; mutation is off in pre-commit scope by default | [[gate]] |
| C-quality-schema | invariant | quality measurement config ([quality.mutation], property, snapshot) is not a test entry, while property and snapshot declarations are runnable test entries — baseline tests.<type> arrays stay runnable declarations | [[gate]] |
| C-coverage-matrix | invariant | a per-spec, per-archetype coverage matrix aggregates scenario contract status across all specs in scope, counts covered scenarios and archetype totals, reports missing contracts as uncovered, and is available machine-readable | [[gate]] |
| C-apply-command | invariant | apply_command is set only when the finding’s suggested_action maps to a concrete mechanical shell command (e.g., enable_capability), and is null for findings requiring human review or code edits | [[gate]] |
| C-property-contract | invariant | in a dual-format spec whose specodelic half is spk lint-clean, each Properties row derives exactly one scenario contract whose id is the slugified property id | [[gate]] |
| C-verifies-covers | invariant | a scenario carrying a VERIFIES bullet linking to an existing property id is covered by that property’s derived contract and emits no no-tests-declared finding | [[gate]] |
| C-unlinked-unchanged | invariant | a scenario without a VERIFIES link keeps the per-scenario contract requirement and may still emit no-tests-declared | [[gate]] |
| C-derived-stale | invariant | a derived contract whose derived_from hash does not match the canonical serialization of its source property row emits a contract-stale structural finding | [[gate]] |
| C-sync-ownership | invariant | ah sync refreshes only derived fields (id, description, archetype, falsifiability_class, derived_from) and never overwrites human-owned fields (tests, status, superseded_by) | [[gate]] |
| C-plain-unaffected | advisory | plain openspec specs and dual-format specs without Properties rows discover, gate, and check byte-identically to before this change | [[gate]] |
| C-batch-threshold | invariant | bindings of one runner type with structured-reporter support and pattern-only flags batch into a single OR-joined invocation when their count exceeds the fixed threshold (8), and never batch at or below it | [[gate]] |
| C-batch-attribution | invariant | a batched contract passes only when every structured-output test matched by its pattern reports passed; any matched test failed or skipped/todo emits a test-failing or no-tests-ran verdict for that contract | [[gate]] |
| C-batch-matched-zero | invariant | a batched contract whose pattern matches no test in the structured output emits no-tests-ran regardless of the batched invocation exit code | [[gate]] |
| C-batch-fallback | invariant | an unparseable, errored, or timed-out batched invocation emits a named fallback signal and re-runs its bindings per-contract with exit-code verdicts | [[gate]] |
| C-batch-timeout | invariant | the batched invocation runs under the maximum timeout_seconds across its batched entries and reports timed_out on expiry before falling back | [[gate]] |
| C-unbatched-unchanged | invariant | runner types without structured-reporter support, bindings with JS-only regex constructs, and sets at or below the threshold keep per-binding exit-code execution byte-identical | [[gate]] |
| C-range-diff | invariant | when --base <ref> is given with --run-tests and no explicit file list, the changed-file set is git diff --name-only <base> <head>, with --head defaulting to HEAD | [[gate]] |
| C-range-precedence | invariant | the changed-file source precedence is total: --all-tests disables selection, --files wins over --base, --base wins over the worktree diff, and the worktree diff remains the only no-flag source | [[gate]] |
| C-range-safe | invariant | a range that yields no files or whose refs fail to resolve runs every declared contract; the gate never exits green having executed zero tests because of a degenerate range | [[gate]] |
| C-range-unchanged | invariant | with no --base/--head, selection input, report shape, finding kinds, and exit codes are byte-identical to pre-change behavior | [[gate]] |
Model
States
discoveringvalidatingexecutingattributedreportingderiving
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-discover | discovering | discovering | [[gate.C-scenario-discovery]] ([[gate.C-overlay-scope]] widens scope) |
| t-correspond | discovering | validating | [[gate.C-sidecar-correspondence]] |
| t-schema | validating | validating | [[gate.C-contract-schema]] |
| t-collect | validating | validating | [[gate.C-range-diff]] AND [[gate.C-range-precedence]] (the changed-file set resolves from files, range, or worktree diff per the total precedence) |
| t-execute | validating | executing | [[gate.C-runner-execution]] ([[gate.C-nr-archetype]] and [[gate.C-quality-schema]] shape what runs) |
| t-quality | executing | executing | [[gate.C-quality-measurement]] |
| t-select | validating | validating | [[gate.C-range-safe]] (a degenerate resolved set selects every capability, never green with zero tests executed) |
| t-attribute | executing | attributed | [[gate.C-batch-attribution]] |
| t-report-batched | attributed | reporting | [[gate.C-batch-matched-zero]] |
| t-report | executing | reporting | [[gate.C-json-findings]] AND [[gate.C-agent-actions]] AND [[gate.C-coverage-matrix]] AND [[gate.C-apply-command]] AND [[gate.C-range-unchanged]] |
| t-bound | reporting | reporting | [[gate.C-scope-boundary]] |
| t-derive | discovering | deriving | [[gate.C-property-contract]] |
| t-match | deriving | validating | [[gate.C-verifies-covers]] |
| t-stale | deriving | reporting | [[gate.C-derived-stale]] |
| t-own | deriving | validating | [[gate.C-sync-ownership]] |
| t-unlinked | discovering | validating | [[gate.C-unlinked-unchanged]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-discovery | unit | [[gate.C-scenario-discovery]] | deployed and dual-format specs, mirrored deltas, and same-id/different-body collisions | one scenario per logical scenario; collisions fail with findings; structured layers inert |
| P-correspondence | unit | [[gate.C-sidecar-correspondence]] | scenarios with missing, orphaned, mismatched, and empty contracts | each emits its structural finding and fails the gate |
| P-schema | unit | [[gate.C-contract-schema]] | contracts with valid, unknown, and superseded status | validation precedes execution; invalid status fails; superseded runs only with a replacement |
| P-execution | unit | [[gate.C-runner-execution]] | unit, shell, timeout, bounded-output, structural-breakage, and failing-test contracts | exit-code verdicts with bounded tails; structural breakage fails before running; non-zero fails the check |
| P-json | unit | [[gate.C-json-findings]] | check runs from clean to finding-rich scopes | stable ordering, verbatim bodies, scope and command details present |
| P-overlay | unit | [[gate.C-overlay-scope]] | overlays applying additions, staged updates, signaled modifications, and each rejection case | legal overlays apply deterministically; every illegal case is rejected |
| P-nr | unit | [[gate.C-nr-archetype]] | an NR contract in overlay scope and an ah upgrade run | NR validates, runs in scope, and upgrades report the archetype addition |
| P-boundary | unit | [[gate.C-scope-boundary]] | a passing test with poor quality and prose mutations | verdicts and outputs are unchanged by test internals or prose content |
| P-agent-actions | unit | [[gate.C-agent-actions]] | any finding-producing check run | every finding has both agent fields; prose verbatim; deterministic sort; kind counts in summary |
| P-quality | unit | [[gate.C-quality-measurement]] | enabled mutation, declared property/snapshot, threshold crossings, tool failures, pre-commit scope | measurement findings never fail on scores in v1; tool/command failures do; mutation off pre-commit |
| P-quality-schema | unit | [[gate.C-quality-schema]] | contracts with quality blocks and quality-declared runnable entries | quality config never counted as a test entry; property/snapshot run as declared tests |
| P-matrix | unit | [[gate.C-coverage-matrix]] | multi-spec scopes with missing contracts | matrix counts coverage per spec and archetype, marks missing as uncovered, and is machine-readable |
| P-apply-command | unit | [[gate.C-apply-command]] | findings across suggested_action classes | apply_command non-null only for mechanical actions |
| P-derive | unit | [[gate.C-property-contract]] | a lint-clean dual-format file with N property rows | the contract set contains exactly N derived contracts with slugified ids |
| P-covers | unit | [[gate.C-verifies-covers]] | a scenario carrying a VERIFIES link to an existing property id | no-tests-declared is suppressed for that scenario |
| P-unlinked | unit | [[gate.C-unlinked-unchanged]] | a scenario without a VERIFIES link | the gate requires its own contract exactly as before this change |
| P-stale | unit | [[gate.C-derived-stale]] | a derived contract whose source row predicate is edited after sync | contract-stale is emitted and ah sync –check exits non-zero without writing |
| P-ownership | unit | [[gate.C-sync-ownership]] | a derived contract with hand-filled tests and status, re-synced after a predicate edit | derived fields change; tests, status, and superseded_by are byte-identical |
| P-unaffected | unit | [[gate.C-plain-unaffected]] | a corpus of plain openspec specs and Properties-less dual-format specs | discovery and check output is byte-identical to pre-change behavior |
| P-batch-count | unit | [[gate.C-batch-threshold]] | a corpus with more than 8 vitest bindings sharing one runner | exactly one runner invocation executes for that runner type; a corpus with 8 or fewer keeps per-binding spawns |
| P-attribution | unit | [[gate.C-batch-attribution]] | a batched contract whose pattern matches two tests where one fails | that contract emits test-failing while contracts whose matched tests all pass succeed |
| P-zero | unit | [[gate.C-batch-matched-zero]] | a batched invocation whose JSON output contains no test matched by one contract’s pattern | that contract emits no-tests-ran while the invocation’s exit code does not cover it |
| P-fallback | unit | [[gate.C-batch-fallback]] | a batched invocation whose structured output is truncated | a named fallback signal is emitted and every affected binding re-runs per-contract |
| P-timeout | unit | [[gate.C-batch-timeout]] | batched entries declaring timeouts of 10 and 30 seconds | the invocation runs under a 30-second bound and a hang reports timed_out before fallback |
| P-unchanged | unit | [[gate.C-unbatched-unchanged]] | a corpus of cargo and shell bindings | check output is byte-identical to pre-change behavior |
| P-range-files | unit | [[gate.C-range-diff]] | a fixture repo where one capability’s spec changes after the base commit | ah check --run-tests --base <base-sha> runs only that capability’s contract; the other capability’s contract is absent from the selection report |
| P-range-precedence | unit | [[gate.C-range-precedence]] | the same fixture invoked with --files, with --all-tests, and with no flags | the explicit file list wins over the range; --all-tests runs everything; the no-flag run uses the worktree diff |
| P-range-safe | unit | [[gate.C-range-safe]] | the fixture invoked with --base HEAD and with --base not-a-ref | both invocations run every declared contract and exit per findings, never green with zero tests executed |
| P-range-unchanged | unit | [[gate.C-range-unchanged]] | the fixture invoked with no range flags | check output is byte-identical to pre-change behavior |
Non-Goals
- Evaluating test quality, assertion strength, or scenario prose semantics — the gate only checks correspondence and runs declared commands (see C-scope-boundary)
- Authoring or repairing the tests inside scenario contracts — test content is human/agent-owned and never generated by the gate or
ah sync - Reimplementing specodelic lint, graph, or derivation rules — the gate consumes
spkoutput verbatim and degrades silently whenspkis unavailable - CI dashboards, trend storage, or historical drift tracking —
ah reportandah checkemit point-in-time snapshots only
Purpose
The behavioral verification gate: scenario discovery, sidecar correspondence, contract schema validation, test execution with exit-code verdicts, stable JSON findings, deterministic change overlays, opt-in quality measurement, the NR archetype, and the conformance coverage matrix. Since derive-contracts-from-specodelic, the gate also derives one scenario contract per Properties row of a lint-clean dual-format spec, covers VERIFIES-linked scenarios via that property’s contract, and detects derived-contract drift against the source row hash.
Requirements
Requirement: Scenario Discovery
The system SHALL discover OpenSpec scenarios from #### Scenario: headings in spec.md files, including dual-format files that additionally carry the specodelic four-layer grammar, deduplicating section-sync mirrors and ignoring structured layers.
Scenario: Discover deployed scenario
- GIVEN
openspec/specs/compiler/spec.mdcontains#### Scenario: Empty input rejected - WHEN
ah checkscans deployed specs - THEN it discovers a scenario with id
empty-input-rejected - AND associates it with the
compilerspec - VERIFIES [[gate.P-discovery]]
Scenario: Reject duplicate scenario ids
- GIVEN two scenarios in the same spec slugify to the same id and have different bodies
- WHEN
ah checkvalidates the spec - THEN it emits a structural finding for the slug collision
- AND exits non-zero
- VERIFIES [[gate.P-discovery]]
Scenario: Deduplicate mirrored delta sections
- GIVEN a change delta carries identical
#### Scenario:headings with identical bodies in both## ADDED Requirementsand the mirrored## Requirementssection - WHEN a user runs
ah check --changes <change-id> - THEN each mirrored scenario is discovered exactly once
- AND the gate emits no slug-collision finding for the mirror
- VERIFIES [[gate.P-discovery]]
Scenario: Parse dual-format deployed spec
- GIVEN a deployed spec under
openspec/specs/carries YAML frontmatter and specodelic tables in addition to its## Requirementssection - WHEN
ah checkscans deployed specs - THEN scenarios are discovered from the
#### Scenario:headings exactly as before - AND frontmatter, constraint rows, model states and transitions, and property rows contribute no scenarios
- VERIFIES [[gate.P-discovery]]
Scenario: Plain openspec specs remain valid
- GIVEN a spec file contains no frontmatter and no specodelic tables
- WHEN
ah checkscans the spec - THEN discovery behaves identically to before this change
- VERIFIES [[gate.P-discovery]]
Requirement: Sidecar Contract Correspondence
The system SHALL require exactly one TOML sidecar contract for each discovered scenario in scope.
Scenario: Missing contract fails
- GIVEN a scenario id
empty-input-rejectedexists underopenspec/specs/compiler/spec.md - AND
.espectacular/compiler/empty-input-rejected.tomldoes not exist - WHEN a user runs
ah check - THEN the command emits a
no-tomlstructural finding - AND exits non-zero
- VERIFIES [[gate.P-correspondence]]
Scenario: Orphan contract fails
- GIVEN
.espectacular/compiler/empty-input-rejected.tomlexists - AND no matching scenario exists under
openspec/specs/compiler/spec.md - WHEN a user runs
ah check - THEN the command emits an
orphan-tomlstructural finding - AND exits non-zero
- VERIFIES [[gate.P-correspondence]]
Scenario: Contract id mismatch fails
- GIVEN
.espectacular/compiler/empty-input-rejected.tomlcontainsid = "different-id" - AND the matching scenario slug is
empty-input-rejected - WHEN a user runs
ah check - THEN the command emits an
id-mismatchstructural finding - AND exits non-zero
- VERIFIES [[gate.P-correspondence]]
Scenario: Empty test set fails
- GIVEN a scenario contract declares no tests
- WHEN a user runs
ah check - THEN the command emits a
no-tests-declaredstructural finding - AND exits non-zero
- VERIFIES [[gate.P-correspondence]]
Requirement: Contract Schema
The system SHALL validate per-scenario TOML contracts before running tests. Contracts MAY declare an optional falsifiability_class field with value safety or liveness; an invalid value is a structural finding, and a liveness-tagged contract whose test entries all lack timeout_seconds produces a warning that does not fail the gate.
Scenario: Validate scenario metadata
- GIVEN a scenario contract contains
id,description,archetype,status, andauthored_with - WHEN a user runs
ah check - THEN the command validates the metadata fields before executing tests
- VERIFIES [[gate.P-correspondence]]
Scenario: Reject unknown status
- GIVEN a scenario contract has
status = "paused" - WHEN a user runs
ah check - THEN the command emits an
invalid-statusstructural finding - AND exits non-zero
- VERIFIES [[gate.P-schema]]
Scenario: Validate superseded status
- GIVEN a scenario contract has
status = "superseded" - WHEN a user runs
ah check - THEN the command requires a non-empty
superseded_byvalue - AND still runs the scenario’s declared tests
- VERIFIES [[gate.P-schema]]
Scenario: Accept contract without falsifiability_class
- GIVEN a scenario contract declares no
falsifiability_classfield - WHEN a user runs
ah check - THEN the contract validates and behaves exactly as it did before the field existed
- AND no finding related to
falsifiability_classis emitted - VERIFIES [[gate.P-schema]]
Scenario: Accept valid falsifiability_class values
- GIVEN a scenario contract has
falsifiability_class = "safety"(or"liveness") - WHEN a user runs
ah check - THEN the contract validates and runs its declared tests normally
- VERIFIES [[gate.P-schema]]
Scenario: Reject invalid falsifiability_class value
- GIVEN a scenario contract has
falsifiability_class = "eventual" - WHEN a user runs
ah check - THEN the command emits an
invalid-falsifiability-classstructural finding - AND exits non-zero without running tests
- VERIFIES [[gate.P-schema]]
Scenario: Warn on liveness contract without any test timeout
- GIVEN a scenario contract has
falsifiability_class = "liveness" - AND every declared test entry omits
timeout_seconds - WHEN a user runs
ah check - THEN the command emits a
missing-liveness-timeoutfinding withseverity = "warning" - AND the finding suggests adding explicit timeout semantics, since liveness claims are only falsifiable under bounded execution
- VERIFIES [[gate.P-schema]]
Scenario: No warning when liveness contract declares a timeout
- GIVEN a scenario contract has
falsifiability_class = "liveness" - AND at least one declared test entry specifies
timeout_seconds - WHEN a user runs
ah check - THEN no
missing-liveness-timeoutfinding is emitted - VERIFIES [[gate.P-schema]]
Scenario: No liveness warning when the contract declares no tests
- GIVEN a scenario contract has
falsifiability_class = "liveness" - AND the contract declares no test entries at all
- WHEN a user runs
ah check - THEN the command emits a
no-tests-declaredstructural finding - AND no
missing-liveness-timeoutwarning is emitted - VERIFIES [[gate.P-schema]]
Scenario: Warnings do not fail the gate
- GIVEN
ah checkproduces onlymissing-liveness-timeoutwarnings and no error-severity findings - WHEN the run completes
- THEN the command exits zero
- VERIFIES [[gate.P-schema]]
Requirement: Test Runner Execution
The system SHALL run every declared test binding and derive each binding’s execution verdict from its command’s exit code, or, when eligible bindings of one structured-output runner are batched into a single invocation, from that invocation’s structured per-test output.
Scenario: Run configured unit test
- GIVEN
.espectacular/config.tomlmapsunitto["uv", "run", "pytest"] - AND a scenario contract declares
[[tests.unit]]withflags = "tests/test_parser.py::test_empty_input" - WHEN a user runs
ah check - THEN the command executes argv
["uv", "run", "pytest", "tests/test_parser.py::test_empty_input"]without a shell from the repository root - AND records the command exit code in JSON output
- VERIFIES [[gate.P-execution]]
Scenario: Run shell test
- GIVEN a scenario contract declares
[[tests.shell]]withcommand = "ah --version | grep -q 'ah '" - WHEN a user runs
ah check - THEN the command executes the shell command through
/bin/sh -cfrom the repository root - AND records the command exit code in JSON output
- VERIFIES [[gate.P-execution]]
Scenario: Enforce test timeout
- GIVEN a declared test command runs longer than its configured timeout
- WHEN a user runs
ah check - THEN the command stops the test command
- AND emits a
test-failingexecution finding withtimed_out = true - AND exits non-zero
- VERIFIES [[gate.P-execution]]
Scenario: Capture bounded output tails
- GIVEN a declared test command writes more than 8 KiB to stdout and stderr
- WHEN
ah checkemits JSON output - THEN the execution finding includes only the final 8 KiB of stdout
- AND includes only the final 8 KiB of stderr
- VERIFIES [[gate.P-execution]]
Scenario: Missing runner fails structurally
- GIVEN a scenario contract declares
[[tests.integration]] - AND
.espectacular/config.tomldoes not definerunners.integration - WHEN a user runs
ah check - THEN the command emits a
missing-runnerstructural finding - AND exits non-zero
- VERIFIES [[gate.P-execution]]
Scenario: Invalid TOML syntax fails structurally
- GIVEN a scenario contract file contains invalid TOML syntax
- WHEN a user runs
ah check - THEN the command emits a
malformed-contractstructural finding - AND exits non-zero
- VERIFIES [[gate.P-execution]]
Scenario: Malformed test entry fails structurally
- GIVEN a non-shell test entry omits
flags - WHEN a user runs
ah check - THEN the command emits a
malformed-contractstructural finding - AND exits non-zero
- VERIFIES [[gate.P-execution]]
Scenario: Non-zero declared test fails check
- GIVEN a declared test command exits non-zero
- WHEN a user runs
ah check - THEN the command emits a
test-failingexecution finding - AND exits non-zero
- VERIFIES [[gate.P-execution]]
Scenario: Batch eligible bindings into one invocation
- GIVEN more than 8 contracts declare bindings for one runner type with structured-reporter support and pattern-only flags
- WHEN a user runs
ah check --run-tests - THEN exactly one runner invocation executes for that runner type with an OR-joined pattern and the structured reporter
- VERIFIES [[gate.P-batch-count]]
Scenario: Attribute batched contract from structured output
- GIVEN a batched contract whose pattern matches two tests where one fails
- WHEN a user runs
ah check --run-tests - THEN that contract emits a
test-failingexecution finding - AND contracts whose matched tests all passed succeed
- VERIFIES [[gate.P-attribution]]
Scenario: Matched-zero batched contract still fails
- GIVEN a batched invocation whose structured output contains no test matched by one contract’s pattern
- WHEN a user runs
ah check --run-tests - THEN that contract emits a
no-tests-ranexecution finding - AND the batched invocation’s exit code does not cover it
- VERIFIES [[gate.P-zero]]
Scenario: Batch fallback re-runs per binding
- GIVEN a batched invocation whose structured output is truncated and unparseable
- WHEN a user runs
ah check --run-tests - THEN a named fallback signal is emitted in JSON output
- AND every affected binding re-runs per-contract with exit-code verdicts
- VERIFIES [[gate.P-fallback]]
Scenario: Batched timeout bound
- GIVEN batched entries declare timeouts of 10 and 30 seconds
- WHEN the batched invocation hangs beyond 30 seconds
- THEN the invocation stops with
timed_out = true - AND every affected binding re-runs per-contract with exit-code verdicts
- VERIFIES [[gate.P-timeout]]
Scenario: Unbatched runners unchanged
- GIVEN a corpus of cargo and shell bindings
- WHEN a user runs
ah check --run-tests - THEN each binding spawns per-contract with exit-code verdicts
- AND the check output is byte-identical to pre-change behavior
- VERIFIES [[gate.P-unchanged]]
Requirement: JSON Findings
The system SHALL emit stable JSON output for ah check results.
Scenario: Report success with empty findings
- GIVEN every scenario in scope has a valid contract
- AND every declared test command exits zero
- WHEN a user runs
ah check - THEN the command exits zero
- AND emits JSON with
findings = [] - VERIFIES [[gate.P-json]]
Scenario: Report all findings in stable order
- GIVEN multiple scenarios have findings
- WHEN a user runs
ah check - THEN the JSON output includes all findings
- AND orders them by spec path and scenario id
- VERIFIES [[gate.P-json]]
Scenario: Include actionable scenario context
- GIVEN a scenario has a finding
- WHEN
ah checkemits JSON output - THEN the finding includes the scenario id, spec path, scenario title, and scenario body markdown
- VERIFIES [[gate.P-json]]
Scenario: Extract scenario body boundaries
- GIVEN a scenario heading is followed by markdown body lines and then another
####heading - WHEN
ah checkemits JSON output for that scenario - THEN
body_markdowncontains only the lines after the scenario heading and before the next heading whose level is####or higher - VERIFIES [[gate.P-json]]
Scenario: Include checked scope
- WHEN
ah checkemits JSON output - THEN the top-level JSON includes whether deployed specs were checked
- AND includes any selected OpenSpec changes
- VERIFIES [[gate.P-json]]
Scenario: Include command details for execution findings
- GIVEN a declared test command exits non-zero
- WHEN
ah checkemits JSON output - THEN the finding includes the test type, command, exit code, timeout flag, stdout tail, and stderr tail when available
- VERIFIES [[gate.P-json]]
Requirement: Change Overlay Scope
The system SHALL support checking selected OpenSpec changes as overlays on deployed specs.
Scenario: Check selected change overlay
- GIVEN
openspec/changes/add-parser/specs/compiler/spec.mdadds a scenario - AND
.espectacular/changes/add-parser/compiler/<scenario>.tomlexists - WHEN a user runs
ah check --changes add-parser - THEN the command validates the deployed compiler spec plus the
add-parserscenario overlay - VERIFIES [[gate.P-overlay]]
Scenario: Apply staged metadata update for deployed scenario
- GIVEN
.espectacular/changes/add-parser/compiler/old-behavior.tomlhasstatus = "superseded" - AND deployed spec
compilercontains scenarioold-behavior - WHEN a user runs
ah check --changes add-parser - THEN the command validates the staged contract as the active contract for
old-behaviorin the overlay - VERIFIES [[gate.P-overlay]]
Scenario: Apply modified scenario text from change overlay
- GIVEN a change delta defines scenario
old-behaviorfor speccompiler, which exists in the deployed specs, with different body text - AND the change stages a contract at
.espectacular/changes/<change>/compiler/old-behavior.toml - WHEN a user runs
ah check --changes <change> - THEN the overlay scenario text replaces the deployed scenario text in scope
- AND no
overlay-conflictfinding is emitted - VERIFIES [[gate.P-overlay]]
Scenario: Reject unsignaled scenario redefinition
- GIVEN a change delta defines scenario
old-behaviorfor speccompiler, which exists in the deployed specs, with different body text - AND the change stages no contract for
old-behavior - WHEN a user runs
ah check --changes <change> - THEN the command emits an
overlay-conflictstructural finding - AND exits non-zero
- VERIFIES [[gate.P-overlay]]
Scenario: Reject supersession with missing replacement
- GIVEN
.espectacular/changes/add-parser/compiler/old-behavior.tomlhasstatus = "superseded" - AND
superseded_by = "new-behavior" - AND no scenario
new-behaviorexists in deployed specs or the selected change overlay - WHEN a user runs
ah check --changes add-parser - THEN the command emits a structural finding for the missing replacement scenario
- AND exits non-zero
- VERIFIES [[gate.P-overlay]]
Scenario: Reject conflicting overlays
- GIVEN two selected changes define the same new scenario id for the same spec
- WHEN a user runs
ah check --changes first --changes second - THEN the command emits a structural finding for the conflict
- AND exits non-zero
- VERIFIES [[gate.P-overlay]]
Scenario: Reject conflicting staged updates for one deployed scenario
- GIVEN two selected changes both stage metadata updates for the same deployed scenario id in the same spec
- WHEN a user runs
ah check --changes first --changes second - THEN the command emits an
overlay-conflictstructural finding - AND exits non-zero
- VERIFIES [[gate.P-overlay]]
Scenario: Overlay resolution is deterministic
- GIVEN selected changes do not conflict
- WHEN a user runs
ah check --changes zeta --changes alpha - THEN the command resolves selected changes in sorted change-id order
- AND produces the same validation scope as
ah check --changes alpha --changes zeta - VERIFIES [[gate.P-overlay]]
Requirement: Non-Regression Archetype
The system SHALL support an NR (Non-Regression) archetype for contracts that assert existing behavior is preserved during change proposals.
Scenario: NR contract is valid
- GIVEN a scenario contract has
archetype = "NR" - WHEN a user runs
ah check - THEN the gate accepts
NRas a valid archetype value - AND validates and runs the contract’s declared tests identically to other archetypes
- VERIFIES [[gate.P-nr]]
Scenario: NR contract runs in change overlay scope
- GIVEN a change proposal modifies a capability
- AND an existing scenario is covered by a contract with
archetype = "NR" - WHEN a user runs
ah check --changes <change-id> - THEN the NR contract is validated as part of the overlay scope
- AND a failing NR test exits non-zero
- VERIFIES [[gate.P-nr]]
Scenario: ah upgrade reports NR as archetype addition
- GIVEN
.espectacular/config.tomlpins a tool version that predatesNRsupport - WHEN a user runs
ah upgrade - THEN the command reports
NRas a newly available archetype before updating the configured tool version - VERIFIES [[gate.P-nr]]
Requirement: Deterministic Scope Boundary
The system SHALL avoid semantic evaluation of test quality or scenario prose.
Scenario: Do not inspect test internals
- GIVEN a declared test command exists and exits zero
- WHEN a user runs
ah check - THEN the command treats the test as passing
- AND does not inspect assertions, fixtures, mocks, or setup code
- VERIFIES [[gate.P-boundary]]
Scenario: Do not hash scenario prose
- GIVEN the body text under an existing scenario heading changes
- WHEN a user runs
ah check - THEN the command does not fail solely because the prose changed
- VERIFIES [[gate.P-boundary]]
Requirement: JSON finding schema includes agent-action fields
The system SHALL include agent-action fields on every finding in the JSON output.
Scenario: Every finding carries suggested_action
- GIVEN
ah checkproduces any finding - WHEN the JSON output is inspected
- THEN every finding object contains a
suggested_actionfield with a value from the documented enum - VERIFIES [[gate.P-agent-actions]]
Scenario: Every finding carries playbook_command
- GIVEN
ah checkproduces any finding - WHEN the JSON output is inspected
- THEN every finding object contains a
playbook_commandfield with a validah explain <topic>invocation - VERIFIES [[gate.P-agent-actions]]
Scenario: scenario_prose is verbatim and untruncated
- GIVEN a finding references a scenario
- WHEN the JSON output is inspected
- THEN the
scenario_prosefield contains the full markdown body of the scenario heading, verbatim, without truncation - VERIFIES [[gate.P-agent-actions]]
Scenario: Findings are sorted deterministically
- GIVEN
ah checkproduces multiple findings - WHEN the JSON output is inspected
- THEN the
findingsarray is sorted by(spec_path, scenario_id, kind)in ascending lexicographic order - VERIFIES [[gate.P-agent-actions]]
Scenario: Summary counts by kind
- GIVEN
ah checkproduces findings of multiple kinds - WHEN the JSON output is inspected
- THEN the envelope
summary.counts_by_kindobject contains the count of each finding kind present - VERIFIES [[gate.P-agent-actions]]
Scenario: Quality findings carry no scenario prose and never fail the gate
- GIVEN
ah checkproduces only a quality finding whose mutation tool has no owning scenario - WHEN the JSON output is inspected
- THEN the quality finding carries its message and action fields with
scenario_prose = null - AND no scenario body is fabricated for it
- AND the run exits zero — a scenario-less quality finding fails nothing
- VERIFIES [[gate.P-agent-actions]]
Requirement: Quality measurement capabilities
The system SHALL support opt-in quality measurement capabilities that run during ah check and emit measurement findings without failing the gate.
Scenario: Mutation testing runs when enabled
- GIVEN a contract declares
[quality.mutation] enabled = true - AND a mutation tool is configured in
.espectacular/config.toml - WHEN a user runs
ah check --mutation - THEN the gate runs the mutation tool against the contract’s declared tests
- AND emits a
quality-mutationinfo finding with the measured score - AND exits zero when the score is below any configured threshold
- VERIFIES [[gate.P-quality]]
Scenario: Property-based testing runs when declared
- GIVEN a contract declares a
tests.propertyentry - WHEN a user runs
ah check - THEN the gate runs the property test command
- AND emits a
quality-propertyfinding with the run result - VERIFIES [[gate.P-quality]]
Scenario: Snapshot testing runs when declared
- GIVEN a contract declares a
tests.snapshotentry - WHEN a user runs
ah check - THEN the gate runs the snapshot test command
- AND emits a
quality-snapshotfinding with the run result - VERIFIES [[gate.P-quality]]
Scenario: Quality scores below threshold do not fail the gate in v1
- GIVEN a quality measurement capability completes successfully and produces a score below threshold
- WHEN a user runs
ah check - THEN the finding severity is
warningorinfo - AND the overall exit status is zero
- VERIFIES [[gate.P-quality]]
Scenario: Property or snapshot command failure fails the gate
- GIVEN a contract declares
[[tests.property]]or[[tests.snapshot]] - AND the declared command exits non-zero or times out
- WHEN a user runs
ah check - THEN the command emits a
test-failingexecution finding - AND the overall exit status is non-zero
- VERIFIES [[gate.P-quality]]
Scenario: Mutation tool execution failure fails the gate
- GIVEN mutation measurement is enabled and the mutation tool command exits non-zero before producing a measurement
- WHEN a user runs
ah check --mutation - THEN the command emits a
test-failingexecution finding - AND the overall exit status is non-zero
- VERIFIES [[gate.P-quality]]
Scenario: Mutation is off in pre-commit scope by default
- GIVEN mutation testing is configured
- AND
ah checkis invoked without an explicit--mutationflag - WHEN the command runs in pre-commit mode
- THEN mutation testing is skipped
- VERIFIES [[gate.P-quality]]
Requirement: Quality contract schema
The system SHALL represent quality measurements without changing the baseline rule that tests.<type> entries are arrays of runnable test declarations.
Scenario: Mutation configuration is not a test entry
- GIVEN mutation measurement is enabled for a scenario contract
- WHEN the contract is validated
- THEN mutation settings are read from a
[quality.mutation]table - AND
tests.mutationas a boolean is rejected as a malformed contract - VERIFIES [[gate.P-quality-schema]]
Scenario: Property and snapshot are runnable test entries
- GIVEN a scenario contract declares
[[tests.property]]or[[tests.snapshot]] - WHEN the contract is validated
- THEN each entry follows the same runnable test-entry shape as other
tests.<type>arrays - VERIFIES [[gate.P-quality-schema]]
Requirement: Conformance coverage matrix
The system SHALL compute a per-spec, per-archetype coverage matrix aggregating scenario contract status across all specs in scope.
Scenario: Matrix counts covered scenarios
- GIVEN
openspec/specs/contains multiple specs, each with scenarios that have contracts - WHEN a user runs
ah report - THEN the command emits a matrix row for each spec with columns for each archetype
- AND each cell contains
covered,missing, andfailingcounts - VERIFIES [[gate.P-matrix]]
Scenario: Matrix includes archetype totals
- GIVEN
ah reportruns against deployed specs - WHEN the output is inspected
- THEN the matrix includes a totals row summing counts across all specs
- VERIFIES [[gate.P-matrix]]
Scenario: Missing contracts appear as uncovered
- GIVEN a deployed scenario has no sidecar contract
- WHEN
ah reportruns - THEN the scenario is counted as
missingfor its spec row - AND the
archetypecolumn isunassigned - VERIFIES [[gate.P-matrix]]
Scenario: Machine-readable matrix output
- WHEN a user runs
ah report --json - THEN the command emits a JSON object with a
matrixarray - AND each row contains
spec,archetype,covered,missing, andfailinginteger fields - VERIFIES [[gate.P-matrix]]
Requirement: apply_command is conditionally present
The system SHALL set apply_command only when the finding’s suggested_action maps to a concrete, mechanical shell command; it SHALL be null for findings that require non-mechanical human action.
Scenario: apply_command is set for enable_capability findings
- GIVEN
ah checkorah doctorproduces a finding withsuggested_action = enable_capability - WHEN the JSON output is inspected
- THEN
apply_commandcontains theah doctor --enable <capability>invocation - VERIFIES [[gate.P-apply-command]]
Scenario: apply_command is null for human_review_required findings
- GIVEN
ah checkproduces a finding withsuggested_action = human_review_required - WHEN the JSON output is inspected
- THEN
apply_commandis null or absent - VERIFIES [[gate.P-apply-command]]
Scenario: apply_command is null for edit_code_not_scenario findings
- GIVEN
ah checkproduces a finding withsuggested_action = edit_code_not_scenario - WHEN the JSON output is inspected
- THEN
apply_commandis null or absent - VERIFIES [[gate.P-apply-command]]
Scenario: apply_command is null for run_ah_sync findings
- GIVEN
ah checkproduces acontract-stalefinding withsuggested_action = run_ah_sync - WHEN the JSON output is inspected
- THEN
apply_commandis null or absent — refreshing derived fields writes TOML, and a command that mutates contract files is refused the mechanical fast-path - VERIFIES [[gate.P-apply-command]]
Scenario: review_and_apply findings carry no apply_command
- GIVEN
ah checkproduces a structural finding withsuggested_action = review_and_apply(e.g. a missing sidecar contract) - WHEN the JSON output is inspected
- THEN
apply_commandis null or absent — correspondence repair requires judgment, and a fabricated one-liner would fail the mechanical-only contract - AND no shell invocation is fabricated for it
- VERIFIES [[gate.P-apply-command]]
Requirement: Property-Derived Contracts
The system SHALL derive one scenario contract per Properties row in a dual-format spec whose specodelic half is lint-clean, using the slugified property id as the contract id, and SHALL treat a scenario carrying a **VERIFIES** [[gate.P-...]] link as covered by that property’s contract.
Scenario: Derive contracts from properties
- GIVEN a dual-format spec is
spk lint-clean and carries three Properties rows - WHEN
ah syncruns - THEN exactly three derived contracts exist with slugified property ids
- AND each records
derived_from = "<property-id>@<hash>" - VERIFIES [[gate.P-derive]]
Scenario: Cover linked scenario
- GIVEN a scenario carries
- **VERIFIES** [[gate.P-not-shipped]]and the property’s derived contract exists - WHEN
ah checkvalidates the spec - THEN the scenario emits no
no-tests-declaredfinding - VERIFIES [[gate.P-covers]]
Scenario: Unlinked scenario unchanged
- GIVEN a scenario carries no
VERIFIESlink - WHEN
ah checkvalidates the spec - THEN the scenario requires its own contract exactly as before this change
- VERIFIES [[gate.P-unlinked]]
Scenario: Plain specs unaffected
- GIVEN a corpus of plain openspec specs and dual-format specs without Properties rows
- WHEN discovery and check run over the corpus
- THEN their output is byte-identical to pre-change behavior
- VERIFIES [[gate.P-unaffected]]
Scenario: Refuse derivation from lint-dirty spec
- GIVEN a dual-format spec whose specodelic half has a
spk lintviolation (e.g. a duplicate row id) - WHEN
ah syncruns - THEN the command refuses the file with a named refusal citing the specodelic rule
- AND no contracts are derived or written for that spec
- VERIFIES [[gate.P-derive]]
Requirement: Derived Contract Drift
The system SHALL record derived_from = "<property-id>@<hash>" in each derived contract, emit a contract-stale structural finding when the hash no longer matches the source row’s canonical serialization, and refresh only derived fields during ah sync — never tests, status, or superseded_by.
Scenario: Detect stale contract
- GIVEN a property row’s predicate is edited after its contract was derived
- WHEN
ah checkvalidates the spec - THEN a
contract-stalestructural finding is emitted - AND
ah sync --checkexits non-zero without writing - VERIFIES [[gate.P-stale]]
Scenario: Refresh preserves owned fields
- GIVEN a derived contract has hand-filled
testsandstatus - WHEN the source predicate is edited and
ah syncre-runs - THEN derived fields are updated to match the row
- AND
tests,status, andsuperseded_byare byte-identical to before - VERIFIES [[gate.P-ownership]]
Requirement: Range-Based Test Selection
The system SHALL derive the ah check --run-tests changed-file set from the commit range git diff --name-only <base> <head> when --base is given, and SHALL run every declared contract when the range yields no files or a ref fails to resolve.
Scenario: Select contracts from a commit range
- GIVEN a fixture repo with two capabilities whose contracts declare tests
- AND a base commit after which only one capability’s spec changed
- WHEN a user runs
ah check --run-tests --base <base-sha> - THEN only the changed capability’s contract tests run
- AND the other capability’s contract is absent from the selection report
- VERIFIES [[gate.P-range-files]]
Scenario: Head defaults to HEAD
- GIVEN the same fixture repo and base commit
- WHEN a user runs
ah check --run-tests --base <base-sha>without--head - THEN the selection is identical to
ah check --run-tests --base <base-sha> --head HEAD - VERIFIES [[gate.P-range-files]]
Scenario: Explicit files win over the range
- GIVEN both
--files <paths>and--base <ref>are given - WHEN a user runs
ah check --run-tests - THEN the explicit file list supplies the changed-file set and the range is ignored
- VERIFIES [[gate.P-range-precedence]]
Scenario: All-tests overrides the range
- GIVEN both
--all-testsand--base <ref>are given - WHEN a user runs
ah check --run-tests - THEN every declared contract runs regardless of the range
- VERIFIES [[gate.P-range-precedence]]
Scenario: Empty range runs everything
- GIVEN
--baseresolves to the same commit as--head, so the range diff is empty - WHEN a user runs
ah check --run-tests --base HEAD - THEN every declared contract runs
- AND the run exits per its findings, never green with zero tests executed
- VERIFIES [[gate.P-range-safe]]
Scenario: Unresolvable ref runs everything
- GIVEN
--base not-a-reffails to resolve, sogit diffexits non-zero - WHEN a user runs
ah check --run-tests --base not-a-ref - THEN every declared contract runs
- AND the command does not crash and exits per its findings
- VERIFIES [[gate.P-range-safe]]
Scenario: No range flags keep worktree behavior
- GIVEN no
--baseor--headis given - WHEN a user runs
ah check --run-tests - THEN the changed-file set comes from the worktree diff exactly as before
- AND the selection report, finding kinds, and exit codes are unchanged
- VERIFIES [[gate.P-range-unchanged]]
Scenario: Range flags are inert without run-tests
- GIVEN
--base <ref>is given without--run-tests - WHEN a user runs
ah check --base <ref> - THEN the structural gate validates the full deployed scope exactly as without the flag
- AND no contract tests run and no selection report is produced
- VERIFIES [[gate.P-range-precedence]]
id: cli kind: intent statement: “WHEN a user drives the ah CLI THE system SHALL provide the command surface — idempotent init with hook integration, the deterministic check gate, doctor with capability enablement, archetype docs, append-only scenario lifecycle, archive companion, upgrade drift reporting, explain playbooks, conformance reporting, recommendation findings, and static spec lint — each exiting deterministically.”
cli Specification
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-cli-name | invariant | the standalone command-line interface is exposed as ah | [[cli]] |
| C-init-command | invariant | ah init is idempotent and prepares a repository for spec-test correspondence checks: it refuses without OpenSpec, stubs existing deployed scenarios, installs a supported pre-commit integration preferring lefthook before prek and falling back to prek, and reports when no hook framework is present | [[cli]] |
| C-check-command | invariant | ah check is the deterministic gate command, operating on deployed specs and accepting an OpenSpec change overlay | [[cli]] |
| C-doctor-command | invariant | ah doctor diagnoses project setup and correspondence wiring | [[cli]] |
| C-type-commands | invariant | ah type exposes built-in archetype guidance: listing archetypes and showing per-archetype details | [[cli]] |
| C-scenario-lifecycle | invariant | scenario lifecycle commands author append-only: creating a scenario in a change rejects without a target requirement; superseding rejects when the replacement is missing | [[cli]] |
| C-archive-companion | invariant | ah archive <change> moves staged scenario contracts after OpenSpec archive; it refuses to run before OpenSpec archive and refuses on collision | [[cli]] |
| C-upgrade-command | invariant | ah upgrade makes tool-version drift explicit by reporting compatibility changes | [[cli]] |
| C-doctor-enable | invariant | ah doctor --enable <capability> for a detected inactive capability writes exactly one config table for it and prints the path and table name written; enabling an unknown capability is an error; enabling an already-active capability is a no-op | [[cli]] |
| C-explain-subcommand | invariant | ah explain <topic> prints playbook guidance for finding kinds, suggested actions, and general topics, supports JSON output and listing, and errors on unknown topics | [[cli]] |
| C-report-command | invariant | ah report displays a conformance coverage matrix across deployed specs and archetype tiers (modeled on the OpenTelemetry per-language compliance matrix); it exits zero when coverage is complete, non-zero when scenarios lack contracts, and supports JSON output | [[cli]] |
| C-recommendation-findings | invariant | ah doctor emits recommendation findings for capabilities that are available but not yet configured; each carries its enable command and is a finding kind, not a log line | [[cli]] |
| C-lint-command | invariant | ah lint statically analyzes OpenSpec scenario files for quality findings without modifying files or running tests, operating on deployed specs and change overlays, scoping to a single check category, supporting JSON output, and exiting zero with empty findings on a clean spec | [[cli]] |
Model
States
barepreparedgatedadvised
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-init | bare | prepared | [[cli.C-init-command]] |
| t-check | prepared | gated | [[cli.C-check-command]] ([[cli.C-lint-command]] widens quality scope) |
| t-doctor | prepared | advised | [[cli.C-doctor-command]] ([[cli.C-doctor-enable]] and [[cli.C-recommendation-findings]] refine it) |
| t-lifecycle | prepared | prepared | [[cli.C-scenario-lifecycle]] AND [[cli.C-archive-companion]] AND [[cli.C-upgrade-command]] |
| t-guidance | advised | advised | [[cli.C-type-commands]] AND [[cli.C-explain-subcommand]] AND [[cli.C-report-command]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-name | unit | [[cli.C-cli-name]] | invoking the binary | it answers to ah with help |
| P-init | unit | [[cli.C-init-command]] | repositories with and without OpenSpec, deployed scenarios, lefthook, prek, and neither hook framework | idempotent preparation; refusal without OpenSpec; scenario stubs; hook preference order; honest missing-framework report |
| P-check | unit | [[cli.C-check-command]] | clean scopes and change overlays | deterministic gate behavior on both scopes |
| P-doctor | unit | [[cli.C-doctor-command]] | healthy and broken installations | diagnoses setup and correspondence wiring |
| P-type | unit | [[cli.C-type-commands]] | ah type list and detail invocations | all archetypes listed; details render per archetype |
| P-lifecycle | unit | [[cli.C-scenario-lifecycle]] | create-with and create-without target requirement, supersede with and without replacement | legal authoring succeeds append-only; both illegal cases rejected |
| P-archive | unit | [[cli.C-archive-companion]] | archive runs before OpenSpec archive, after it, and on collision | staged contracts move only post-archive and refuse collisions |
| P-upgrade | unit | [[cli.C-upgrade-command]] | version-drifted tool references | compatibility changes reported explicitly |
| P-enable | unit | [[cli.C-doctor-enable]] | enabled capabilities: pytest, cargo, vitest, mutation, property, snapshot, unknown, already-active | one config table per enable with reported path/table; unknown errors; active is a no-op |
| P-explain | unit | [[cli.C-explain-subcommand]] | finding-kind, suggested-action, and general topics with JSON, list, and unknown-topic variants | guidance printed per topic class; JSON valid; list complete; unknown errors |
| P-report | unit | [[cli.C-report-command]] | scopes with complete coverage vs missing contracts | zero exit on completeness, non-zero on gaps, JSON output available |
| P-recommendation | unit | [[cli.C-recommendation-findings]] | doctor runs over projects with unconfigured available capabilities | recommendation findings carry enable commands and are finding kinds |
| P-lint | unit | [[cli.C-lint-command]] | clean specs, dirty specs, overlays, single categories, JSON output | quality findings without file modification or test execution; clean exits zero with empty findings |
Purpose
TBD - created by archiving change add-spec-assertions. Update Purpose after archive.
Non-Goals
- Defining contract TOML content or test-entry semantics — those live in the gate spec; the CLI only routes commands to it
- Framework-specific test invocation logic — adapters own detection and dispatch (see the adapters spec)
- Interactive prompts or wizards — every command is non-interactive and CI-safe
- Replacing OpenSpec’s own CLI —
ahcommands complementopenspeccommands and never parse or rewrite the change/proposal format
Requirements
Requirement: CLI Command Name
The system SHALL expose the standalone command-line interface as ah.
Scenario: Invoke help
- WHEN a user runs
ah --help - THEN the CLI displays available
ahcommands - VERIFIES [[cli.P-name]]
Scenario: Unknown subcommand fails with a suggestion
- WHEN a user runs a subcommand name
ahdoes not implement - THEN the command fails with a non-zero exit and a
Did you meansuggestion naming the closest known command - VERIFIES [[cli.P-name]]
Requirement: Project Initialization
The system SHALL provide an idempotent ah init command that prepares a repository for spec-test correspondence checks.
Scenario: Initialize project files
- GIVEN a repository contains an
openspec/directory - WHEN a user runs
ah init - THEN the command creates
.espectacular/config.tomlwhen it is missing - AND writes
.espectacular/AGENTS.md - AND creates top-level
AGENTS.mdandCLAUDE.mdwhen they are absent - AND refreshes managed
ahblocks in top-level instruction files - VERIFIES [[cli.P-init]]
Scenario: Refuse initialization without OpenSpec
- GIVEN a repository does not contain an
openspec/directory - WHEN a user runs
ah init - THEN the command fails without creating
.espectacular/ - VERIFIES [[cli.P-init]]
Scenario: Stub existing deployed scenarios
- GIVEN deployed OpenSpec scenarios exist under
openspec/specs/ - WHEN a user runs
ah init - THEN the command creates matching
.espectacular/<spec>/<scenario>.tomlstubs for scenarios without contracts - AND the stubs declare no tests until the user or AI supplies them
- VERIFIES [[cli.P-init]]
Scenario: Install supported pre-commit integration
- GIVEN the repository uses
lefthook - WHEN a user runs
ah init - THEN the command installs or refreshes a managed pre-commit integration that runs
ah check - VERIFIES [[cli.P-init]]
Scenario: Prefer lefthook before prek
- GIVEN the repository has both
lefthookandprekconfigured - WHEN a user runs
ah init - THEN the command installs the managed pre-commit integration through
lefthook - VERIFIES [[cli.P-init]]
Scenario: Fall back to prek
- GIVEN the repository uses
prek - AND does not use
lefthook - WHEN a user runs
ah init - THEN the command installs or refreshes a managed pre-commit integration through
prek - VERIFIES [[cli.P-init]]
Scenario: Report missing hook framework
- GIVEN the repository does not use
lefthookorprek - WHEN a user runs
ah init - THEN the command reports a concern that the user or AI must set up pre-commit integration
- AND does not write a raw
.git/hooks/pre-commitfallback - VERIFIES [[cli.P-init]]
Requirement: Correspondence Check Command
The system SHALL provide ah check as the deterministic gate command.
Scenario: Check deployed specs
- WHEN a user runs
ah check - THEN the command validates deployed specs under
openspec/specs/ - AND validates matching contracts under
.espectacular/<spec>/ - AND emits JSON output
- VERIFIES [[cli.P-check]]
Scenario: Check an OpenSpec change overlay
- WHEN a user runs
ah check --changes add-parser - THEN the command validates deployed specs plus the
add-parserchange overlay - AND validates staged contracts under
.espectacular/changes/add-parser/ - AND includes the selected change in the JSON scope
- VERIFIES [[cli.P-check]]
Scenario: Reject a selected change that does not exist
- GIVEN no
openspec/changes/missing-change/specs/directory exists - WHEN a user runs
ah check --changes missing-change - THEN the command fails with a diagnostic naming the missing change path
- VERIFIES [[cli.P-check]]
Requirement: Health Check Command
The system SHALL provide ah doctor for installation health checks.
Scenario: Diagnose project setup
- WHEN a user runs
ah doctor - THEN the command validates
.espectacular/config.toml - AND checks managed instruction blocks
- AND checks supported hook integration
- AND reports tool-version compatibility concerns
- VERIFIES [[cli.P-doctor]]
Scenario: Diagnose correspondence wiring
- WHEN a user runs
ah doctor - THEN the command reports slug collisions as errors
- AND reports orphan contracts as errors
- AND reports unknown archetype names as warnings
- VERIFIES [[cli.P-doctor]]
Requirement: Archetype Documentation Commands
The system SHALL expose built-in archetype guidance through ah type commands.
Scenario: List archetypes
- WHEN a user runs
ah type - THEN the command lists all known archetypes with one-line descriptions
- AND includes
PF,SA,BP,CE, andNR - VERIFIES [[cli.P-type]]
Scenario: Show archetype details
- WHEN a user runs
ah type PF - THEN the command prints the full built-in documentation for the
PFarchetype - VERIFIES [[cli.P-type]]
Scenario: Reject an unknown archetype code
- WHEN a user runs
ah type NOPE - THEN the command exits non-zero and names the known archetype codes
- AND a near-miss input suggests the closest known code
- VERIFIES [[cli.P-type]]
Requirement: Scenario Lifecycle Commands
The system SHALL provide commands for append-only scenario authoring.
Scenario: Create scenario in a change
- GIVEN
openspec/changes/add-parser/specs/compiler/spec.mdcontains### Requirement: Parser Input Validation - WHEN a user runs
ah scenario new add-parser compiler --requirement "Parser Input Validation" "Empty input rejected" - THEN the command appends a scenario heading under that requirement
- AND writes placeholder
WHENandTHENlines under the scenario heading - AND creates
.espectacular/changes/add-parser/compiler/empty-input-rejected.tomlwithid, emptydescription, emptyarchetype,status = "active", emptysuperseded_by, andauthored_with - VERIFIES [[cli.P-lifecycle]]
Scenario: Reject scenario creation without target requirement
- GIVEN
openspec/changes/add-parser/specs/compiler/spec.mddoes not contain### Requirement: Parser Input Validation - WHEN a user runs
ah scenario new add-parser compiler --requirement "Parser Input Validation" "Empty input rejected" - THEN the command fails without creating or modifying files
- VERIFIES [[cli.P-lifecycle]]
Scenario: Supersede a scenario
- GIVEN scenario
new-behaviorexists in the deployed-plus-add-parseroverlay for speccompiler - WHEN a user runs
ah scenario supersede compiler old-behavior --with=new-behavior --in-change=add-parser - THEN the command stages
.espectacular/changes/add-parser/compiler/old-behavior.toml - AND marks the staged contract as superseded
- AND records
new-behavioras the replacement scenario id - VERIFIES [[cli.P-lifecycle]]
Scenario: Reject supersession with missing replacement
- GIVEN scenario
new-behaviordoes not exist in the deployed-plus-add-parseroverlay for speccompiler - WHEN a user runs
ah scenario supersede compiler old-behavior --with=new-behavior --in-change=add-parser - THEN the command fails without creating or modifying files
- VERIFIES [[cli.P-lifecycle]]
Requirement: Archive Companion Command
The system SHALL provide ah archive <change> to move staged scenario contracts after OpenSpec archive.
Scenario: Archive staged contracts
- GIVEN
openspec archive add-parserhas applied the OpenSpec change - AND every staged contract id exists in deployed
openspec/specs/ - WHEN a user runs
ah archive add-parser - THEN the command moves new contracts from
.espectacular/changes/add-parser/<spec>/to.espectacular/<spec>/ - VERIFIES [[cli.P-archive]]
Scenario: Refuse archive before OpenSpec archive
- GIVEN
.espectacular/changes/add-parser/compiler/new-behavior.tomlexists - AND deployed
openspec/specs/compiler/spec.mddoes not contain scenarionew-behavior - WHEN a user runs
ah archive add-parser - THEN the command fails without moving staged contracts
- VERIFIES [[cli.P-archive]]
Scenario: Refuse archive collision
- GIVEN
.espectacular/compiler/foo.tomlalready exists - AND
.espectacular/changes/add-parser/compiler/foo.tomlis not a superseded metadata update forfoo - WHEN a user runs
ah archive add-parser - THEN the command fails without overwriting the deployed contract
- VERIFIES [[cli.P-archive]]
Requirement: Upgrade Command
The system SHALL provide ah upgrade to make tool-version drift explicit.
Scenario: Report compatibility changes
- GIVEN
.espectacular/config.tomlpins an older tool version than the installedah - WHEN a user runs
ah upgrade - THEN the command reports config schema version changes, execution default changes, archetype additions, and archetype deprecations before updating the configured tool version
- AND does not rewrite existing scenario contract
authored_withvalues - VERIFIES [[cli.P-upgrade]]
Scenario: Signal drift to CI with a non-zero exit
- GIVEN
.espectacular/config.tomlpins an older tool version than the installedah - WHEN a user runs
ah upgrade - THEN the command updates the config and exits non-zero so CI can detect the compatibility change
- AND an up-to-date config exits zero instead
- VERIFIES [[cli.P-upgrade]]
Requirement: Doctor enable flag
When a user runs ah doctor --enable <capability> for a detected inactive capability, the system SHALL write exactly one config table for that capability and SHALL print the path and table name written.
Scenario: Enable pytest adapter
- GIVEN pytest is detected by
ah doctor - WHEN a user runs
ah doctor --enable pytest - THEN the command writes
[runners.pytest] command = ["pytest"]to.espectacular/config.toml - AND prints
.espectacular/config.tomland[runners.pytest] - VERIFIES [[cli.P-enable]]
Scenario: Enable cargo adapter
- GIVEN cargo is detected by
ah doctor - WHEN a user runs
ah doctor --enable cargo - THEN the command writes
[runners.cargo] command = ["cargo", "test"]to.espectacular/config.toml - AND prints
.espectacular/config.tomland[runners.cargo] - VERIFIES [[cli.P-enable]]
Scenario: Enable vitest adapter
- GIVEN vitest is detected by
ah doctor - WHEN a user runs
ah doctor --enable vitest - THEN the command writes
[runners.vitest] command = ["vitest", "run"]to.espectacular/config.toml - AND prints
.espectacular/config.tomland[runners.vitest] - VERIFIES [[cli.P-enable]]
Scenario: Enable mutation capability
- GIVEN a mutation testing tool is detected
- WHEN a user runs
ah doctor --enable mutation - THEN the command writes
[capabilities.mutation] enabled = trueto.espectacular/config.toml - AND prints
.espectacular/config.tomland[capabilities.mutation] - VERIFIES [[cli.P-enable]]
Scenario: Enable property capability
- GIVEN a property-based testing framework is detected
- WHEN a user runs
ah doctor --enable property - THEN the command writes
[capabilities.property] enabled = trueto.espectacular/config.toml - AND prints
.espectacular/config.tomland[capabilities.property] - VERIFIES [[cli.P-enable]]
Scenario: Enable snapshot capability
- GIVEN a snapshot testing framework is detected
- WHEN a user runs
ah doctor --enable snapshot - THEN the command writes
[capabilities.snapshot] enabled = trueto.espectacular/config.toml - AND prints
.espectacular/config.tomland[capabilities.snapshot] - VERIFIES [[cli.P-enable]]
Scenario: Enable unknown capability is an error
- WHEN a user runs
ah doctor --enable nonexistent - THEN the command exits non-zero
- AND prints
unrecognized capability: nonexistent - VERIFIES [[cli.P-enable]]
Scenario: Enable already-active capability is a no-op
- GIVEN a capability is already present in
.espectacular/config.toml - WHEN a user runs
ah doctor --enable <capability> - THEN the command reports it is already enabled and makes no changes
- VERIFIES [[cli.P-enable]]
Requirement: Explain subcommand
The system SHALL provide an ah explain <topic> subcommand that prints playbook guidance for a finding kind or suggested action.
Scenario: Explain a finding kind
- WHEN a user runs
ah explain no-toml - THEN the command prints markdown guidance for the
no-tomlfinding kind - VERIFIES [[cli.P-explain]]
Scenario: Explain a suggested action
- WHEN a user runs
ah explain run_ah_scenario_new - THEN the command prints markdown guidance for the
run_ah_scenario_newsuggested action - VERIFIES [[cli.P-explain]]
Scenario: Explain a general topic
- WHEN a user runs
ah explain workflow - THEN the command prints markdown guidance for the general
workflowtopic - VERIFIES [[cli.P-explain]]
Scenario: Explain with JSON output
- WHEN a user runs
ah explain no-toml --json - THEN the command emits a JSON object with fields:
topic,summary,when,do,human_approval,related_topics,hints - AND each
hintsitem containskindandmessagestring fields - VERIFIES [[cli.P-explain]]
Scenario: List all topics
- WHEN a user runs
ah explain --list - THEN the command prints all available topic identifiers, one per line
- VERIFIES [[cli.P-explain]]
Scenario: Unknown topic is an error
- WHEN a user runs
ah explain no-such-topic - THEN the command exits non-zero
- AND prints either
Run ah explain --listor the sorted list of available topic identifiers - VERIFIES [[cli.P-explain]]
Requirement: Coverage report command
The system SHALL provide ah report to display a conformance coverage matrix across all deployed specs and archetype tiers, modeled on the OpenTelemetry per-language compliance matrix pattern.
Scenario: Report coverage by spec and archetype
- WHEN a user runs
ah report - THEN the command prints a table showing each spec as a row and each archetype as a column
- AND each cell shows covered/missing/failing counts
- VERIFIES [[cli.P-report]]
Scenario: Report exits zero when coverage is complete
- GIVEN every deployed scenario has a valid, passing contract
- WHEN a user runs
ah report - THEN the command exits zero
- VERIFIES [[cli.P-report]]
Scenario: Report exits non-zero when scenarios are missing contracts
- GIVEN at least one deployed scenario has no sidecar contract
- WHEN a user runs
ah report - THEN the command exits non-zero
- VERIFIES [[cli.P-report]]
Scenario: Report JSON output
- WHEN a user runs
ah report --json - THEN the command emits a JSON conformance matrix consumable by CI dashboards and agent harnesses
- VERIFIES [[cli.P-report]]
Requirement: Recommendation findings
The system SHALL emit recommendation findings when ah doctor detects capabilities that are available but not yet configured.
Scenario: Recommendation finding carries enable command
- GIVEN
ah doctordetects an available framework not yet configured - WHEN the output is inspected (JSON or text)
- THEN a
recommendationfinding is present withsuggested_action = enable_capability - AND
apply_commandcontains theah doctor --enable <capability>invocation - VERIFIES [[cli.P-recommendation]]
Scenario: Recommendation finding is a finding kind, not a log line
- GIVEN
ah doctordetects an available but unconfigured framework - WHEN the output is requested as JSON (
ah doctor --json) - THEN the finding appears in the
findingsarray withkind = recommendation - AND it carries a
playbook_commandfield - VERIFIES [[cli.P-recommendation]]
Scenario: Configured frameworks emit no recommendation
- GIVEN every available framework is already configured in
.espectacular/config.toml - WHEN
ah doctor --jsonruns - THEN no
recommendationfinding is emitted for a configured framework — a duplicate enable suggestion would be an invalid nudge - AND the framework is reported as a detection instead
- VERIFIES [[cli.P-recommendation]]
Requirement: Spec Lint Command
The system SHALL provide ah lint to statically analyze OpenSpec scenario files for quality findings without modifying files or running tests.
Scenario: Lint deployed specs
- WHEN a user runs
ah lint - THEN the command analyzes all spec files under
openspec/specs/ - AND emits lint findings to stdout
- AND exits zero when only warning-severity findings are present
- VERIFIES [[cli.P-lint]]
Scenario: Lint a change overlay
- WHEN a user runs
ah lint --changes add-parser - THEN the command analyzes deployed specs plus the
add-parserchange spec overlay - VERIFIES [[cli.P-lint]]
Scenario: Lint a single check category
- WHEN a user runs
ah lint --check vague-qualifier - THEN the command runs only the
vague-qualifiercheck and skips all others - VERIFIES [[cli.P-lint]]
Scenario: Lint JSON output
- WHEN a user runs
ah lint --json - THEN the command emits a JSON object in the same schema as
ah check --json - AND each finding includes
kind,severity,spec_path,scenario_id,message, andsuggestion - VERIFIES [[cli.P-lint]]
Scenario: Clean spec exits zero with empty findings
- GIVEN all analyzed spec files pass all lint checks
- WHEN a user runs
ah lint - THEN the command exits zero
- AND emits no findings
- VERIFIES [[cli.P-lint]]
Scenario: Reject an unknown check category
- WHEN a user runs
ah lint --check no-such-check - THEN the command fails with a non-zero exit and lists the valid check kinds
- AND no spec files are analyzed
- VERIFIES [[cli.P-lint]]
id: adapters kind: intent statement: “WHEN contracts declare test commands THE adapter layer SHALL detect frameworks through a configured-manifest-environment-source precedence chain, dispatch per test type via bundled pytest, cargo, and vitest adapters normalizing output into the finding schema, emit missing-adapter findings when nothing matches, and wire custom runners through a strict JSON envelope.”
adapters Specification
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-detection-precedence | invariant | adapter detection runs through the precedence chain configured > manifest > environment > source_import before any adapter invocation, with manifest evidence winning per matching test type (cross-language evidence is not a conflict), and the selection reported by ah doctor --json / ah check --json as adapter, test_type, and detection_source ∈ {manifest, environment, source_import, configured} | [[adapters]] |
| C-pytest-adapter | invariant | the bundled pytest adapter detects pytest via pyproject.toml (reporting the detected version), runs the declared command, and normalizes output: zero exit emits no test-failing finding; non-zero emits test-failing with bounded stdout/stderr tails; JSON ImportError, missing-fixture, and collection failures remain test-failing with execution context test.type = pytest-import-error, pytest-fixture-error, pytest-collection-error | [[adapters]] |
| C-cargo-adapter | invariant | the bundled cargo adapter detects cargo via Cargo.toml, runs the declared command, and normalizes output: zero exit emits no test-failing finding; non-zero emits test-failing with bounded stdout/stderr tails | [[adapters]] |
| C-vitest-adapter | invariant | the bundled vitest adapter detects vitest via package.json dependencies (reporting the detected version), runs the declared command, and normalizes output like the cargo adapter | [[adapters]] |
| C-missing-adapter | invariant | a contract that declares a test command with no adapter configured or detected for the test type emits a missing-adapter finding carrying kind, message, suggested_action, and playbook_command, directing the user to ah doctor, and is distinct from no-tests-declared | [[adapters]] |
| C-custom-runner-envelope | invariant | [runners.custom.<name>] config blocks wire arbitrary shell commands via the JSON envelope of schemas/custom-runner.schema.json (findings conform to schemas/check-output.schema.json): the envelope requires exit_code (integer), passed (boolean), findings (array); empty findings with zero exit is a pass; envelope failure (passed = false or non-empty findings) overrides process success; process failure overrides envelope success; non-zero exit without a valid envelope emits test-failing with raw tails; no custom runner is invoked without explicit config | [[adapters]] |
Model
States
detectingrunningnormalizingreported
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-detect | detecting | running | [[adapters.C-detection-precedence]] |
| t-run | running | normalizing | a bundled adapter ([[adapters.C-pytest-adapter]], [[adapters.C-cargo-adapter]], or [[adapters.C-vitest-adapter]]) or [[adapters.C-custom-runner-envelope]] |
| t-normalize | normalizing | reported | bundled-adapter normalization or envelope processing rules |
| t-missing | detecting | reported | [[adapters.C-missing-adapter]] |
| t-no-runner | detecting | detecting | [[adapters.C-custom-runner-envelope]] (no invocation without explicit config) |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-precedence | unit | [[adapters.C-detection-precedence]] | projects with configured, manifest-declared, environment-installed, and source-imported frameworks in overlapping combinations | the strongest surviving signal per test type wins; cross-language evidence never conflicts; reports carry adapter/test_type/detection_source |
| P-pytest | unit | [[adapters.C-pytest-adapter]] | pytest runs exiting zero and non-zero, plus JSON outputs with import, fixture, and collection errors | pass normalization and test-failing classification with bounded tails and correct test.type context |
| P-cargo | unit | [[adapters.C-cargo-adapter]] | cargo test runs exiting zero and non-zero | pass normalization and test-failing with bounded tails |
| P-vitest | unit | [[adapters.C-vitest-adapter]] | vitest runs exiting zero and non-zero | pass normalization and test-failing with bounded tails |
| P-missing-adapter | unit | [[adapters.C-missing-adapter]] | a contract declaring a test with no adapter anywhere | missing-adapter finding with all four fields, distinct from no-tests-declared |
| P-envelope | unit | [[adapters.C-custom-runner-envelope]] | runners spanning the five envelope outcomes | each outcome maps to the mandated emission; no invocation occurs without config |
Purpose
TBD - created by archiving change add-quality-measurement-and-adapters. Update Purpose after archive.
Non-Goals
- Bundling adapters beyond the shipped pytest, cargo test, and vitest set — the custom runner plugin protocol is the extension path for everything else
- Owning test-framework behavior, versions, or output formats — upstream tool concerns the adapters only normalize
- Measuring test quality (mutation, coverage, flakiness) — that is the gate spec’s quality-measurement domain
Requirements
Requirement: Adapter detection precedence
The system SHALL detect framework availability through a defined precedence chain before invoking any adapter, SHALL dispatch adapters per declared contract test type rather than selecting one global adapter for the whole repository, and SHALL record the selected detection source in doctor and check output.
Scenario: Explicit config takes precedence
- GIVEN
.espectacular/config.tomlselects an adapter for a declared contract test type - WHEN adapter detection runs
- THEN the configured adapter is treated as the strongest signal
- AND the command reports
detection_source = configured - VERIFIES [[adapters.P-precedence]]
Scenario: Manifest declaration is second
- GIVEN no explicit adapter config exists
- AND a project declares a test framework in its language manifest (e.g.,
pyproject.toml,Cargo.toml,package.json) - WHEN adapter detection runs
- THEN the manifest declaration overrides environment and source signals
- VERIFIES [[adapters.P-precedence]]
Scenario: Environment detection is third
- GIVEN a framework is not declared in a manifest but is installed in the environment
- WHEN adapter detection runs
- THEN the environment presence is used to confirm availability
- VERIFIES [[adapters.P-precedence]]
Scenario: Source import is weakest signal
- GIVEN a framework is not configured, not in the manifest, and not in the environment, but is imported in a test file
- WHEN adapter detection runs
- THEN the source import is recognized as the weakest positive signal
- VERIFIES [[adapters.P-precedence]]
Scenario: Manifest signal wins for the matching adapter
- GIVEN a project manifest declares pytest as the Python test framework
- AND the environment also has vitest installed
- WHEN adapter detection runs for a Python contract test type
- THEN pytest is selected for that Python test invocation because manifest takes precedence over environment
- AND the vitest environment presence remains available for TypeScript test invocations and is not treated as a conflict
- VERIFIES [[adapters.P-precedence]]
Scenario: Detection source is reported
- GIVEN adapter detection selects a framework through manifest, environment, or source-import evidence
- WHEN
ah doctor --jsonorah check --jsonreports the selected adapter - THEN the report includes
adapter,test_type, anddetection_source - AND
detection_sourceis one ofmanifest,environment,source_import, orconfigured - VERIFIES [[adapters.P-precedence]]
Scenario: No detection signal fails to select an adapter
- GIVEN a project with no configured adapter, no manifest declaration, no installed framework, and no test-file imports
- WHEN adapter detection runs
- THEN no adapter is selected and no detection source is fabricated
- AND the contract test falls to the missing-adapter path instead of invoking a guessed runner
- VERIFIES [[adapters.P-precedence]]
Requirement: Python pytest adapter
The system SHALL provide a bundled pytest adapter that detects pytest, runs the declared test command, and normalizes output into the finding schema.
Scenario: Pytest adapter detects via pyproject.toml
- GIVEN a project contains
pyproject.tomlwith pytest in[tool.pytest.ini_options]or as a dependency - WHEN the pytest adapter runs detection
- THEN it reports pytest as available with the detected version
- VERIFIES [[adapters.P-pytest]]
Scenario: Pytest adapter normalizes zero exit to pass
- GIVEN a contract declares a pytest test command
- WHEN the adapter runs the command and pytest exits zero
- THEN the adapter emits no
test-failingfinding for that contract - VERIFIES [[adapters.P-pytest]]
Scenario: Pytest adapter normalizes non-zero exit to test-failing
- GIVEN a contract declares a pytest test command
- WHEN the adapter runs the command and pytest exits non-zero
- THEN the adapter emits a
test-failingfinding with bounded stdout/stderr tails - VERIFIES [[adapters.P-pytest]]
Scenario: Pytest adapter classifies import errors
- GIVEN pytest emits JSON output containing an
ImportError - WHEN the adapter normalizes the failing result
- THEN the finding remains
test-failing - AND the execution context reports
test.type = pytest-import-error - VERIFIES [[adapters.P-pytest]]
Scenario: Pytest adapter classifies fixture failures
- GIVEN pytest emits JSON output containing a missing fixture failure
- WHEN the adapter normalizes the failing result
- THEN the finding remains
test-failing - AND the execution context reports
test.type = pytest-fixture-error - VERIFIES [[adapters.P-pytest]]
Scenario: Pytest adapter classifies collection failures
- GIVEN pytest emits JSON output containing a collection error
- WHEN the adapter normalizes the failing result
- THEN the finding remains
test-failing - AND the execution context reports
test.type = pytest-collection-error - VERIFIES [[adapters.P-pytest]]
Requirement: Rust cargo test adapter
The system SHALL provide a bundled cargo test adapter that detects cargo, runs the declared test command, and normalizes output into the finding schema.
Scenario: Cargo adapter detects via Cargo.toml
- GIVEN a project contains
Cargo.toml - WHEN the cargo adapter runs detection
- THEN it reports cargo test as available
- VERIFIES [[adapters.P-cargo]]
Scenario: Cargo adapter normalizes zero exit to pass
- GIVEN a contract declares a cargo test command
- WHEN the adapter runs the command and cargo exits zero
- THEN the adapter emits no
test-failingfinding for that contract - VERIFIES [[adapters.P-cargo]]
Scenario: Cargo adapter normalizes non-zero exit to test-failing
- GIVEN a contract declares a cargo test command
- WHEN the adapter runs the command and cargo exits non-zero
- THEN the adapter emits a
test-failingfinding with bounded stdout/stderr tails - VERIFIES [[adapters.P-cargo]]
Requirement: TypeScript vitest adapter
The system SHALL provide a bundled vitest adapter that detects vitest, runs the declared test command, and normalizes output into the finding schema.
Scenario: Vitest adapter detects via package.json
- GIVEN a project contains
package.jsonwith vitest independenciesordevDependencies - WHEN the vitest adapter runs detection
- THEN it reports vitest as available with the detected version
- VERIFIES [[adapters.P-vitest]]
Scenario: Vitest adapter normalizes zero exit to pass
- GIVEN a contract declares a vitest test command
- WHEN the adapter runs the command and vitest exits zero
- THEN the adapter emits no
test-failingfinding for that contract - VERIFIES [[adapters.P-vitest]]
Scenario: Vitest adapter normalizes non-zero exit to test-failing
- GIVEN a contract declares a vitest test command
- WHEN the adapter runs the command and vitest exits non-zero
- THEN the adapter emits a
test-failingfinding with bounded stdout/stderr tails - VERIFIES [[adapters.P-vitest]]
Requirement: No-adapter-configured path
When a contract declares a test command but no adapter is configured or detected for the declared test type, the system SHALL emit a missing-adapter finding with kind, message, suggested_action, and playbook_command fields.
Scenario: Missing adapter emits missing-adapter finding
- GIVEN a contract declares a test command
- AND no adapter is configured in
.espectacular/config.tomlfor the declared test type - AND adapter detection finds no matching framework
- WHEN
ah checkruns - THEN a
missing-adapterfinding is emitted with a message directing the user to runah doctor - AND the finding is distinct from
no-tests-declaredbecause the contract did declare a test - VERIFIES [[adapters.P-missing-adapter]]
Scenario: Configured adapter suppresses missing-adapter
- GIVEN a contract declares a test command
- AND an adapter is configured in
.espectacular/config.tomlfor the declared test type - WHEN
ah checkruns - THEN no
missing-adapterfinding is emitted for that contract - AND the run fails only when the declared test itself fails
- VERIFIES [[adapters.P-precedence]]
Requirement: Custom runner plugin protocol
The system SHALL support [runners.custom.<name>] config blocks that wire arbitrary shell commands into the adapter layer via a documented JSON envelope defined in schemas/custom-runner.schema.json.
Note: The envelope schema (schemas/custom-runner.schema.json) specifies the top-level structure the shell command must emit. Individual findings within the findings array conform to the full finding schema (schemas/check-output.schema.json), not to the envelope schema.
Scenario: Custom runner emits required envelope fields
- GIVEN a custom runner is configured and invoked
- WHEN the runner’s stdout is parsed
- THEN the envelope contains at minimum:
exit_code(integer),passed(boolean),findings(array) - AND each finding in the array conforms to the full finding schema
- VERIFIES [[adapters.P-envelope]]
Scenario: Empty findings array with zero exit is a pass
- GIVEN a custom runner exits zero
- AND the envelope
findingsarray is empty - WHEN the adapter processes the result
- THEN no
test-failingfinding is emitted for that contract - VERIFIES [[adapters.P-envelope]]
Scenario: Envelope failure overrides process success
- GIVEN a custom runner exits zero
- AND stdout contains a valid envelope with
passed = falseor non-emptyfindings - WHEN the adapter processes the result
- THEN the adapter emits the envelope findings
- AND treats the contract as not passing
- VERIFIES [[adapters.P-envelope]]
Scenario: Process failure overrides envelope success
- GIVEN a custom runner exits non-zero
- AND stdout contains a valid envelope with
passed = trueand an emptyfindingsarray - WHEN the adapter processes the result
- THEN a
test-failingfinding is emitted with the raw stdout/stderr tails - AND the process exit code is preserved in the finding
- VERIFIES [[adapters.P-envelope]]
Scenario: Custom runner non-zero exit without valid envelope is an error finding
- GIVEN a custom runner exits non-zero
- AND stdout is not a valid envelope
- WHEN the adapter processes the result
- THEN a
test-failingfinding is emitted with the raw stdout/stderr tails - VERIFIES [[adapters.P-envelope]]
Scenario: Custom runner is not invoked without explicit config
- GIVEN no
[runners.custom.<name>]block exists in config - WHEN
ah checkruns - THEN no custom runner is invoked
- VERIFIES [[adapters.P-envelope]]
id: explain kind: intent statement: “WHEN a user runs ah explain THE playbook SHALL serve compile-enforced topics for every FindingKind, SuggestedAction, quality finding kind, and compiled-in adapter capability, with stable sorted listing, machine-readable JSON, and non-zero exit for unknown topics.”
explain Specification
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-compile-enforced | invariant | the build fails if any FindingKind or SuggestedAction enum variant lacks a playbook topic body, and succeeds enumerating all topics when complete | [[explain]] |
| C-topic-coverage | invariant | ah explain provides topics for every FindingKind value, every SuggestedAction value, and a set of general topics | [[explain]] |
| C-json-schema | invariant | ah explain <topic> --json emits a valid JSON object with topic, summary, when, do (string array), human_approval (boolean), related_topics (string array), and hints (objects with kind and message), for every valid topic | [[explain]] |
| C-listing-stable | invariant | ah explain --list prints all topic identifiers one per line, sorted alphabetically, identical across runs | [[explain]] |
| C-unknown-topic | invariant | an unknown topic exits non-zero with either the sorted topic list or a pointer to ah explain --list | [[explain]] |
| C-quality-kinds | invariant | topics exist for the quality finding kinds quality-mutation, quality-property, quality-snapshot, and are subject to compile enforcement like all FindingKind values | [[explain]] |
| C-adapter-topics | invariant | progressive-enablement capability topics are included when their adapter modules are compiled in; two adapter modules registering the same topic identifier fail the build | [[explain]] |
Model
States
compilingservingerrored
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-enforce | compiling | compiling | [[explain.C-compile-enforced]] AND [[explain.C-adapter-topics]] (duplicate registration) |
| t-serve | compiling | serving | [[explain.C-topic-coverage]] AND [[explain.C-quality-kinds]] |
| t-json | serving | serving | [[explain.C-json-schema]] |
| t-list | serving | serving | [[explain.C-listing-stable]] |
| t-unknown | serving | errored | [[explain.C-unknown-topic]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-enforced | unit | [[explain.C-compile-enforced]] | a build with one variant stripped of its topic body vs the complete set | the stripped build fails naming the missing topic; the complete build succeeds and --list enumerates every variant |
| P-coverage | unit | [[explain.C-topic-coverage]] | every FindingKind and SuggestedAction value plus a general topic | each resolves via ah explain <topic> printing guidance and exiting zero |
| P-json | unit | [[explain.C-json-schema]] | every valid topic with --json | output is a valid object with exactly the required fields and typed hints entries |
| P-listing | unit | [[explain.C-listing-stable]] | two successive --list runs | outputs are byte-identical and alphabetically sorted |
| P-unknown | unit | [[explain.C-unknown-topic]] | ah explain no-such-topic | exits non-zero with the topic list or the --list pointer |
| P-quality | unit | [[explain.C-quality-kinds]] | ah explain quality-mutation, quality-property, quality-snapshot | each prints kind-specific guidance (mutation meaning/enablement) and exits zero |
| P-adapter | unit | [[explain.C-adapter-topics]] | a build with the pytest adapter compiled in vs two modules registering one topic id | the first serves ah explain pytest enablement guidance; the second fails the build naming the conflict |
Purpose
TBD - created by archiving change add-quality-measurement-and-adapters. Update Purpose after archive.
Non-Goals
- Serving playbook content from runtime or external files — topics are embedded in the binary at compile time
- Authoring playbooks for upstream tools’ own findings (genesis, specodelic) — only espectacular’s kinds and actions
- Localizing or theming playbook text — English-only output
Requirements
Requirement: Playbook is compile-enforced
The system SHALL fail to build if any FindingKind or SuggestedAction enum variant lacks a corresponding ah explain topic body.
Scenario: Missing topic body is a compile error
- GIVEN a
FindingKindorSuggestedActionvariant has no associated playbook body - WHEN the project is built with
cargo build - THEN the build fails with an error identifying the missing topic
- VERIFIES [[explain.P-enforced]]
Scenario: All variants have topics at build time
- GIVEN all enum variants have associated playbook bodies
- WHEN the project is built
- THEN the build succeeds and
ah explain --listenumerates them all - VERIFIES [[explain.P-coverage]]
Requirement: Topic coverage
The system SHALL provide ah explain topics for every FindingKind value, every SuggestedAction value, and a set of general topics.
Scenario: Finding kind topic exists
- WHEN a user runs
ah explain no-toml - THEN the command prints guidance for the
no-tomlfinding kind and exits zero - VERIFIES [[explain.P-coverage]]
Scenario: Suggested action topic exists
- WHEN a user runs
ah explain run_ah_scenario_new - THEN the command prints guidance for the
run_ah_scenario_newaction and exits zero - VERIFIES [[explain.P-coverage]]
Scenario: General topic exists
- WHEN a user runs
ah explain workflow - THEN the command prints general workflow guidance and exits zero
- VERIFIES [[explain.P-coverage]]
Scenario: Topic with no kind or action is refused
- WHEN a user runs
ah explainwith a topic identifier that corresponds to noFindingKind,SuggestedAction, or general topic - THEN the command exits non-zero and prints no guidance for it
- VERIFIES [[explain.P-unknown]]
Requirement: Structured JSON output
The system SHALL support --json output for ah explain that emits a machine-readable object.
Scenario: JSON output has required fields
- WHEN a user runs
ah explain no-toml --json - THEN the output is a valid JSON object containing:
topic(string),summary(string),when(string),do(array of strings),human_approval(boolean),related_topics(array of strings),hints(array of objects) - AND each
hintsitem containskind(string) andmessage(string) - VERIFIES [[explain.P-json]]
Scenario: JSON output is valid for every topic
- GIVEN any valid topic identifier
- WHEN
ah explain <topic> --jsonis run - THEN the output passes JSON schema validation
- VERIFIES [[explain.P-json]]
Scenario: JSON output is not emitted for an unknown topic
- WHEN a user runs
ah explain no-such-topic --json - THEN the command exits non-zero and emits a plain-text error, never a malformed JSON object
- VERIFIES [[explain.P-unknown]]
Requirement: Topic listing
The system SHALL enumerate all available topics on demand.
Scenario: List enumerates all topics
- WHEN a user runs
ah explain --list - THEN the command prints all topic identifiers, one per line, and exits zero
- VERIFIES [[explain.P-listing]]
Scenario: List is stable across runs
- WHEN
ah explain --listis run twice in succession - THEN the output is identical (topics are sorted alphabetically)
- VERIFIES [[explain.P-listing]]
Scenario: Invalid extra argument does not corrupt the listing
- WHEN
ah explain --listis run with a stray positional argument - THEN the command still prints the full sorted topic list and exits zero
- VERIFIES [[explain.P-listing]]
Requirement: Unknown topic handling
When a user requests an unknown ah explain topic, the system SHALL exit non-zero and print either Run ah explain --list or the sorted list of available topic identifiers.
Scenario: Unknown topic exits non-zero
- WHEN a user runs
ah explain no-such-topic - THEN the command exits non-zero
- AND the error message lists available topics or directs the user to
ah explain --list - VERIFIES [[explain.P-unknown]]
Requirement: Quality finding kind topics
The system SHALL provide ah explain topics for every quality finding kind introduced by this change: quality-mutation, quality-property, quality-snapshot. These are FindingKind values and are therefore subject to the compile-enforcement requirement.
Scenario: quality-mutation topic exists
- WHEN a user runs
ah explain quality-mutation - THEN the command prints guidance explaining what the mutation score means, how to enable mutation testing, and when the finding appears
- VERIFIES [[explain.P-quality]]
Scenario: quality-property topic exists
- WHEN a user runs
ah explain quality-property - THEN the command prints guidance for the
quality-propertyfinding kind and exits zero - VERIFIES [[explain.P-quality]]
Scenario: quality-snapshot topic exists
- WHEN a user runs
ah explain quality-snapshot - THEN the command prints guidance for the
quality-snapshotfinding kind and exits zero - VERIFIES [[explain.P-quality]]
Scenario: Unknown quality topic is refused
- WHEN a user runs
ah explain quality-nonexistent - THEN the command exits non-zero and prints no quality guidance
- VERIFIES [[explain.P-unknown]]
Requirement: Adapter topics ship with adapters
The system SHALL include ah explain topics for progressive-enablement capabilities when their adapter modules are compiled in.
Scenario: Pytest adapter contributes topic
- GIVEN the pytest adapter is compiled into the binary
- WHEN a user runs
ah explain pytest - THEN the command prints guidance for enabling and using the pytest adapter
- VERIFIES [[explain.P-adapter]]
Scenario: Duplicate topic registration is a compile error
- GIVEN two adapter modules attempt to register the same topic identifier
- WHEN the project is built
- THEN the build fails identifying the conflicting topic name
- VERIFIES [[explain.P-enforced]]
id: cli.core kind: intent statement: “WHEN the tool adopts genesis::guide or a user runs ah sync THE CLI SHALL build the scaffold via Guide::builder, return Output or use ErrorSink with errors surfaced via suggestion footers, create or refresh property-derived contracts for the current scope, exit non-zero when spk lint fails or spk is unavailable, and support a check mode that writes nothing and fails on drift.”
cli-core spec delta: adopt genesis::guide
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-guide-builder | invariant | the tool’s main.rs CLI setup uses genesis::guide::Guide::builder() when the tool adopts genesis::guide (adoption is the maintainer’s decision — this spec fixes the contract, not the timing), with cargo test passing | [[cli.core]] |
| C-output-handlers | advisory | command handlers SHOULD return Output<T> or use ErrorSink | [[cli.core]] |
| C-errorsink-selfheal | advisory | on a command error, ErrorSink SHOULD print the error with a suggestion footer and write to the error scratch (for --from-last-error) | [[cli.core]] |
| C-sync-refusal | invariant | ah sync exits non-zero without writing when spk lint fails on any target file or when the spk binary is missing, reporting spk-unavailable for the latter | [[cli.core]] |
| C-sync-check | invariant | ah sync –check writes nothing, exits zero when all derived contracts exist and are fresh, and exits non-zero on drift or missing contracts | [[cli.core]] |
| C-sync-registration | invariant | ah sync is registered in the Command enum with help text and completions like every other subcommand | [[cli.core]] |
| C-sync-scope | advisory | without an explicit scope, ah sync operates on the same default scope as ah check | [[cli.core]] |
Model
States
ad-hoc-cliguide-builtlintingparsingwrittenchecked
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-build | ad-hoc-cli | guide-built | [[cli.core.C-guide-builder]] |
| t-refuse | linting | linting | [[cli.core.C-sync-refusal]] |
| t-parse | linting | parsing | [[cli.core.C-sync-refusal]] |
| t-write | parsing | written | [[cli.core.C-sync-registration]] |
| t-check | parsing | checked | [[cli.core.C-sync-check]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-guide-builder | unit | [[cli.core.C-guide-builder]] | the adopted main.rs | CLI setup is constructed via Guide::builder() and tests pass |
| P-handlers | unit | [[cli.core.C-output-handlers]] | each command handler | handlers return Output<T> or use ErrorSink where the advisory is honored |
| P-selfheal | unit | [[cli.core.C-errorsink-selfheal]] | a failing command run | the error prints with a suggestion footer and lands in the error scratch readable by --from-last-error |
| P-refuse | unit | [[cli.core.C-sync-refusal]] | a scope containing a lint-dirty dual-format file, and a scope with no spk binary on PATH | sync exits non-zero with a reason in both cases and writes nothing |
| P-check | unit | [[cli.core.C-sync-check]] | a scope with fresh contracts, drifted contracts, and missing contracts | check mode exits zero, non-zero, and non-zero respectively; no file is modified in any case |
| P-register | unit | [[cli.core.C-sync-registration]] | ah –help and shell completion generation | sync appears with help text and completes like other subcommands |
| P-scope | unit | [[cli.core.C-sync-scope]] | sync invoked with and without an explicit scope | the default scope matches ah check’s default scope |
Purpose
Adopt genesis::guide for the CLI scaffold: Guide::builder() replaces
ad-hoc setup, handlers standardize on Output<T>/ErrorSink, and errors
become self-healing via suggestion footers and the error scratch.
Give adopters a single command that turns lint-clean Properties rows into
gate-readable contracts, safely refreshable in CI via --check.
Non-Goals
- Changing
ah checkscope resolution, findings, or exit-code semantics — owned by the gate spec - Defining contract file format or
derived_fromsemantics — owned by the gate spec’s Contract Schema and Property-Derived Contracts requirements - Authoring or repairing spec text:
ah syncderives contracts from existing lint-clean rows, it never edits the specs themselves - Vendoring or wrapping
spkbehavior: lint rules and their semantics live upstream in specodelic
Requirements
Requirement: CLI scaffold uses Guide
The tool’s main.rs CLI setup SHALL use genesis::guide::Guide::builder()
when the tool adopts genesis::guide. Adoption is determined by the tool’s
maintainer — this spec defines the contract for how to do it.
Scenario: Guide builder replaces ad-hoc setup
- GIVEN the tool adopts
genesis::guide - WHEN
main.rsis updated to useGuide::builder(...) - THEN command handlers SHOULD return
Output<T>or useErrorSink - AND
cargo testSHALL pass - VERIFIES [[cli.core.P-guide-builder]]
Scenario: ErrorSink for self-healing errors
- GIVEN the tool adopts
genesis::guide - WHEN a command returns an error
- THEN
ErrorSinkSHOULD print the error with a suggestion footer - AND it SHOULD write to the error scratch (for
--from-last-error) - VERIFIES [[cli.core.P-handlers]]
- VERIFIES [[cli.core.P-selfheal]]
Requirement: Sync Command
ah sync SHALL create or refresh property-derived contracts for the current scope, SHALL exit non-zero without writing when spk lint fails on any target file or when spk is unavailable, and SHALL support --check, which writes nothing and exits non-zero on drift or missing contracts.
Scenario: Sync creates derived contracts
- GIVEN the default scope contains a lint-clean dual-format spec with Properties rows
- WHEN
ah syncruns - THEN a derived contract exists per property row with
derived_fromrecorded - VERIFIES [[cli.core.P-scope]]
Scenario: Sync is registered like every subcommand
- GIVEN the tool’s CLI is built
- WHEN a user runs
ah --helpor generates shell completions - THEN
syncappears with help text and completes like the other subcommands - VERIFIES [[cli.core.P-register]]
Scenario: Sync refuses on lint failure
- GIVEN the scope contains a dual-format spec that fails
spk lint - WHEN
ah syncruns - THEN it exits non-zero, names the offending file, and writes nothing
- VERIFIES [[cli.core.P-refuse]]
Scenario: Missing spk reports spk-unavailable
- GIVEN
spkis not on PATH - WHEN
ah syncruns - THEN it exits non-zero with an
spk-unavailablereason - VERIFIES [[cli.core.P-refuse]]
Scenario: Check mode is CI-safe
- GIVEN a scope with one drifted and one missing derived contract
- WHEN
ah sync --checkruns - THEN it exits non-zero, reports both, and modifies no files
- VERIFIES [[cli.core.P-check]]
id: config kind: intent statement: “WHEN the tool adopts genesis::config THE tool SHALL implement ConfigFile, delegate all config file I/O to genesis, register its config with ConfigRegistry at startup, and remove dead config code without new clippy warnings.”
config spec delta: adopt genesis::config
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-configfile-impl | invariant | the tool’s config struct implements genesis::config::ConfigFile and all config file I/O (read, write, parse) delegates to genesis, with cargo test passing | [[config]] |
| C-startup-registration | invariant | at startup the tool registers its config struct with ConfigRegistry | [[config]] |
| C-configstore-advisory | advisory | config discovery and validation SHOULD use ConfigStore | [[config]] |
| C-dead-code-removed | invariant | after adoption the old config parsing code is removed, cargo clippy introduces no new warnings, and cargo test passes | [[config]] |
Model
States
own-configgenesis-configregistered
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-adopt | own-config | genesis-config | [[config.C-configfile-impl]] |
| t-register | genesis-config | registered | [[config.C-startup-registration]] |
| t-prune | registered | registered | [[config.C-dead-code-removed]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-configfile | unit | [[config.C-configfile-impl]] | the adopted config struct | it implements ConfigFile; every read, write, and parse call routes through genesis; tests pass |
| P-registration | unit | [[config.C-startup-registration]] | a startup run | the config struct is registered with ConfigRegistry before config is first read |
| P-configstore | unit | [[config.C-configstore-advisory]] | config discovery and validation paths | they use ConfigStore where the advisory is honored |
| P-prune | unit | [[config.C-dead-code-removed]] | the post-adoption tree | no old config parsing code remains; cargo clippy adds no new warnings and cargo test passes |
Purpose
Adopt genesis::config as the shared config substrate: the tool’s config
implements ConfigFile, all file I/O delegates to genesis, startup registers
with ConfigRegistry, and dead local config code is removed cleanly.
Non-Goals
- Defining the tool’s config schema or default values — each tool owns its own config content
- genesis::config internals — the substrate’s behavior is an upstream concern
- Remote or secret-management config sources — local file I/O only
Requirements
Requirement: Shared config management
The tool SHALL adopt genesis::config for shared config management.
Scenario: config struct implements ConfigFile
- GIVEN the tool needs config management
- WHEN the tool adopts
genesis::config - THEN the tool’s config SHALL implement
genesis::config::ConfigFile - AND all config file I/O (read, write, parse) SHALL delegate to genesis
- AND
cargo testSHALL pass - VERIFIES [[config.P-configfile]]
- VERIFIES [[config.P-configstore]]
Scenario: config registered at startup
- GIVEN the tool starts up
- WHEN it initializes
- THEN it SHALL register its config struct with
ConfigRegistry - AND it SHOULD use
ConfigStorefor config discovery and validation - VERIFIES [[config.P-registration]]
Scenario: dead config code removed
- GIVEN the tool has adopted genesis::config
- WHEN the old config parsing code is removed
- THEN
cargo clippySHALL introduce no new warnings - AND
cargo testSHALL pass - VERIFIES [[config.P-prune]]
Scenario: Config I/O bypassing genesis is rejected
- GIVEN the tool has adopted
genesis::config - WHEN a config read, write, or parse call routes anywhere other than genesis
- THEN the delegation property is violated and the change is rejected
- AND
cargo testSHALL fail - VERIFIES [[config.P-configfile]]
id: lint kind: intent statement: “WHEN ah lint inspects specification files THE linter SHALL flag vague qualifiers, imperative UI steps, conjunctive step bloat, missing negative scenarios, missing non-goals, unresolved ambiguities, and entangled presentation concerns, emit findings in the shared check finding schema with warning severity by default, bridge specodelic findings for dual-format files without reimplementing specodelic rules, never skip a kind-intent file silently, relay spk graph typing violations and dangling references as spk.graph.* warnings, and report scenario-to-property trace gaps as advisory findings that never affect the exit code.”
lint Specification
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-vague-qualifier | invariant | requirement bodies and scenario steps containing unbound qualitative terms without an adjacent numeric measurement emit a vague-qualifier finding suggesting a measurable condition; a qualifier with an adjacent numeric bound emits none | [[lint]] |
| C-imperative-step | invariant | WHEN/THEN steps describing UI mechanics (explicit click, URL navigation) emit an imperative-step finding suggesting business-action phrasing; declarative business steps emit none | [[lint]] |
| C-conjunctive-bloat | invariant | scenarios chaining more than the configured maximum of AND-linked steps emit a conjunctive-bloat finding suggesting a split; a scenario at exactly the limit is accepted; the maximum is configurable via [lint] max_and_steps in .espectacular/config.toml | [[lint]] |
| C-missing-negative | invariant | a requirement with no scenario exercising an error, rejection, or boundary condition emits a missing-negative-scenario finding suggesting a failure-mode scenario; one negative scenario suppresses it | [[lint]] |
| C-missing-non-goals | invariant | a spec capability file lacking a Non-Goals/Non Goals/non-goals/Out of Scope heading emits a missing-non-goals finding for that file | [[lint]] |
| C-unresolved-ambiguity | invariant | requirement or scenario text containing a bracketed NEEDS CLARIFICATION marker emits an unresolved-ambiguity finding including the marker as context; text without the marker emits none | [[lint]] |
| C-entangled-spec | invariant | a scenario with contract archetype PF or SA whose text references presentation-layer primitives (buttons, modals, colors, CSS selectors, component names, client routes) emits an entangled-spec finding suggesting the domain event or state; the same text in a BP scenario and domain-legitimate lookalike terms are not flagged | [[lint]] |
| C-finding-schema | invariant | lint findings use the same stable JSON envelope as ah check: each carries kind, severity, spec_path, message, suggested_action, playbook_command, plus scenario_id/scenario_title when scenario-specific; findings default to severity = "warning"; a malformed spec file emits a severity = "error" finding and exits non-zero | [[lint]] |
| C-dual-bridge | invariant | for dual-format files (id: spec frontmatter) ah lint invokes spk lint and relays each specodelic finding as kind = "spk.<rule_id>" with severity = "warning"; plain openspec files cause no specodelic invocation and identical pre-bridge output; a missing spk binary emits one advisory spk-unavailable finding with unchanged exit code; a non-envelope spk failure emits one advisory finding and linting continues | [[lint]] |
| C-no-silent-skip | invariant | a kind-intent file whose frontmatter id does not match its filename stem emits an spk-frontmatter-mismatch finding instead of being skipped silently | [[lint]] |
| C-graph-relay | invariant | spk graph typing violations and dangling references for dual-format files are relayed as spk.graph.* warning findings | [[lint]] |
| C-trace-advisory | advisory | verifies-dangling, scenario-unlinked, and property-untraced are reported at advisory severity and never affect the lint exit code | [[lint]] |
| C-plain-untouched | advisory | plain openspec files never trigger an spk invocation and produce no specodelic findings | [[lint]] |
Model
States
scanningbridgingreportedrelayingtracing
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-textual-rules | scanning | scanning | [[lint.C-vague-qualifier]], [[lint.C-imperative-step]], [[lint.C-conjunctive-bloat]], [[lint.C-missing-negative]], [[lint.C-missing-non-goals]], [[lint.C-unresolved-ambiguity]], or [[lint.C-entangled-spec]] |
| t-bridge | scanning | bridging | [[lint.C-dual-bridge]] (dual-format file present) |
| t-inert | scanning | scanning | [[lint.C-dual-bridge]] (plain file — no invocation) |
| t-schema | bridging | reported | [[lint.C-finding-schema]] |
| t-degrade | bridging | reported | [[lint.C-dual-bridge]] (missing binary or failed invocation — advisory only) |
| t-relay | bridging | relaying | [[lint.C-no-silent-skip]] |
| t-graph | relaying | tracing | [[lint.C-graph-relay]] |
| t-trace | tracing | reported | [[lint.C-graph-relay]] |
| t-plain | relaying | reported | [[lint.C-no-silent-skip]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-vague | unit | [[lint.C-vague-qualifier]] | bodies with unbound qualifiers vs numerically bounded qualifiers, in requirements and THEN steps | findings exactly in the unbound cases, with measurable-condition suggestions |
| P-imperative | unit | [[lint.C-imperative-step]] | WHEN steps with click/URL mechanics vs declarative business steps | findings exactly in the mechanical cases, with business-action suggestions |
| P-bloat | unit | [[lint.C-conjunctive-bloat]] | scenarios at limit-1, limit, and limit+1 AND-steps under default and custom maximums | finding only above the configured limit; exact limit accepted |
| P-negative | unit | [[lint.C-missing-negative]] | requirements with only happy-path scenarios vs one negative scenario | finding exactly in the happy-path-only case |
| P-non-goals | unit | [[lint.C-missing-non-goals]] | spec files with and without any non-goals heading variant | finding exactly when no variant is present |
| P-ambiguity | unit | [[lint.C-unresolved-ambiguity]] | bodies with and without bracketed NEEDS CLARIFICATION markers | finding with marker context exactly when present |
| P-entangled | unit | [[lint.C-entangled-spec]] | PF/SA scenarios with presentation primitives vs BP scenarios and domain lookalikes | findings exactly in the PF/SA primitive cases |
| P-schema | unit | [[lint.C-finding-schema]] | lint runs over well-formed and malformed specs | shared finding fields throughout, warning default, malformed → error + non-zero exit |
| P-bridge | unit | [[lint.C-dual-bridge]] | dual files with spk findings, plain files, missing binary, and failing invocation | relaying with spk. kind prefix; inert for plain files; advisory-only degradation in both failure modes |
| P-mismatch | unit | [[lint.C-no-silent-skip]] | a kind-intent file whose frontmatter id differs from its filename stem | lint emits spk-frontmatter-mismatch naming the file and the expected id |
| P-graph | unit | [[lint.C-graph-relay]] | a dual-format corpus containing a typing violation and a dangling reference | both are relayed as spk.graph.* warnings |
| P-trace | unit | [[lint.C-trace-advisory]] | a dual-format corpus with a dangling VERIFIES link, an unlinked scenario, and an untraced property | all three appear as advisory findings; the exit code is zero |
| P-plain | unit | [[lint.C-plain-untouched]] | a corpus of plain openspec spec files | lint output contains no specodelic findings and no spk invocation occurs |
Purpose
The spec linter: textual spec-shape findings (vague qualifiers, imperative steps, conjunctive bloat, missing negative scenarios and non-goals, unresolved ambiguities, entangled specs), the shared stable finding schema, and the dual-format specodelic bridge — complete since derive-contracts-from-specodelic: no silent skips, spk graph relay, and advisory scenario-to-property trace checks.
Requirements
Requirement: Vague Qualifier Detection
The system SHALL flag requirements and scenario steps that contain unbound qualitative terms without an adjacent numeric measurement.
Scenario: Flag unbound qualifier in requirement
- GIVEN a requirement body contains the unbound qualifier “fast” with no numeric bound (a line like “p95 < 200 ms” would pass)
- WHEN
ah lintruns - THEN the command emits a
vague-qualifierfinding for that requirement - AND the finding suggests adding a measurable response condition (e.g., “within 200 ms”)
- VERIFIES [[lint.P-vague]]
Scenario: Do not flag bounded qualifier
- GIVEN a requirement body contains “fast (p95 < 200 ms)”
- WHEN
ah lintruns - THEN no
vague-qualifierfinding is emitted for that requirement - AND no error-severity finding is emitted — a numeric bound satisfies the check
- VERIFIES [[lint.P-vague]]
Scenario: Flag qualifier in scenario step
- GIVEN a THEN step contains “the response is user-friendly” and the step line carries no numeric bound (a step like “served under 200 ms” would pass)
- WHEN
ah lintruns - THEN the command emits a
vague-qualifierfinding for that scenario step - VERIFIES [[lint.P-vague]]
Requirement: Imperative Step Detection
The system SHALL flag WHEN and THEN steps that describe UI mechanics rather than business intent, coupling the spec to implementation details.
Scenario: Flag explicit click step
- GIVEN a WHEN step contains “clicks the Submit button”
- WHEN
ah lintruns - THEN the command emits an
imperative-stepfinding - AND the finding suggests rephrasing to describe the business action (e.g., “submits the form”)
- VERIFIES [[lint.P-imperative]]
Scenario: Flag URL navigation step
- GIVEN a WHEN step contains “navigates to /dashboard/settings”
- WHEN
ah lintruns - THEN the command emits an
imperative-stepfinding - VERIFIES [[lint.P-imperative]]
Scenario: Do not flag declarative business steps
- GIVEN a WHEN step contains “the user submits a payment”
- WHEN
ah lintruns - THEN no
imperative-stepfinding is emitted - AND no error-severity finding is emitted — business phrasing passes cleanly
- VERIFIES [[lint.P-imperative]]
Requirement: Conjunctive Step Bloat Detection
The system SHALL flag scenarios that chain more than the configured maximum number of AND-linked steps, indicating a scenario that tests multiple behaviors at once.
Scenario: Flag overlong scenario
- GIVEN a scenario has eight AND-linked steps
- AND the configured maximum is five
- WHEN
ah lintruns - THEN the command emits a
conjunctive-bloatfinding - AND the finding suggests splitting the scenario into focused single-behavior scenarios
- VERIFIES [[lint.P-bloat]]
Scenario: Accept scenario within limit
- GIVEN a scenario has four AND-linked steps
- AND the configured maximum is five
- WHEN
ah lintruns - THEN no
conjunctive-bloatfinding is emitted for that scenario - VERIFIES [[lint.P-bloat]]
Scenario: Scenario exactly at limit is accepted
- GIVEN a scenario has five AND-linked steps
- AND the configured maximum is five
- WHEN
ah lintruns - THEN no
conjunctive-bloatfinding is emitted for that scenario — the limit boundary does not fail - VERIFIES [[lint.P-bloat]]
Scenario: Default maximum is configurable
- GIVEN
.espectacular/config.tomlsets[lint] max_and_steps = 3 - WHEN
ah lintruns - THEN the command uses 3 as the maximum AND-step count
- VERIFIES [[lint.P-bloat]]
Requirement: Missing Negative Scenario Detection
The system SHALL flag requirements that have no scenario exercising an error condition, rejection, or boundary violation, indicating incomplete behavioral specification.
Scenario: Flag requirement with only happy-path scenarios
- GIVEN a requirement has two scenarios, both describing successful outcomes
- AND no scenario contains rejection, error, or boundary language
- WHEN
ah lintruns - THEN the command emits a
missing-negative-scenariofinding - AND the finding suggests adding a scenario for the corresponding failure mode
- VERIFIES [[lint.P-negative]]
Scenario: Accept requirement with at least one negative scenario
- GIVEN a requirement has a scenario whose THEN step contains “the command exits non-zero”
- WHEN
ah lintruns - THEN no
missing-negative-scenariofinding is emitted for that requirement - VERIFIES [[lint.P-negative]]
Requirement: Missing Non-Goals Detection
The system SHALL flag spec capability files that lack an explicit Non-Goals or Out-of-Scope section, since their absence enables silent scope creep during AI-assisted implementation.
Scenario: Flag spec without non-goals section
- GIVEN a
spec.mdfile contains no heading matchingNon-Goals,Non Goals,non-goals, orOut of Scope - WHEN
ah lintruns - THEN the command emits a
missing-non-goalsfinding for that spec file - VERIFIES [[lint.P-non-goals]]
Scenario: Accept spec with non-goals section
- GIVEN a
spec.mdfile contains a## Non-Goalsheading - WHEN
ah lintruns - THEN no
missing-non-goalsfinding is emitted for that file — an explicit exclusion section is never rejected - VERIFIES [[lint.P-non-goals]]
Requirement: Unresolved Ambiguity Detection
The system SHALL flag requirements and scenarios that contain the bracketed NEEDS CLARIFICATION marker, indicating authoring-time decisions deferred but not yet resolved.
Scenario: Flag needs-clarification marker
- GIVEN a requirement body contains a bracketed NEEDS CLARIFICATION marker (e.g. an unresolved question about which auth provider applies, wrapped in square brackets)
- WHEN
ah lintruns - THEN the command emits an
unresolved-ambiguityfinding - AND the finding includes the marker text as context
- VERIFIES [[lint.P-ambiguity]]
Scenario: Accept requirement with no ambiguity markers
- GIVEN a requirement body contains no bracketed NEEDS CLARIFICATION marker, and no line pairs those two words inside square brackets
- WHEN
ah lintruns - THEN no
unresolved-ambiguityfinding is emitted for that requirement - AND resolved requirements emit no error — only deferred decisions are flagged
- VERIFIES [[lint.P-ambiguity]]
Requirement: Entangled Specification Detection
The system SHALL flag scenarios whose contract archetype is PF or SA but whose text references presentation-layer primitives (buttons, modals, colors, CSS selectors, component names, client routes), since the archetype declares the scenario belongs to a layer where UI mechanics must not appear. The same text in a BP scenario is not flagged, as transport mechanics are legitimate at a boundary seam.
Scenario: Flag UI primitive in PF scenario
- GIVEN a scenario with contract archetype
PFwhose THEN step contains “a green notification banner is displayed” - WHEN
ah lintruns - THEN the command emits an
entangled-specfinding - AND the finding suggests specifying the domain event or state instead of the visual primitive
- VERIFIES [[lint.P-entangled]]
Scenario: Do not flag transport mechanics in BP scenario
- GIVEN a scenario with contract archetype
BPwhose WHEN step contains “navigates to /dashboard/settings” - WHEN
ah lintruns - THEN no
entangled-specfinding is emitted for that scenario - VERIFIES [[lint.P-entangled]]
Scenario: Do not flag domain language resembling UI terms
- GIVEN a scenario with contract archetype
SAwhose text contains “the event routing layer delivers the message” - WHEN
ah lintruns - THEN no
entangled-specfinding is emitted, as the matcher list excludes domain-legitimate uses of superficially similar terms - AND no error-severity finding is emitted for the lookalike either
- VERIFIES [[lint.P-entangled]]
Requirement: Lint Finding Schema
The system SHALL emit lint findings using the same stable JSON envelope as ah check, enabling agent harnesses to consume both without separate parsing logic.
Scenario: Lint findings share check finding fields
- GIVEN
ah lint --jsonproduces findings - WHEN the JSON output is inspected
- THEN each finding contains
kind,severity,spec_path,message,suggested_action, andplaybook_command - AND findings that reference a specific scenario also contain
scenario_idandscenario_title - VERIFIES [[lint.P-schema]]
Scenario: Lint findings are warning severity by default
- GIVEN
ah lintproduces warning-severity findings for any lint check category - WHEN the JSON output is inspected
- THEN every finding has
severity = "warning" - VERIFIES [[lint.P-schema]]
Scenario: Malformed spec file is error severity
- GIVEN a spec file contains invalid Markdown that prevents scenario parsing
- WHEN
ah lintruns - THEN the command emits a finding with
severity = "error" - AND exits non-zero
- VERIFIES [[lint.P-schema]]
Requirement: Dual-Format Lint Bridge
The system SHALL relay specodelic lint findings for dual-format spec files into the shared lint finding schema by invoking spk lint, without reimplementing specodelic rules.
Scenario: Relay specodelic findings for dual-format files
- GIVEN a spec file carries
id: specfrontmatter (dual-format) - AND
spk lintreports findings for that file - WHEN
ah lintruns - THEN each specodelic finding appears in the output with
kind = "spk.<rule_id>" - AND every relayed finding has
severity = "warning" - VERIFIES [[lint.P-bridge]]
Scenario: Bridge is inert for plain openspec files
- GIVEN a spec file contains no dual-format frontmatter
- WHEN
ah lintruns - THEN no specodelic invocation occurs and the output is identical to pre-bridge behavior
- VERIFIES [[lint.P-bridge]]
Scenario: Missing spk binary is advisory only
- GIVEN a dual-format spec file
- AND the
spkbinary is not available on PATH - WHEN
ah lintruns - THEN the command emits a single advisory
spk-unavailablefinding - AND the exit code is unchanged relative to a successful warning-only lint run
- VERIFIES [[lint.P-bridge]]
Scenario: Specodelic invocation failure does not hard-fail
- GIVEN a dual-format spec file
- AND
spk lintexits with a non-envelope error or unparseable output - WHEN
ah lintruns - THEN the command emits a single advisory finding describing the failure
- AND continues linting the remaining spec files
- VERIFIES [[lint.P-bridge]]
Requirement: Specodelic Relay Completeness
The linter SHALL emit an spk-frontmatter-mismatch finding for every kind: intent file whose frontmatter id does not match its filename stem instead of skipping it silently, and SHALL relay spk graph typing violations and dangling references as spk.graph.* warning findings.
Scenario: Mismatched id is reported
- GIVEN a
kind: intentfile whose frontmatter id differs from its filename stem - WHEN
ah lintruns over it - THEN an
spk-frontmatter-mismatchfinding names the file and the expected id - AND no specodelic file is skipped without a finding
- VERIFIES [[lint.P-mismatch]]
Scenario: Graph findings relayed
- GIVEN a dual-format corpus containing a typing violation and a dangling reference
- WHEN
ah lintruns over the corpus - THEN both are relayed as
spk.graph.*warnings - VERIFIES [[lint.P-graph]]
Scenario: Clean dual-format file unchanged
- GIVEN a dual-format file that passes
spk lintandspk graph - WHEN
ah lintruns over it - THEN no specodelic findings are emitted for the file
Requirement: Scenario Property Trace
The linter SHALL report advisory trace findings — verifies-dangling, scenario-unlinked, and property-untraced — for dual-format files with Properties rows, without affecting the exit code.
Scenario: Dangling verifies link
- GIVEN a scenario carries
**VERIFIES** [[lint.P-missing]]and no such property exists - WHEN
ah lintruns over the file - THEN a
verifies-danglingadvisory finding is emitted
Scenario: Unlinked scenario and untraced property
- GIVEN a dual-format file with Properties rows where one scenario has no
VERIFIESlink and one property is cited by no scenario - WHEN
ah lintruns over the file - THEN
scenario-unlinkedandproperty-untracedadvisory findings are emitted - AND the exit code is zero
- VERIFIES [[lint.P-trace]]
Scenario: Plain files untouched
- GIVEN a corpus of plain openspec spec files
- WHEN
ah lintruns over the corpus - THEN no specodelic findings are emitted and no spk invocation occurs
- AND no error-severity trace finding is emitted — plain files never fail the trace gate
- VERIFIES [[lint.P-plain]]
id: spec.authoring kind: intent statement: “WHEN any spec file in this repository is added, edited, or deployed by archive THE corpus SHALL stay lint-clean under specodelic dual format, with the mandate enforced in the repo hook chain and CI and the specodelic half re-derived from the archived delta whenever archive strips it.”
spec-authoring corpus mandate
Dogfooding change: every spec file in this repository — deployed capability
specs and change deltas alike — carries the specodelic four-layer grammar in
addition to its openspec grammar. This is repo practice, not product behavior:
espectacular remains opt-in for adopters (C-opt-in, deployed gate spec), and
this mandate never reaches ah check’s product logic.
This delta is itself authored in dual format, extending the pilot from
adopt-dual-format-specs to the whole corpus.
Constraints
| id | kind | expr | traces_to |
|---|---|---|---|
| C-corpus-dual | invariant | every spec.md under openspec/specs/ and under active openspec/changes/*/specs/ carries YAML frontmatter (id: spec, kind: intent, EARS statement) and the Constraints, Model, and Properties tables | [[spec.authoring]] |
| C-lint-clean | invariant | spk lint exits zero over the corpus, and the repo hook chain plus CI run it on every change to a spec file | [[spec.authoring]] |
| C-open-half-stable | invariant | migrating a file leaves its openspec requirement and scenario text textually identical (section-sync mirror and ah check stay green) | [[spec.authoring]] |
| C-adopters-unaffected | advisory | no espectacular product behavior requires dual format; plain openspec repos remain fully valid | [[spec.authoring]] |
| C-archive-rederive | invariant | when openspec archive merges new requirements into a deployed spec and strips its specodelic half, the half is re-derived — frontmatter copied from the archived delta, surviving constraint/property rows carried over from the pre-archive version, rows added for newly merged requirements — and the gates re-run | [[spec.authoring]] |
Model
States
plainduallinted
Transitions
| id | from | to | guard |
|---|---|---|---|
| t-migrate | plain | dual | [[spec.authoring.C-corpus-dual]] |
| t-lint | dual | linted | [[spec.authoring.C-lint-clean]] |
| t-stable | linted | linted | [[spec.authoring.C-open-half-stable]] |
| t-strip | linted | dual | [[spec.authoring.C-archive-rederive]] |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| P-corpus | unit | [[spec.authoring.C-corpus-dual]] | any spec.md under openspec/ | the file parses as dual format: frontmatter plus all three tables present |
| P-lint | unit | [[spec.authoring.C-lint-clean]] | the full corpus after any edit to a spec file | spk lint exits zero |
| P-open-half | unit | [[spec.authoring.C-open-half-stable]] | the openspec half of a migrated file diffed against its pre-migration git blob | the diff is empty outside frontmatter and the three tables |
| P-adopter | unit | [[spec.authoring.C-adopters-unaffected]] | a plain openspec spec file with no frontmatter | ah check discovers scenarios and imposes no dual-format finding |
| P-rederive | unit | [[spec.authoring.C-archive-rederive]] | a deployed spec whose specodelic half was stripped by an archive that also merged new requirements | after re-derivation, constraint and property rows cover both pre-existing and newly merged requirements, and spk lint exits zero |
Purpose
Make the entire spec corpus of this repository dual-format and machine-linted, flipping this repo’s own enforcement from opt-in to mandated while preserving espectacular’s opt-in promise to adopters.
Non-Goals
- Making any espectacular product behavior depend on dual format — adopters’ plain openspec repos remain fully valid (
C-adopters-unaffected) - Automating archive-time re-derivation inside the tool: re-deriving the stripped specodelic half is a contributor step after
openspec archive, not tool behavior - Retroactively migrating archived change deltas — the mandate covers deployed specs and active changes only
- Extending the specodelic grammar itself — upstream concern (specodelic)
Requirements
Requirement: Dual-Format Spec Corpus
The repository SHALL keep every spec file under openspec/ in specodelic dual format, lint-clean under spk lint, with the mandate enforced by the repo hook chain and CI, and SHALL re-derive the specodelic half after openspec archive merges new requirements into a deployed spec and strips it.
Scenario: Plain spec file rejected
- GIVEN a
spec.mdunderopenspec/carries no YAML frontmatter and no specodelic tables - WHEN the repo hook chain or CI lints the corpus
- THEN
spk lintemits a finding for the file - AND the change cannot be committed
- VERIFIES [[spec.authoring.P-adopter]]
Scenario: Migration keeps the openspec half identical
- GIVEN a spec file is migrated to dual format
- WHEN its openspec requirement and scenario text is diffed against the pre-migration version
- THEN the diff shows no change outside the frontmatter and the Constraints, Model, and Properties tables
- AND
ah checkoutput is unchanged - VERIFIES [[spec.authoring.P-open-half]]
Scenario: Archive strip is re-derived
- GIVEN
openspec archivemerged a delta’s requirements into a deployed spec and stripped its specodelic half - WHEN the contributor re-derives the half — frontmatter from the archived delta, surviving constraint and property rows from the pre-archive version, rows added for the newly merged requirements — and re-runs the gates
- THEN
spk lintexits zero on the deployed spec again - AND the constraints and properties cover both pre-existing and newly merged requirements
- AND the corpus-wide lint stays green
- VERIFIES [[spec.authoring.P-rederive]]
Scenario: Corpus parses as dual format
- GIVEN any non-archived
spec.mdunderopenspec/ - WHEN the corpus gate (
just spec-lint) runs - THEN every file parses as dual format: frontmatter plus Constraints, Model, and Properties tables present
- VERIFIES [[spec.authoring.P-corpus]]
Scenario: Corpus is lint clean
- GIVEN the full spec corpus after any edit to a spec file
- WHEN
spk lintruns over the non-archived corpus - THEN it exits zero with no findings
- VERIFIES [[spec.authoring.P-lint]]