specsy
v0.3.0
Published
A linter for specifications. Catches vague, untestable, and untraceable requirements before an AI agent turns them into code.
Downloads
76
Maintainers
Readme
A linter for specifications. It catches vague, untestable and untraceable requirements before an AI agent turns them into code.
openspec/changes/add-auth/specs/auth/spec.md
16:24 warn "reasonable" is not measurable. no-weasel-words
→ Replace with the observable outcome. What would you check
to know "reasonable" was achieved?
16:67 error Performance claim "fast" has no target. quantify-performance
→ State a budget and a percentile, e.g. "p95 under 200ms".
openspec/changes/add-auth/tasks.md
6:1 error Task references "AUTH-9", which is not defined in this change.
→ Either the id is a typo or the requirement was never written down.
✖ 10 problems (4 errors, 6 warnings) in 3 documents.Why this exists
Spec-driven development tools — OpenSpec, GitHub Spec Kit, Kiro, BMAD — all give you a template. None of them check whether what you wrote into it is any good.
So teams fill a beautifully structured spec.md with "should be fast", "handle errors gracefully", "user-friendly" — and the agent, which cannot ask a follow-up question at 2am, invents an answer. The spec was never the bottleneck. The quality of the spec was.
npx specsy is the compiler pass that was missing. It reads the specs you already have, in the format you already use, and refuses to let an unanswerable requirement reach an agent.
It is not another spec format. It sits on top of the one you have.
How it works
npx specsy sits between the spec and the code. Findings come back with a file:line and a concrete fix, and the command exits 1 — so a spec that cannot be built correctly never reaches the implementation step.
With the MCP server the agent operates that gate itself: it writes a spec, lints it, reads the fix, rewrites, and only implements once the spec is clean. Nobody copies output between windows.
Walkthrough
You have an idea and nothing else
mkdir tally && cd tally && git init
npx @fission-ai/openspec@latest init # the spec format — not specsy's job
claude mcp add specsy -- npx -y specsy mcpBefore writing anything, learn the bar. Ask your agent to call list_rules, or run:
npx specsy rules
npx specsy explain quantify-performanceThen propose a change (/opsx:propose add receipt upload) and have the agent lint its own work. Left alone it writes what everyone writes — "upload must be fast and handle errors gracefully" — and specsy sends it back:
proposal.md:1:1 warn [require-non-goals] Proposal has no non-goals section.
specs/…/spec.md:12 warn [quantify-performance] Performance claim "fast" has no target.
specs/…/spec.md:19 error [require-acceptance-criteria] Requirement "Amount extraction" has no acceptance criteria.The agent can fix most of that alone. What it can't fix — "what accuracy target, and what happens on a blurry photo?" — it now has to ask you. That question is the point. Without it the agent picks silently and you find out in review.
Once clean, check what the change will cost to keep re-reading, then build:
npx specsy footprint # 9.4k tokens; ~75k over 8 agent turns, ~$0.38Finally, make it permanent — a pre-commit hook and a CI job:
npx specsy || exit 1- run: npx specsy --reporter github --max-warnings 0You already have specs
Look before adopting. Nothing is installed, nothing is written:
cd my-project
npx specsyIf it is noisy, walk away — that is the honest way to judge a linter. If it is useful, adopt it as a ratchet rather than a rewrite. Fix the errors, defer the warnings:
npx specsy --quiet # errors only; warnings still counted for the exit codeThen hold new specs to the full bar while the backlog drains, by putting only errors in CI:
- run: npx specsy --quietTurn rules back on in .specsyrc.json as you go. Finally, commit a .mcp.json so every agent on the team self-checks before implementing, and stop being the person pasting terminal output into chat.
Install
npx specsy lints the directory you run it from, so run it inside a project:
cd my-project
npx specsyOr add it as a dev dependency:
npm install -D specsy # or: pnpm add -D specsy
npx specsyA local install is not on your
PATH— typing a barespecsywill fail with "not recognized as an internal or external command". Usenpx specsy, or add"lint:spec": "specsy"to yourpackage.jsonscripts, wherenode_modules/.binis already on the path. Install globally (npm i -g specsy) only if you want the bare command everywhere.
Usage
npx specsy # auto-detect the format, lint everything
npx specsy openspec/ # point at a directory
npx specsy --format openspec # force an adapter
npx specsy --reporter json # machine-readable output
npx specsy --reporter github # inline annotations on a PR diff
npx specsy --quiet # errors only
npx specsy --max-warnings 0 # treat warnings as failures too
npx specsy rules # list every rule
npx specsy explain <rule> # one rule, with a failing and a passing example
npx specsy footprint # token cost of each change
npx specsy mcp # run as an MCP server for agentsCheck the specs you are working on
By default, Specsy checks all in-flight changes and the living spec. Narrow the run explicitly when you only want findings for current work:
npx specsy --scope changes # every in-flight change
npx specsy --scope living # the living spec only
npx specsy --change add-auth # one in-flight change by directory name
npx specsy --scope all # everything (the default)--scope and --change cannot be combined. Either flag overrides the configured
scope. Unknown changes, empty selections, and non-default scopes on the generic
adapter are refused with exit code 2. Archives are excluded at every scope.
Narrowed reports name the scope and the number of documents examined.
For a project that normally checks in-flight work, add this to .specsyrc.json:
{
"format": "auto",
"scope": "changes",
"rules": {}
}CLI and MCP read and validate the configuration nearest to the target directory.
MCP lint_specs accepts the same scope values and its existing change argument.
Explicit arguments override the configuration. The JSON reporter includes a
scope object, such as {"kind":"changes"} or {"kind":"change","id":"add-auth"}.
--max-warnings accepts non-negative safe integers, including 0. footprint
--turns accepts positive safe integers. Invalid values, including decimals and
trailing text, are refused rather than partially parsed.
npx specsy --help lists every command with when to reach for it. A mistyped command is caught rather than read as a directory name:
$ npx specsy footprnt
Unknown command "footprnt".
Did you mean: npx specsy footprint
Commands: rules explain footprint mcp
Run 'npx specsy --help' to see what each one is for.Exit codes:
| Code | Meaning |
|---|---|
| 0 | No errors. Warnings alone do not fail — add --max-warnings 0 if they should |
| 1 | One or more errors, or warnings above --max-warnings |
| 2 | specsy could not run: bad config, unknown format or reporter, or nothing was examined |
--quiet prints errors only. It is a display filter — warnings are still found, still counted, and still fail the run when they exceed --max-warnings.
A run that examines zero documents exits 2, never 0. A config whose ignore matches every file is a CI gate that passes because it checked nothing, which is worse than one that fails.
Supported formats
| Adapter | Reads |
|---|---|
| openspec | openspec/changes/<id>/{proposal,design,tasks}.md plus delta specs and the living specs/ tree |
| generic | any directory of markdown — opt-in, via --format generic. Never auto-detected: "a folder containing markdown" describes a home directory as readily as a spec folder |
Spec Kit and Kiro adapters are the next ones planned. An adapter only has to describe a directory layout; all parsing and every rule is shared. See src/adapters/.
Rules
16 rules, all deterministic — no model calls, no API key, no network. Run them on every commit for free.
| Rule | Default | Applies to | What it catches |
|---|---|---|---|
| no-weasel-words | warn | spec, proposal | Subjective words that cannot be turned into a test. |
| quantify-performance | warn | spec, proposal | Performance claims with no number and unit. |
| use-normative-keywords | warn | spec | Soft modals ("will", "needs to") instead of MUST / SHOULD / MAY. |
| one-requirement-per-statement | warn | spec | Sentences bundling several obligations into one. Line wrapping does not affect it. |
| no-ambiguous-pronoun | warn | spec | Requirements opening with a pronoun that has no antecedent. |
| require-acceptance-criteria | error | spec | Requirements with no scenario to verify them. |
| no-placeholders | error | all | TODO/TBD markers and unfilled template slots. |
| no-empty-sections | warn | all | Headings with nothing beneath them. |
| require-sections | warn | proposal, design | Documents missing the sections their kind expects. |
| no-implementation-in-requirements | warn | spec | Requirements naming a library instead of a behaviour. |
| unique-requirement-ids | error | all | Two requirements sharing an id, anywhere in the change. |
| tasks-reference-requirements | warn | tasks | Tasks not linked to any requirement. †|
| no-dangling-references | error | tasks | Tasks citing a requirement id that does not exist. |
| requirements-have-tasks | warn | spec | Requirements no task implements. †|
| require-non-goals | warn | proposal | Proposals that never say what they are not doing. |
| require-requirement-ids | warn | spec | Requirements with no stable id to cite. †|
† These three rules switch themselves on. Requirement ids and task citations are disciplines a team opts into — no spec format mandates them, and OpenSpec's tasks.md never cites requirements. Demanding them unconditionally would flag every line of a perfectly good spec, so each rule stays silent until the change already uses the convention somewhere. Adopt ids on one requirement and the rest of the spec is held to it.
Code fences are never linted, so example snippets inside a spec stay untouched.
Requirement id format. An id is PREFIX-N — two to five uppercase alphanumerics, a hyphen, then digits: REQ-001, ACCT-14, TXN-7. It must open the requirement's heading (### Requirement: ACCT-001 Account creation), and ids must be unique across the whole change, not merely within one file — a task citing ACCT-001 cannot tell two spec.md files apart. Ids are optional; see the † note above.
Standards references are not requirement ids. ISO-4217, ISO-8601, RFC-3339 and SHA-256 match the shape of an id but are excluded, and a requirement takes its id only from the start of its heading (### Requirement: REQ-014 Account creation) — a mention in prose never christens one.
Proposals are linted as prose. A proposal rarely contains formal MUST requirements, so the clarity rules read every line of it — except the motivation sections (Why, Background, Context, Problem, Rationale). "Search feels slow" there is a problem statement, not an unmeasurable requirement, and is left alone.
Use it from an agent (MCP)
Without this, the loop has a human in it: the agent writes a spec, you run specsy, you paste the output back. With it, the agent lints its own spec and fixes it before writing any code.
claude mcp add specsy -- npx -y specsy mcpOr commit a .mcp.json at your repo root so the whole team gets it:
{
"mcpServers": {
"specsy": { "command": "npx", "args": ["-y", "specsy", "mcp"] }
}
}Cursor, Windsurf and Zed take the same command/args pair in their own MCP config. The server speaks stdio and needs no API key.
| Tool | What the agent uses it for |
|---|---|
| lint_specs | Check specs before implementing. Returns each finding with file:line and a concrete fix. |
| explain_rule | Understand a finding, with a failing and a passing example. |
| list_rules | Learn the bar before writing a spec. |
| spec_footprint | See what a change costs in context tokens. |
| specsy_usage | See what specsy itself has added to the conversation. |
Context cost
A spec is not read once — an agent re-reads it on every turn, so its size is a recurring cost:
npx specsy footprintadd-core-ledger 11k tokens
2.8k design openspec/changes/add-core-ledger/design.md
2.1k spec openspec/changes/add-core-ledger/specs/ledger/transactions/spec.md
...
re-read over 8 agent turns: ~86k tokens, ~$0.43Counts are estimated locally (±15%), with no network call and no API key — the same promise as the rules. --exact uses Anthropic's count_tokens endpoint for real, model-specific numbers when a credential is available.
specsy cannot see your agent's total token usage — that belongs to Claude Code or Cursor and its API account. What it measures is the specs themselves, and its own contribution to the conversation. Those are the parts of the bill it is honestly able to see.
Configuration
Drop a .specsyrc.json anywhere at or above the directory you lint:
{
"format": "auto",
"rules": {
"require-requirement-ids": "error",
"no-implementation-in-requirements": "off"
},
"weaselWords": ["performant-ish", "TBC"],
"ignore": ["**/vendor/**"]
}Every rule takes "error", "warn" or "off". weaselWords is merged with the built-in list, not replaced. ignore takes globs and drops matching files from the run.
no-weasel-words allows "most recent" and numeric bounds such as "at most 10".
It still flags unspecified quantities such as "most users" and "at most several".
The config is validated before anything acts on it — a misspelled rule id or an unknown setting exits 2 rather than being ignored:
Invalid config in .specsyrc.json:
Unknown rule "no-weasle-words". Did you mean "no-weasel-words"?In CI
- run: npx specsy --reporter github --max-warnings 0The github reporter emits workflow annotations, so findings land inline on the pull request diff.
As a pre-commit hook:
npx specsy --quiet || exit 1Programmatic use
import { detectAdapter, lint, formatJson } from "specsy";
const adapter = await detectAdapter("./openspec");
const project = await adapter.load("./openspec");
const result = lint(project, { rules: { "no-weasel-words": "off" } });
console.log(result.errorCount, result.diagnostics);The CLI is a thin wrapper over exactly these exports.
Roadmap
v0.1 is the lint layer. The two things that follow are where this gets genuinely hard, and genuinely valuable:
spec trace— build the requirement → task → file → test graph and report what is uncovered on either side.spec drift— diff the code against the last archived spec and flag requirements whose implementation changed without a spec delta. Specs rot the moment a hotfix lands, and nothing today detects it.
Adapters for Spec Kit and Kiro, and a rule-authoring guide, are also planned.
Contributing
A new rule is one file in src/rules/ and one entry in the registry. Rules see a neutral model (src/model.ts), never raw markdown, so a rule written once works across every format.
pnpm install
pnpm build
pnpm testTo run this source checkout with npm (on Windows PowerShell, use npm.cmd):
npm run build
npm run lint:spec
node dist/cli.js --help
npm testTests build the CLI first. Publishing runs the build and full test suite through
prepublishOnly; no global pnpm installation is required for that step.
Please add a fixture case in tests/fixtures/ for anything a new rule should — and should not — flag. False positives are worse than missed findings here: a linter people mute is a linter that does nothing.
License
MIT
