Semantic governance gate for spec files: classifies PR changes against a locked project goal and scope.
Project description
SpecGuard is a semantic governance layer for spec files. It classifies every PR change against your locked project goal and scope — passing additive changes silently, warning on low-confidence shifts, and blocking unapproved direction changes at merge time.
The Problem
In repos where AI agents and humans both contribute, a PR can look perfectly fine on the surface while silently shifting the project's direction. SpecGuard catches that — not by checking who made the change, but by understanding what the change means against your locked goal and scope.
PR: "refactored README for clarity"
Change: Added a full SaaS pricing section
to a project scoped as a local CLI tool.
SpecGuard: ❌ SCOPE CHANGE — 94% confidence
"SaaS pricing" is out of scope
requires approval from @architect
How It Works
Lock your goal and scope in .specguard/lock.json — or, if you use Spec Kit or OpenSpec,
let SpecGuard derive it from the spec files you already maintain.
Then it does the rest on every PR.
PR opened
├─ Not a watched file ───────────────────── ✅ Pass
├─ Protected path, wrong author ──────────── ❌ Block (no AI involved)
└─ Watched spec file changed
└─ Claude classifies the diff
├─ ADDITIVE ───────────────────── ✅ Pass (silent)
├─ SCOPE CHANGE, low confidence ── ⚠️ Warn
└─ SCOPE CHANGE, high confidence ── ❌ Block (until authorized approval)
Approving via GitHub's normal review flow re-evaluates the check automatically — no new commits needed.
Quickstart
1. Create .specguard/lock.json
{
"goal": "A CLI tool that converts Markdown to PDF",
"scope_in": ["Markdown parsing", "PDF rendering", "CLI flags"],
"scope_out": ["GUI", "cloud sync", "collaboration features"]
}
2. Add the workflow
# .github/workflows/specguard.yml
name: specguard
on:
pull_request:
pull_request_review:
types: [submitted]
issue_comment: # `/specguard approve` comment command
types: [created]
permissions:
contents: read
pull-requests: read
jobs:
specguard: # the required branch-protection check
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0} # required: base...head history
- uses: Sawaiz-zip/spec-guard@v0
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
reevaluate: # an approval re-runs the check in place
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions: {actions: write}
steps:
- env:
GH_TOKEN: ${{ github.token }}
run: |
run_id=$(gh api "repos/${{ github.repository }}/actions/workflows/specguard.yml/runs?event=pull_request&head_sha=${{ github.event.pull_request.head.sha }}" --jq '.workflow_runs[0].id // empty')
[ -n "$run_id" ] && gh api -X POST "repos/${{ github.repository }}/actions/runs/$run_id/rerun"
comment-approve: # `/specguard approve` re-runs the check
# Grants no authority — it only retriggers the gate, which recomputes authorization
# from roles.yml at the trusted base. Anyone may comment; only a real role member clears a block.
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/specguard approve')
runs-on: ubuntu-latest
permissions: {actions: write, pull-requests: read}
steps:
- env:
GH_TOKEN: ${{ github.token }}
run: |
head_sha=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}" --jq '.head.sha')
run_id=$(gh api "repos/${{ github.repository }}/actions/workflows/specguard.yml/runs?event=pull_request&head_sha=$head_sha" --jq '.workflow_runs[0].id // empty')
[ -n "$run_id" ] && gh api -X POST "repos/${{ github.repository }}/actions/runs/$run_id/rerun"
specguard initwrites this file for you (including the/specguard approvetrigger) — the snippet above is what you get.
3. Set ANTHROPIC_API_KEY as a repo secret, then require the specguard check under branch protection.
That's it for solo use — scope changes now warn on every PR. To make them block until an authorized teammate approves, add roles:
# .specguard/roles.yml (optional — presence switches warn mode to enforce mode)
roles:
architect: [your-github-username]
rules:
".specguard/**": # nobody outside the role may touch the lock itself
edit: architect
"README.md": # who can approve scope changes per file
scope_changes: {approve: architect}
# .specguard/config.yml (optional — these are the defaults)
watch: ["README.md", "CLAUDE.md", "AGENTS.md", "ARCHITECTURE.md", "*.kilo", ".specguard/**"]
block_threshold: 0.75
on_error: warn # vendor outage: pass with a loud warning ("fail" to block)
provider: anthropic # anthropic | openai | gemini | openrouter
model: claude-sonnet-4-6
max_diff_chars: 30000
You bring your own API key and choose the model — SpecGuard never bills you directly. With the default
claude-sonnet-4-6expect roughly $0.01–0.02 per watched file per push (~3–5K input + ~500 output tokens); it scored a perfect confusion matrix on the calibration corpus.claude-opus-4-8is hard-blocked by a project guardrail (no quality gain on this task at ~6× the cost).
Choose your LLM provider
SpecGuard classifies through one shared engine behind a provider seam — pick the backend you already pay for. Anthropic ships in the base install; the rest are one extra away:
provider: |
install | API key env var | example model: |
|---|---|---|---|
anthropic (default) |
pip install specguard-ci |
ANTHROPIC_API_KEY |
claude-sonnet-4-6 |
openai |
pip install "specguard-ci[openai]" |
OPENAI_API_KEY |
gpt-4o-2024-11-20 |
gemini |
pip install "specguard-ci[gemini]" |
GEMINI_API_KEY |
gemini-2.0-flash |
openrouter |
pip install "specguard-ci[openai]" |
OPENROUTER_API_KEY |
anthropic/claude-3.5-sonnet |
Non-Anthropic providers require an explicit model:. Only Anthropic + Sonnet 4.6 is
calibration-verified against the golden corpus; other backends work but are unvalidated until
you run them through tests/eval/run_eval.py. claude-haiku-4-5 is selectable and ~3× cheaper
but missed the 90% recall gate (83%), so it stays opt-in, not the default.
Python: the package installs on Python 3.10+. The CI Action provisions its own Python on the runner, so the gate works for repos in any language; only the local tools need a 3.10+ interpreter on your machine.
Govern the specs you already have (Spec Kit / OpenSpec)
If your repo uses Spec Kit or
OpenSpec, you don't have to hand-author
.specguard/lock.json — SpecGuard reads the goal and scope from the files those
frameworks already maintain (it parses their public markdown; it never imports their code):
| Source | Where the goal/scope comes from |
|---|---|
| explicit lock (always wins) | .specguard/lock.json |
| Spec Kit | .specify/memory/constitution.md + the touched specs/<feature>/spec.md |
| OpenSpec | openspec/project.md + the touched openspec/changes/<id>/proposal.md scope sections |
| plain | no framework detected — behaves exactly as before |
Selection is automatic from your repo layout, in that precedence order. Every run reports
which source it used (Governance source: … in the CI summary and specguard check output),
and an explicit lock.json always overrides framework derivation when you need to pin scope by hand.
The Spec Kit adapter is dogfooded on this repository. The OpenSpec adapter is built against OpenSpec's documented file format but has not yet been validated against a live OpenSpec project — if your layout differs, drop in an explicit
.specguard/lock.jsonto pin the scope.
Local Tools
Everything the merge gate decides, you can preview on your machine — same engine, same verdicts, advisory only.
pip install specguard-ci
specguard init # guided setup: goal, scope, optional roles/workflow/hook
specguard check # what would the gate say about my working tree?
specguard check --staged # ...about what I'm committing?
specguard check --base origin/main # ...about this branch as a PR?
Pre-commit warnings (never blocks a commit — enforcement stays at merge time):
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Sawaiz-zip/spec-guard
rev: v0.3.0
hooks: [{id: specguard-check}]
Warn coding agents at write time — the MCP server lets agents like Claude Code check a drafted spec change before writing it:
pip install "specguard-ci[mcp]"
// e.g. .mcp.json for Claude Code
{ "mcpServers": { "specguard": { "command": "specguard", "args": ["mcp"] } } }
Agents get four tools: check_proposed_change (full verdict for proposed content — and, when a
change would block, a redirect naming the approver role and suggesting a separate proposal),
get_scope_lock, list_watched_paths, and check_permission (may this identity make this class of
change to this file?). Drift prevention moves from "blocked PR" to "agent self-corrects mid-draft."
Local results always carry an advisory notice: nothing local enforces. Governance config is read from your committed baseline — editing your own lock locally doesn't change the verdict your PR will actually get.
Approving a scope change
When the gate blocks a scope change, any one authorized approval clears it — three equivalent paths, all evaluated by the same rule and recorded identically:
| Path | How |
|---|---|
| Native PR review | An authorized role member clicks Approve in the GitHub review UI. |
| Comment command | An authorized member comments /specguard approve on the PR (mobile-friendly); the gate re-runs in place. |
| CLI | specguard approve <pr-number> from the terminal (needs GH_TOKEN/GITHUB_TOKEN). |
Authorization always uses the server-side GitHub login against roles.yml — a comment or CLI call
from someone outside the authorizing role does not clear the block (anyone can trigger a re-run;
only a real role member can approve). Merge-time stays the only enforcement layer.
Advanced Governance
Lock a section, let the rest float
Govern just a heading region of a file — a goal paragraph or an out-of-scope list — while the FAQ or examples around it stay free to edit:
# .specguard/regions.yml (optional)
files:
"ARCHITECTURE.md": ["Goal", "Out of Scope"]
Edits outside every declared region pass quietly without ever reaching the classifier — strictly
less friction, never more. If a declared heading is renamed or removed, the check fails loudly
(never silently un-governed) — rename it back, or update regions.yml deliberately.
Monorepo: one scope per package
Drop a .specguard/ into any subdirectory and it governs that subtree independently — its own
goal, scope, roles, and regions:
packages/api/.specguard/lock.json # "API service", scope_out: [billing]
packages/web/.specguard/lock.json # "Web app", scope_out: [payments]
A PR touching both packages gets two independent verdicts in one run. Each package's
roles.yml/config.yml is written as if its .specguard/ were the repo root — copy the whole
directory between packages and it just works. Files outside any package scope fall back to the
repo-root lock (or Spec Kit/OpenSpec derivation), unchanged from single-scope repos.
Audit export
SPECGUARD_AUDIT_PATH=audit.json python -m specguard.ci
Writes one JSON record per verdict — file, scope, classification, confidence, required approver roles, and every approval seen on the PR — for upload as a workflow artifact. No secrets, no new datastore; it's a formatting pass over data the gate already computed.
Roadmap
| Phase | Status | What ships |
|---|---|---|
| 0 — CI Gate | 🟢 Shipped | GitHub Action · scope classification · role-based approval · branch protection |
| 1 — Local Tools | 🟢 Shipped | CLI (specguard init, specguard check) · pre-commit hook · MCP server |
| 1.5 — Provider-Agnostic | 🟢 Shipped | Anthropic · OpenAI · Gemini · OpenRouter behind one engine · Python 3.10+ |
| 2 — Framework Adapters | 🟢 Shipped | Spec Kit + OpenSpec governance overlay — auto-derive the lock from existing specs · explicit-lock override · source reporting |
| 2 — Approval Commands | 🟢 Shipped | /specguard approve PR comment · specguard approve CLI · MCP check_permission + write-time redirect |
| 2 — GitHub App | 🟡 Core built | Native Checks API · fork PR support · bot vs human identity (self-hostable; deploy + GitLab pending) |
| 3 — Advanced | 🟢 Shipped | Section-level locking · monorepo multi-scope · audit export |
Principles
No false blocks. No new UI. No dashboards.
The only enforceable boundary is merge time — everything else is advisory. A wrong Friday block means uninstall by Monday, so additive changes always pass silently. Hard blocks are deterministic (no AI). Probabilistic verdicts always show their confidence and never block without explanation.
Full constitution: .specify/memory/constitution.md
Built with Spec Kit · Powered by Claude · MIT License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file specguard_ci-0.4.0.tar.gz.
File metadata
- Download URL: specguard_ci-0.4.0.tar.gz
- Upload date:
- Size: 673.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d8273713c4650bde1ea544cdbae8ae25d47b20c50338c1345361532a16ae2e9
|
|
| MD5 |
b6a4e3f95308b4edf7ad40e89efaeae1
|
|
| BLAKE2b-256 |
11b2dd1522a9d641c10e3d7b437003e8f7532fe7a7de7a902fa824565f7c767c
|
File details
Details for the file specguard_ci-0.4.0-py3-none-any.whl.
File metadata
- Download URL: specguard_ci-0.4.0-py3-none-any.whl
- Upload date:
- Size: 63.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5410d99488dcfeb681d7a99fc7341eb6986d40afaa19d4f73aac744e08dbfcd1
|
|
| MD5 |
44201783cfa411eee1de31f017dff3d8
|
|
| BLAKE2b-256 |
3d9493d478e416d27cab631b94b192ef81a96e5e44a9afb83225aae61f2e170c
|