Checkwright companion — design record
The companion package puts Checkwright’s gates over a repository whose specs another toolkit writes. It holds, for each supported spec-authoring toolkit, a recipe that fits each install tier (§The tiers) to that toolkit’s tree, and a fixture tree that proves the recipe. The package carries the recipes, and init --recipe <name> applies one (installer/SPEC.md §Payload recipes). For Spec Kit it also holds a catalog extension that installs Checkwright and applies the recipe. companion/README.md is the usage tier in front of this file.
The component
companion/ is repo-root-governed and not a kit. It carries no checks/ and no smoke/, so no kit-root resolver admits it. The package carries its recipes under recipes/, named in this repository’s GATE_SDK_PAYLOAD_RECIPES: openspec, openspec-lifecycle and speckit. It adds no kit mechanism: each recipe is consumer config in knobs the prose profile reads, and the lifecycle layer is config in knobs lifecycle-kit reads. The toolkits’ names live here, on the docs pages and on the front door, and never in a kit SPEC or a kit literal (gate-sdk/SPEC.md §The provenance seam).
toolkits.listpins each toolkit, one<toolkit> <package> <version> <name…>line each. The toolkit name keys the line, the package is what the toolkit leg installs, the version is what it pins and asserts (§The toolkit legs), and the display name heads the toolkit’s column in the support table (§The support table), in the file’s order.native.listrosters the checks each toolkit itself ships, one<toolkit> <arguments…>line each. The toolkit leg runs every line and the support table lists it, so the roster has two readers and no line goes untested.exclusions.listrecords the kits each toolkit’scomplementline leaves out, one<toolkit> <kit> <surface…>line each: the kit, and the toolkit’s own surface that already does its job. The companion arm holds each toolkit’scomplementline to its rows (§The tiers).<toolkit>/recipe/holds a recipe (§Recipes).openspec/lifecycle/holds OpenSpec’s lifecycle layer (§The lifecycle layer).speckit/is the Spec Kit extension around its recipe (§The Spec Kit extension). The directory is not named after the toolkit’s repository, and a page links that repository at its root only, becausecheck-kit-ref-livenessreds a<name>-kitpath segment that names no kit root.fixtures/<toolkit>/holds the fixture tree and its planted defects (§The fixtures).
Recipes
A recipe is a directory in installer/SPEC.md §Payload recipes’ format, and that section owns how init applies one. Each file opens with a # comment citing this file.
Each line answers a red, answers a red of a house rule the adopter registers, or arms a gate that registers disarmed. Install any tier on a fresh tree of the toolkit at its pin. It reds a gate on one of the toolkit’s own idioms until the line is there. For a house rule, it would red once the adopter registers that rule with --with-gate. Or a gate asserts nothing on that tree until the line points it there. The companion arm of the consumer smoke holds that both ways: a line it shows unnecessary is deleted, and one it shows missing is added, each with its idiom recorded below.
Applying a recipe
An adopter applies a recipe with the install itself: init --profile prose --recipe <name>, through the one-line install or any other route to init. The install makes one commit with the recipe applied, and the manifest records the recipe, so an upgrade re-applies it with the release it was tested against. Each toolkit’s documented line sits between <!-- companion-install:begin --> and <!-- companion-install:end --> as one fence holding one line. For OpenSpec it is on the OpenSpec page, docs/openspec.md, a text fence reading checkwright init …, where checkwright stands for the reader’s install line (docs/maintenance.md §Managing’s convention), so the page points to the install page rather than restating it. For Spec Kit every line is in speckit/commands/install.md, each an sh fence holding the one-line install the agent runs, and the Spec Kit page’s complement and full lines carry the same words from init. Each toolkit’s complement and full lines take a marker pair each (§The tiers). The companion arm runs each line’s words from init to the line’s end, so the documented steps are the tested steps.
A flag, not a pasted block. The manifest records the recipe, so a re-run re-applies it rather than rewriting it, and a flag behind the invoke owes no PowerShell twin. So the one-line install carries a recipe on every host, with no sh block to paste and no tag to type.
The tiers
Each toolkit has three install lines, each a profile with the toolkit’s recipe applied, and each contains the one before it, so moving up only adds (installer/SPEC.md §Profiles). An adopter reaches the next tier by running init with its line, or asks the install command for it.
prose, the default, puts canon-kit’s document gates over the toolkit’s tree and owes nobash. Of Checkwright’s workflow surfaces it seeds only a task-queue skeleton, the file canon-kit’s task-liveness gates resolve a marker against (installer/SPEC.md §What init seeds).complementinstalls every kit except those whose job the toolkit already does, so every gate the toolkit does not already do runs over the tree. It isfullwith a--without-kitper left-out kit (installer/SPEC.md §Selecting kits and gates), so a kit the payload gains joins it. A kit is left out when the toolkit, at its pin, ships the surface the kit’s subject governs, andexclusions.listrecords each with that surface; the line’s--without-kitset is exactly the toolkit’s rows. It seeds evidence-kit’s files under.workflow/beside the queue skeleton.fullinstalls every kit, and seeds Checkwright’s own workflow beside the toolkit’s: queue-kit’s task queue, the stage machine’s state file, a doctrine block in the agent file, and evidence files under.workflow/.
A left-out kit is the path to replacing that piece of the toolkit’s workflow. Run the complement line again without that kit’s --without-kit: a run passing any selection flag replaces the recorded selection whole. Each toolkit’s page carries its complement line between <!-- companion-complement:begin --> and <!-- companion-complement:end --> and its full line between <!-- companion-full:begin --> and <!-- companion-full:end -->, in the same text form as the default line. On OpenSpec the full line also applies the lifecycle layer, which no gate init registers reads until a stage session runs (§The lifecycle layer). The Spec Kit extension’s install command installs any of the three, by the user’s input, its complement and full lines each in its own block.
No tier re-checks what a toolkit’s own checks cover, unless an overlap clears the value bar. An overlap re-checks ground a toolkit’s check covers, and is admitted only for a defect that check misses where it matters: a check that runs only after commit, or one a flag skips. The miss is measured at the toolkit’s pin, recorded with that version, and pinned by a fixture that the toolkit leg runs the toolkit’s own check against (§The toolkit legs), so a toolkit release that closes the miss is seen rather than assumed. An overlap is additive and off by default. It ships as a recipe layer, <toolkit>/overlap/, which the adopter applies as --recipe <toolkit>-overlap beside the toolkit’s recipe, on the lifecycle layer’s pattern, and the toolkit’s own check stays enabled. The default install line carries no overlap, and the manifest records the layer as it records any recipe. The gate an overlap arms is a kit’s and stays toolkit-blind; the layer holds its binding.
The Spec Kit recipe
Spec Kit’s own checks: none over the written specs. It ships templates and no validator.
Measured on specify init --here --non-interactive --integration claude --script sh, which writes .specify/ and the agent’s skills, with one feature written under specs/:
GATE_SDK_PRUNE_EXTRA_DIRS = .specify, ingate-sdk-config.knobs. Spec Kit’s own scripts under.specify/scripts/bash/redcheck-comment-tier, since their comments carry no tier tag, andcheck-path-dialect, on their absoluteness tests. They are the toolkit’s files and not the adopter’s.CANON_KIT_PROSE_SURFACE_GLOBS[] = specs/**/*.md, incanon-config.knobs. The feature specs join the governed doc set. Without the line no claimed gate readsspecs/, so every planted defect passes.CANON_KIT_FENCE_PROGRAMS_EXTRA[] = Task:, incanon-config.knobs. The tasks template’s parallel example is abashfence ofTask:lines, whichcheck-fence-command-headreds as naming nothing that runs. The line admitsTask:as a command head. The colon is part of the word. The gate’s other remedy, another info string, would edit Spec Kit’s template text, which no recipe edits.CANON_KIT_TASK_LIST_GLOBS[] = specs/*/tasks.md, incanon-config.knobs, armscheck-task-path-claimandcheck-task-label-resolutionon each feature’s task list.CANON_KIT_TASK_LABEL_CITES[story] = \[US([0-9]+)\]andCANON_KIT_TASK_LABEL_DEFINES[story] = ^### User Story ([0-9]+)[ ], in the same file, bind the user-story family: a task’s[USn]label names the### User Story nheading in thespec.mdbeside it. The define pattern’s trailing space is a bracket, since the knob grammar trims a value’s trailing blank. A converge task’sper FR-003is not bound, since each claimed gate has one planted defect and the family would be untested; an adopter adds that family with two knob lines.unregister.listnames no gate.
The OpenSpec recipe
OpenSpec’s own checks: openspec validate --strict, over the structure of specs and change deltas, and openspec archive, which refuses a MODIFIED, RENAMED or ADDED delta disagreeing with its base spec unless --skip-specs is passed.
Measured on openspec init --tools claude --no-animation --no-copilot-cloud ., with a capability spec and one change:
CANON_KIT_PROSE_SURFACE_GLOBS[] = openspec/**/*.md, incanon-config.knobs, for the reason the Spec Kit line has.CANON_KIT_TEMPORAL_EXEMPT_PATHS[] = openspec/changes/**, in the same file. A MODIFIED requirement in a change delta carries a(Previously: …)line, the form OpenSpec’s own docs show, andcheck-manifest-temporalreds on it. Change deltas and the archive underopenspec/changes/are history by design.CANON_KIT_TASK_LIST_GLOBS[] = openspec/changes/*/tasks.md, in the same file, armscheck-task-path-claimon each in-flight change’s task list. The single*keepsopenspec/changes/archive/out, since an archived change’s paths are history.unregister.listnames no gate.
One convention, left to the adopter. OpenSpec specs often repeat a scenario title under two requirements, and a citation of the second title binds to the first. check-spec-pointer’s one-title-per-file rule reds the repeat when CANON_KIT_SPEC_POINTER_TITLE_ONCE is on, a house rule the recipe does not arm; the OpenSpec page states the convention and the knob line.
The lifecycle layer
OpenSpec’s full line (§The tiers) applies the layer, as --recipe openspec-lifecycle beside the recipe. The complement line leaves lifecycle-kit out, so it carries no layer, and init refuses the layer beside it, since the layer writes a seam no selected kit writes (installer/SPEC.md §Payload recipes). The layer binds the knobs that let check-stage-entry see the toolkit’s in-flight work as amendments, so a cross-component build entry owes an align stamp. The directory is not named recipe/, so the companion arm’s toolkit roster, read from */recipe/, does not take it for a third toolkit.
- OpenSpec.
openspec/lifecycle/binds the amendment glob to in-flight change deltas by path, the roster basename tospec.mdand the contract token tospec.md. A change touching two capabilities fires the audit, and so does a delta citing another capability’s spec. An archived change sits one level deeper, underopenspec/changes/archive/, and is no amendment. A queue entry’s[spec:]ref names the change’sproposal.mdby repo-relative path. - Spec Kit: no layer. A feature’s directory persists after it ships and the current feature is the branch’s name, so nothing on disk marks a spec as in flight. A glob over
specs/*/spec.mdwould demand the audit at every build entry once two features exist.check-stage-entrytherefore sees no amendment in a Spec Kit tree, and the align trigger there is the authoring stage’s own recommendation.
check-spec-pointer’s reach needs no layer. Both toolkits’ specs are markdown, and each recipe already brings them into the governed set.
The tested claim
The recipes are proved against the defect classes below, each caught by a named gate, on prose and complement for both toolkits and on full for Spec Kit, whose complement and full legs plant them again. Both toolkits carry:
- a broken relative or anchored link, by
check-md-refs; - a dangling section citation, by
check-spec-pointer; - an unclosed fence, by
check-spec-fence-balance; - a documented command that invokes a missing script, by
check-docs-cmd; - a ticked task naming a path that does not exist, by
check-task-path-claim.
Spec Kit also carries a task citing a user story its spec does not define, by check-task-label-resolution.
The OpenSpec full line is held green by the lifecycle leg. Each complement leg also asserts the left-out kits absent: the manifest’s kits holds none of the toolkit’s exclusions.list kits, and its recorded selection removes exactly them, after the install and after a bare re-run and a bare update. Delta-to-base agreement is OpenSpec’s, at archive. A gate for it is an overlap, and none ships until one clears the value bar (§The tiers). plan.md’s source tree draws the structure a feature will have, so no gate reads it either.
No recipe drops a gate. A toolkit idiom a gate reds is answered by a knob line fitting the gate to it. Dropping a gate would narrow what the companion ships, so it is a change to this section and not a recipe edit.
One citation form passes unchecked. check-spec-pointer reads a bare <path>.md §<heading> citation’s path as repo-relative, so a file-relative spec.md §… is skipped. A bare §Requirement: <name> must name a requirement whole, since a lead clause shared by a family resolves nothing (canon-kit/SPEC.md §check-spec-pointer). The fixtures cite repo-relatively.
The support table on the toolkit page is derived from the fixture directories and native.list, so it states what is tested (§The support table).
The fixtures
fixtures/<toolkit>/layout/ is a tree in the toolkit’s layout at its pin, authored here. Its section structure follows the toolkit’s own templates, and no template text is copied, so the tree tests the same gates and ships no third-party text. It carries every idiom a recipe line answers, so a dropped line reds the arm’s green leg or lets a planted defect pass. It also carries cross-file links and at least one repo-relative section citation that resolves.
- Spec Kit:
.specify/memory/constitution.md; a.specify/scripts/bash/script with ordinary comments and a/*absoluteness test; and one feature underspecs/001-release-notes/withspec.md,plan.mdandtasks.md. Itstasks.mdcarries thebashfence ofTask:lines,[US1]labels and a tickedT003naming a source file undersrc/. - OpenSpec:
openspec/config.yaml; one capability spec underopenspec/specs/; and one change underopenspec/changes/with a proposal, tasks and a delta whose MODIFIED requirement carries a(Previously: …)line. Its task1.1is ticked and names a source file undersrc/. Afulloverlay underfixtures/openspec/full/check-stage-entry/adds a second capability to that change, at a build cursor with no align stamp.
Each source file is neither markdown nor a shell script, so no other claimed gate reads it.
fixtures/<toolkit>/defects/<gate>/ holds, per claimed gate, the files that replace their layout counterparts to plant that gate’s defect. OpenSpec’s check-spec-pointer defect is a citation of a requirement its spec does not carry, and its check-md-refs defect adds an anchored link to a missing requirement. Each toolkit’s check-task-path-claim defect is its ticked task naming an absent path, and Spec Kit’s check-task-label-resolution defect adds a task citing [US2], which spec.md does not define.
fixtures/<toolkit>/full/<gate>/ overlays the layout with what only the full line’s gate reads, and names that gate.
fixtures/<toolkit>/overlap/<gate>/ plants the defect an overlap exists for, over the layout. The companion arm applies the toolkit’s recipe and overlap layer and asserts the gate reds it. The toolkit leg runs the toolkit’s own check over the same planted tree and asserts the check misses it, so a pin move to a release that closes the miss reds the leg, and the overlap is weighed again (§The tiers).
The OpenSpec layout passes openspec validate --all --strict at the pin, with and without the full overlay, so the toolkit is its own fixture’s oracle. This repository prunes every directory named fixtures from its own walks (GATE_SDK_PRUNE_EXTRA_DIRS in scripts/gate-sdk-config.knobs), since the trees carry each red on purpose. The companion arm governs them instead, inside a scratch consumer (installer/SPEC.md §The consumer smoke).
The Spec Kit extension
speckit/ is a Spec Kit extension: extension.yml, two command files and a README.md.
extension.ymldeclaresschema_version: "1.0", the extension’s identity (id: checkwright),requires.speckit_versionat>=thespeckitpin intoolkits.listand git as a required tool. It provides two commands and one hook, and carries two to four lowercase tags.speckit.checkwright.install(commands/install.md) installs the release this extension version was tested with, with the recipe applied. It runs the one-line install withCHECKWRIGHT_VERSIONset toextension.versionand the argumentsinit --profile prose --recipe speckit, or itscomplementorfullline’s when the user’s input asks for that tier, naming the tier’s costs before running it, then runs the commandsinitprinted. The user’s input reaches the body as$ARGUMENTSand the command’sargument-hintreaches the rendered skill, so one command offers every tier.speckit.checkwright.check(commands/check.md) runs the battery and reports its verdict, each red with its finding and itshelp:line. It fixes nothing unasked.- The
after_implementhook is optional and offers the check. README.mdsays what the extension adds, how to install it and where the landing page is, which the catalog requires of a listed extension. Its description line inextension.ymlstays under the catalog’s 200 characters.
Each command is an agent instruction. Spec Kit hands the command bodies and the hook to the agent and executes neither, and its claude integration renders the commands as skills.
The version is stamped at pack time. The tracked extension.yml carries version: "0.0.0", as installer/package.json does, and the pack step writes the tag’s version into the packed copy (§Packing the extension). A tracked version would be a second version line to bump by hand. The install command reads the stamped value, so a listing installs the kits it was tested with.
Packing the extension
scripts/ci-pack-extension.sh <version> <out-dir> builds checkwright-companion-<version>.zip and its .sha256 sidecar:
- it extracts
companion/speckit/’s tracked files atHEADwithgit archiveinto a scratchcheckwright-companion-<version>/, lessrecipe/, which the package carries instead, adding the repository’sLICENSE; - it rewrites the one
version: "0.0.0"line to<version>, and refuses when that line is absent or occurs twice; - it zips the directory with
python3 -m zipfile -c, so one top-level directory holdsextension.yml, and writes the sidecar in the<hex> <name>form the other assets use.
It probes git, python3, tar and a hasher before any step, refusing by name, and its scratch lives outside the worktree. publish.yml’s pack job runs it with the tag’s version into the directory the release job attaches, and the asset name is declared in gate-sdk/SPEC.md §Consumer payload, which check-release-assets holds at the tag.
A Spec Kit archive install looks for extension.yml at the archive’s root or inside exactly one top-level directory. The asset takes the second shape. A GitHub tag archive would wrap the whole repository in its top-level directory, where no extension.yml sits.
The toolkit legs
The toolkits’ own tools are oracles, and nothing here re-implements their schemas. The companion-toolkits job in .github/workflows/gates.yml reads toolkits.list and:
- installs the pinned
specify-cli, runsspecify init --here --non-interactive --integration claude --script sh --ignore-agent-toolsin an empty scratch directory, since the non-interactive init refuses a non-empty one and a runner carrying noclaudeCLI, and packs the extension at version0.0.0. It serves the zip from a local web server and installs it withspecify extension add checkwright --from <url>, answering the trust prompt. It asserts exit 0,.specify/extensions/checkwright/extension.ymlpresent, and the hook registered in.specify/extensions.yml; - asserts that
extension.yml’srequires.speckit_versionis>=the pinned version; - runs each
native.listline of a toolkit with a layout, with the toolkit’s pinned package, over a copy of the layout and again with eachfulloverlay copied over it; - for each
fixtures/<toolkit>/overlap/<gate>/, runs the toolkit’s own check over the layout with the defect planted, and asserts that it passes.
A pin moves by editing its toolkits.list line, and the job then proves the new version on the next push.
The support table
docs/spec-toolkits.md carries a table with one row per check and one column per toolkit, between <!-- support-table:begin --> and <!-- support-table:end --> with a blank line inside each marker. bash gate-sdk/bin/run-gates.sh --emit support-table prints the block, and --write rewrites it and touches nothing else. A row is a tested fact, so it derives from the fixture tree and native.list, never from the recipe files: one recipe line arms many gates, while a defect directory proves one.
- The header is
| Check |, then eachtoolkits.listdisplay name in file order. - A gate row is one per gate name under any toolkit’s
fixtures/<toolkit>/{defects,full,overlap}/, sorted by name, the name in a code span. The directory is the tier. A toolkit’s cell readsprosewhere itsdefects/<gate>/exists, since the default line arms and tests it and each tier above it only adds. Otherwise it readsfullwhere itsfull/<gate>/exists,opt-inwhere itsoverlap/<gate>/exists (§The tiers), and—elsewhere. - A toolkit row follows the gate rows, one per
native.listline in file order: the line’s<toolkit> <arguments…>in a code span,toolkitin that toolkit’s cell and—in every other. - Exit 2, from the arm and from its gate, on a roster it cannot read: an unreadable
toolkits.listornative.list, atoolkits.listline with no name, or anative.listline naming a toolkittoolkits.listlacks or carrying no arguments. It also exits 2 on a fixture toolkit directorytoolkits.listlacks, and on a block that is absent or occurs twice.
check-support-table-fresh compares the page’s block with the arm’s rendering in process, one finding for a stale block, printing the regen command. Its positional form is check-support-table-fresh [companion-dir page], so a fixture tree stands in.
Honest limits
- The Spec Kit fixture’s fidelity rests on the pinned templates’ structure, since Spec Kit ships no validator.
- The agent-run parts of the extension, its command bodies and its hook, are exercised by no harness. The toolkit leg proves the archive installs, and the companion arm runs each of the install command’s
shlines, but no run follows an agent through either command. - The Windows route is documented and not run. A recipe adds no step of its own to it, and a leg piping the PowerShell line would retire this limit.
- A pinned toolkit version says nothing about the next one.
- The exclusion record is measured at each toolkit’s pin, from what its init writes and the commands it ships. A toolkit release that adds or drops a workflow surface is seen only when a pin move measures it again.
- No overlap has shipped, so neither the companion arm nor the toolkit leg yet loops over
fixtures/<toolkit>/overlap/; the first overlap adds both loops. - A change whose two in-flight deltas sit under one capability in two changes fires the OpenSpec layer’s audit, since each delta’s directory is a component. So does a delta citing its own capability’s spec, which sits outside the delta’s directory. The align waiver is the valve. On Spec Kit the audit trigger is not machine-held.