CtrlK
BlogDocsLog inGet started
Tessl Logo

spec-driven-development/spec-as-source

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

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

plan-mode.mdrules/

plan-mode

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.

Where the plan lives

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.

Structure

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.

States and legal transitions

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.

Approval

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.

The only judge, and the only hash implementation

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 hash

Never 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:

ReasonMeaningFix
NO-ENTRYno entry binds this changeadd an entry and have it approved
NOT-APPROVEDthe entry exists but has no valid approval blockask the human to approve it
HASH-MISMATCHthe contract fields changed after approvalask the human to re-approve that entry
STALEthe change is archived but its entry is not doneset 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.

Product context preflight

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: PRD.md, docs/PRD.md
  • personas: USER-PERSONAS.md, docs/USER-PERSONAS.md
  • technology stack: TECH-STACK.md, docs/TECH-STACK.md
  • backlog: BACKLOG.md, docs/BACKLOG.md
  • architecture decisions: ADR.md, docs/ADR.md, adr/*.md, docs/adr/*.md

For 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:

StateMeaning
freshthe current SHA-256 equals the digest recorded in the plan, or this is the first capture
stalethe file exists but its current SHA-256 differs from the recorded digest
missingan explicitly configured path does not exist
not configuredthe type was omitted from the manifest, or no conventional candidate exists
conflictconventional 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.

Logging

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.

README.md

tile.json