Spec-driven development on OpenSpec, with mechanical spec-as-source enforcement: a custom 'spec-as-source' OpenSpec schema adds file-ownership (targets) and test-verification ([@test]) metadata to every capability spec, three scripts (link check, ownership check, manifest build) keep code and specs from drifting apart, plus requirement-gathering, spec-writer, work-review, and a session-handoff skill with a proactive context-warning hook.
68
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
A persistent, human-approved plan that sequences OpenSpec changes toward a stated
goal. It is the tier above tasks.md: tasks.md plans the inside of one change
and is archived with it, while the plan answers which changes are still needed,
in what order, to reach the goal.
The relationship to the rest of the method is deliberate: spec-as-source
enforces no code outside the spec; this rule enforces no change outside the
plan, one level up, by the same means — a mechanical check that fails loudly
rather than an agent's promise.
openspec/PLAN.md. One per project, at that exact path.
Not under .handoff/. That directory is optional: rules/history-log.md
states that a project without it is left untouched and that nothing may bootstrap
it as a side effect. A hard gate cannot depend on a file whose directory is
allowed not to exist — it would be silently unenforceable in exactly the projects
that skipped handoff init. openspec/ is present in every project that ran
spec-as-source-setup, which is the same population that has changes to gate.
openspec/PLAN.md is deliberately not listed in any spec's targets:. Its
content changes on every state transition, so owning it would make
scripts/check-target-ownership.sh fail every time an entry advances — and the
natural response to that is to stop updating states, which kills the gate by
friction instead of by decision. The spec governs the plan's format and this
rule; it does not own the instance. Same reasoning as .handoff/HISTORY.md,
which history-log specifies without owning.
YAML frontmatter, then entries:
---
goal: >
What this project is trying to reach.
done-when: >
How anyone can tell it has been reached.
plan-version: 1
---
# PLAN — <project>
## Entries
### E01 — <Title>
- **Change**: <change-directory-name> | none
- **Depends on**: <entry ids> | —
- **Done when**: <observable, checkable condition>
- **State**: draft | approved | in-progress | done | dropped
- **Approved by**: <human name> | —
- **Approved at**: <YYYY-MM-DD HH:MM from a real clock read> | —
- **Approval hash**: sha256:<16 hex> | —Change names a directory under openspec/changes/, or the literal none when
the entry is work that produces no OpenSpec change. The binding is declared by the
entry only — a change never has to name its plan entry, so changes stay readable
and archivable on their own.
Entry ids are append-only. Never reuse an id for different work, and never delete
an entry: work that is abandoned becomes dropped with a stated reason, so the
plan keeps the record of what was decided against.
draft → approved → in-progress → done
└──────────┴──────────┴─────────→ dropped (from any state, with a reason)An entry MUST NOT move to in-progress before it is approved. When an entry's
bound change is archived under openspec/changes/archive/, the entry must be
done — the gate reports STALE for as long as it is not, so an archived change
cannot leave a stale plan behind.
An entry counts as approved only when it carries all three of Approved by
(a human name), Approved at (a timestamp from an actual clock read, never
guessed), and Approval hash matching its current contract fields.
The agent MUST NOT write an approval block on its own initiative. Computing
the hash is allowed at any time; filling Approved by / Approved at /
Approval hash is allowed only after the human has explicitly approved that
specific entry in the conversation. Writing an approval the human did not give
turns the one human checkpoint in this method into decoration.
Approval is per-entry, never a single signature over the file. This is what makes
the plan amendable: a new entry starts in draft and invalidates nothing, and
editing one approved entry's contract invalidates that entry alone.
The contract fields are the entry title, Change, Depends on and
Done when. State is excluded on purpose — otherwise ordinary progress would
break the approval and train the operator to re-approve mechanically, which
destroys the meaning of approval faster than having no gate at all.
bash scripts/check-plan-gate.sh # check every active change
bash scripts/check-plan-gate.sh --change <name> # check one
bash scripts/check-plan-gate.sh --hash E02 # print one entry's canonical hashNever compute an approval hash by hand or by eye, and never declare the plan
consistent from reading it — run the script and report what it printed. It exits
0 only when every active change (every directory directly under
openspec/changes/ except archive/) is bound to an approved entry whose hash
still matches. Otherwise it names the change and one reason:
| Reason | Meaning | Fix |
|---|---|---|
NO-ENTRY | no entry binds this change | add an entry and have it approved |
NOT-APPROVED | the entry exists but has no valid approval block | ask the human to approve it |
HASH-MISMATCH | the contract fields changed after approval | ask the human to re-approve that entry |
STALE | the change is archived but its entry is not done | set the entry to done |
A missing openspec/PLAN.md is a failure, not a pass: a project with active
changes and no plan is exactly the situation this rule exists to catch.
Before drafting or amending a plan entry, inspect product context with
skills/plan-mode/scripts/product_context.py. The inspection is read-only and
must finish before any mutation of openspec/PLAN.md.
When openspec/product-context.yaml exists, it is the only source authority.
It must declare version: 1 and a closed sources map whose only keys are
prd, personas, tech-stack, adr and backlog; every value is a list of
repository-relative regular-file paths. An omitted type is not configured.
Never fill an omission from conventional paths when the manifest exists.
Only when the manifest is absent, inspect exactly these conventional paths:
PRD.md, docs/PRD.mdUSER-PERSONAS.md, docs/USER-PERSONAS.mdTECH-STACK.md, docs/TECH-STACK.mdBACKLOG.md, docs/BACKLOG.mdADR.md, docs/ADR.md, adr/*.md,
docs/adr/*.mdFor a non-ADR type, simultaneous root and docs/ candidates are conflict:
do not choose one. Multiple distinct ADR files are all admitted. Files outside
these exact paths are not conventional sources.
The inventory uses these states:
| State | Meaning |
|---|---|
fresh | the current SHA-256 equals the digest recorded in the plan, or this is the first capture |
stale | the file exists but its current SHA-256 differs from the recorded digest |
missing | an explicitly configured path does not exist |
not configured | the type was omitted from the manifest, or no conventional candidate exists |
conflict | conventional discovery found competing non-ADR candidates |
Freshness is content-based. mtime, file age and elapsed-time thresholds must
not affect it. Preserve, beside each admitted file, its repository-relative
path, SHA-256 and verification command. On later inspection, pass the previously
recorded inventory with --recorded-inventory; the helper retains no hidden
baseline.
Malformed YAML, an unsupported version, unknown keys, invalid value shapes,
absolute paths, directory paths, repository escapes or an invalid recorded
inventory produce the named error INVALID-PRODUCT-CONTEXT. On that error,
leave openspec/PLAN.md byte-identical and do not attempt conventional
fallback. missing, not configured, conflict and stale are reportable
states, not preflight failures.
Product context enriches the plan; it never governs it. It may supply cited,
decision-relevant outcomes, scope, persona needs, architectural constraints and
backlog priorities, but it must not approve an entry, change its state,
authorize implementation or create another gate. openspec/PLAN.md and
scripts/check-plan-gate.sh remain the only approval authority and mechanical
gate. Preserve semantic disagreements with their source citations for the human
to resolve; never settle them silently.
A plan entry changing state is one of the events enumerated by
rules/history-log.md. Append one line with the plan-mode tag, written
entry <id> <old> → <new>. Rewording an entry or drafting a new one is not a
state transition and gets no line.
.tessl-plugin
rules
skills
handoff
handoff-skill
openspec-apply-change
openspec-archive-change
openspec-explore
openspec-propose
openspec-sync-specs
plan-judge
plan-mode
prompt-loop
requirement-gathering
spec-as-source-setup
templates
openspec-schema
spec-as-source
templates
spec-ci-sync
spec-loop
spec-rebuild
spec-verify
spec-writer
work-review