OpenSpec 1.4.1 on-disk format (for lazyopenspec)
Source of truth: the @fission-ai/ npm package (v1.4.1, Node CLI, dist/ is plain readable JS).
Everything below was confirmed by reading dist/ and running the CLI against the fixture.
Fixture repo: testdata/ (copied without its git history).
1. openspec init
openspec init --tools none . (non-interactive) creates only empty dirs:
openspec/
openspec/specs/
openspec/changes/
openspec/changes/archive/
config.yaml is skipped in non-interactive mode ("Config: skipped (non-interactive mode)") unless you pass --force
(or run interactively). openspec init --tools none --force . adds openspec/:
schema: spec-driven
# Project context (optional)
# ... (commented examples for `context: |` and `rules: {proposal: [...], tasks: [...]}`)
With tools (--tools claude etc.) it also writes agent skills/commands outside openspec/ (e.g. .claude/,
.claude/). Not relevant to a viewer. 1.4.1 does not create project.md or openspec/; those are
0.x legacy files (init only prints a migration hint for project.md, which is superseded by config.yaml context:).
Note: git does not track empty dirs, so a fresh clone may lack changes/ (or even specs/). Handle missing dirs.
2. Directory tree (fixture)
openspec/
config.yaml # schema + optional context/rules
specs/ # "main" (source-of-truth) specs
data-export/spec.md
notifications/spec.md # created by archive (Purpose still "TBD - ...")
task-management/spec.md
user-auth/spec.md
changes/
add-workspace-invites/ # scaffold only (just ran `openspec new change`)
.openspec.yaml
add-due-dates/ # proposal only (+ README.md from --description)
.openspec.yaml README.md proposal.md
add-task-search/ # proposal + delta specs (one new capability, one modified)
.openspec.yaml proposal.md
specs/search/spec.md # ADDED (new capability)
specs/task-management/spec.md # MODIFIED
harden-session-security/ # proposal+design+specs+tasks, tasks 4/10
.openspec.yaml proposal.md design.md tasks.md
specs/user-auth/spec.md # ADDED + MODIFIED + REMOVED + RENAMED
add-csv-export-filters/ # proposal+specs+tasks (8/8), NO design.md
.openspec.yaml proposal.md tasks.md
specs/data-export/spec.md # ADDED + MODIFIED
archive/
2026-08-19-add-user-auth/ # full change dir moved verbatim
.openspec.yaml proposal.md design.md tasks.md specs/user-auth/spec.md
2026-09-23-add-email-notifications/
.openspec.yaml proposal.md tasks.md specs/{notifications,task-management}/spec.md
Optional extra dirs a viewer may see: openspec/ (+ templates/) for project-local workflow
schemas; openspec/ (alt extension); legacy openspec/, openspec/.
3. Per-change files
.openspec.yaml (written by openspec new change)
schema: spec-driven
created: 2026-10-13 # YYYY-MM-DD, optional
# optional, workspace/initiative features only:
goal: "..." # string
affected_areas: [web, api] # string[]
initiative: { store: my-store, id: my-initiative } # kebab-case ids
Zod schema in dist/. schema must name an existing schema or the CLI errors.
Schema resolution for a change: explicit --schema > .openspec.yaml schema > config.yaml schema > spec-driven.
The file may be missing (hand-made or pre-1.x changes): treat as optional.
README.md
Only created by openspec new change <name> --description "...": # <name>\n\n<description>\n. Not an artifact.
Change names
new change enforces /. Archived dirs (2026-...) fail this, which is why
status/show cannot address archived changes (see section 9).
4. Default workflow schema spec-driven
schemas/ (also workspace-planning, same 4 artifact ids, used for workspaces):
| artifact | generates | requires |
|---|---|---|
| proposal | proposal.md |
- |
| specs | specs/ (glob) |
proposal |
| design | design.md |
proposal |
| tasks | tasks.md |
specs, design |
apply: { requires: [tasks], tracks: tasks.md }. Templates live in schemas/
(openspec templates --json prints resolved paths). openspec schemas --json lists {name, description, artifacts[], source: package|user|project}.
Schema lookup order: <repo>/ > $XDG_DATA_HOME/ (default ~/) > package.
How status decides an artifact is done (dist/ core/ artifact-graph/ {state,outputs}.js)
- done = the
generatespath exists as a file. Pure existence; empty file counts. For globs, fast-globspecs/relative to the change dir (dotfiles excluded) must match >= 1 file. Note**/ *.md specs/(no capability dir) counts as done for status even though the delta parser only readsfoo.md specs/.<cap>/ spec.md - ready = not done and every
requiresid is done. - blocked = otherwise;
missingDeps= unmet requires (sorted). - Dependencies do NOT gate "done":
add-csv-export-filtershas tasks.md but no design.md, so status is proposal=done, design=ready, specs=done, tasks=done,isComplete:false. isComplete= every artifact done. It has nothing to do with task checkboxes.- Artifact order in output = topological build order with alphabetical tie-break:
proposal, design, specs, tasks.
5. Markdown formats
proposal.md (template; no H1)
## Why
## What Changes (bullets; **BREAKING** marker convention)
## Capabilities
### New Capabilities - `<kebab-name>`: description
### Modified Capabilities
## Impact
Change title (used by show/change list JSON) = first match of / in proposal.md, else the
change id. The template has no H1, so most titles fall back to the id. Fixture: add-task-search has # Change: Full-text task search;
add-email-notifications (archived) also has an H1; others don't.
Parsing a change for show --json throws unless ## Why and ## What Changes exist (case-insensitive header match, any level).
design.md
## Context, ## Goals / (with **Goals:**/**Non-Goals:**), ## Decisions, ## Risks /,
optional ## Migration Plan, ## Open Questions. Free-form; never parsed by the CLI.
tasks.md and task progress
## 1. Group name
- [ ] 1.1 Task description
- [x] 1.2 Done task
Counting (dist/, used by list, view, archive, change list):
TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i // tested against the raw line, NOT trimmed
COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i
- Only column-0
-/*bullets count. Indented (nested) checkboxes are ignored entirely.+bullets ignored. [x]and[X]both complete.[~],[-]etc. are not tasks.- Code fences are NOT excluded (checkboxes inside ``` blocks count).
- Missing tasks.md =>
{total:0, completed:0}. list --jsonstatus:total==0 -> "no-tasks",completed==total -> "complete", else"in-progress".instructions apply --jsonuses a slightly different regex,/(space after bullet optional, needs text), and returns per-task^[-*]\s*\[([ xX])\]\s*(.+)\s*$/ {id: "<1-based ordinal>", description, done}plusstate: blocked|all_done|ready. Fixtureharden-session-security/exercises this: 10 top-level tasks (4 done incl. onetasks.md [X]) plus 2 indented sub-checkboxes that the CLI ignores (=>4/; counting nested would give10 5/).12
Main spec openspec/ specs/ <cap>/ spec.md
# <Title> Specification (H1 is free text; the CLI never reads it)
## Purpose
<text> (required; < 50 chars => WARNING)
## Requirements
### Requirement: <name>
<first body line MUST contain SHALL or MUST>
#### Scenario: <name>
- **WHEN** ...
- **THEN** ...
- **AND** ...
Key regexes (dist/):
/^(#{1,6})\s+(.+)$/ // generic header; fenced code (``` or ~~~) is masked out
/^##\s+Requirements\s*$/i // requirements section (ends at next /^##\s+/)
/^###\s*Requirement:\s*(.+)\s*$/i // requirement header; name = trim(capture)
/^####\s+/ // scenario count (any H4 under a requirement)
/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i // forbidden in main specs (validation ERROR)
Section lookup for Purpose/Requirements/Why/What Changes is case-insensitive on the title and searches nested
sections, so ## Purpose vs ### Purpose both work. Requirement "text" = first non-empty line after the header (before any #).
Archive-created specs use this skeleton (note: archive also strips blank lines around ## Requirements in specs it rewrites):
# <cap-id> Specification
## Purpose
TBD - created by archiving change <change-name>. Update Purpose after archive.
## Requirements
### Requirement: ...
Fixture notifications keeps the TBD purpose; task-management shows the blank-line stripping after a MODIFIED merge.
Delta spec openspec/ changes/ <change>/ specs/ <cap>/ spec.md
No H1/Purpose. Top-level ## sections (case-insensitive):
## ADDED Requirements
### Requirement: Session revocation
Users SHALL ... (SHALL/MUST on first body line; `**Key**:` metadata lines skipped)
#### Scenario: ... (>= 1 required for ADDED/MODIFIED)
## MODIFIED Requirements
### Requirement: Session expiry (name must match the main spec header; FULL replacement block)
...
## REMOVED Requirements
### Requirement: Remember me
**Reason**: ...
**Migration**: ...
(alt form also accepted: - `### Requirement: Name`)
## RENAMED Requirements
- FROM: `### Requirement: Password login`
- TO: `### Requirement: Password authentication`
RENAMED regexes: /?###\sRequirement:\s(.+?)?\s*$/ and same with TO:; pairs are FROM followed by TO.
A requirement block runs from its ### Requirement: line to the next requirement header or ## line.
Only specs/ files are read for deltas (one level deep, file name must be spec.md).
Archive (openspec archive <name> -y)
Applies deltas into openspec/ (creating the skeleton for new caps), validates the rebuilt spec,
then moves the whole change dir to openspec/ (UTC date from toISOString()).
Archived dirs keep .openspec.yaml, all artifacts and the delta specs. Fixture archive dirs were renamed to backdated
dates after a real archive run.
6. Discovery rules differ per command (important for parity)
| command | what counts as a change | what counts as a spec | root |
|---|---|---|---|
list (changes) |
any dir in changes/ except archive (dot-dirs included, proposal NOT required) |
- | ./ (cwd only) |
list --specs |
- | any dir in specs/ (missing/unparseable spec.md => 0 requirements) |
cwd only |
spec list |
- | any dir in specs/ that has spec.md (dot-dirs included) |
cwd only |
show, validate, change list |
dir not starting with ., not archive, has proposal.md |
dir not starting with ., has spec.md |
cwd only |
status, instructions, new change |
dir under changesDir matching kebab name |
- | walks up ancestors to the nearest dir containing openspec/ (or a workspace) |
So add-workspace-invites (no proposal.md) appears in list --json but not in change list, show, or validate.
list/show/validate fail from a subdirectory ("No OpenSpec changes directory found"); run them with cwd = repo root.
7. "last modified" / --sort recent
dist/: recursive walk of the change dir, max stat.mtime over files (incl.
.openspec.yaml, nested specs/); if no files, the dir's own mtime. Output as toISOString() (UTC, ms). --sort recent
(default) = desc by that; --sort name = localeCompare. Git checkout resets mtimes, so on a fresh clone all changes tie.
(The fixture's mtimes were set with touch -t locally to give a meaningful order; they won't survive a clone.)
8. Validation (openspec validate)
- Change validation only looks at delta specs (
validateChangeDeltaSpecs), never at proposal.md content. ERRORs: zero deltas total ("Change must have at least one delta..."), aspecs/with no delta headers, delta headers with no parsed entries, ADDED/MODIFIED without SHALL/MUST (<cap>/ spec.md /on first body line) or without an\b(SHALL|MUST)\b/ ####scenario, duplicates within a section, same name in MODIFIED+REMOVED / MODIFIED+ADDED / ADDED+REMOVED, MODIFIED using a RENAMED old name, RENAMED TO colliding with ADDED. - Spec validation: needs
## Purpose+## Requirements, >=1 requirement, each with SHALL/MUST (substring, case-sensitive) and=1 non-empty scenario; delta headers or requirement headers outside
## Requirementsare ERRORs; Purpose <50 chars WARNING; requirement text >500 chars INFO;--strictmakes WARNINGs fail. - Exit code 1 if any item fails. Bulk results sorted by id (changes and specs interleaved).
Fixture result: 7 passed, 1 failed. add-due-dates (proposal-only) fails with "Change must have at least one delta". This is
inherent: any change before its specs exist fails validation. add-workspace-invites is not validated at all (no proposal.md).
All 4 main specs and the other 3 changes pass.
9. JSON output shapes (trimmed)
openspec list --json (only changes; {"changes": []} when empty):
{"changes": [
{"name": "harden-session-security", "completedTasks": 4, "totalTasks": 10,
"lastModified": "2026-10-13T17:30:00.000Z", "status": "in-progress"},
{"name": "add-workspace-invites", "completedTasks": 0, "totalTasks": 0, "lastModified": "...", "status": "no-tasks"},
{"name": "add-csv-export-filters", "completedTasks": 8, "totalTasks": 8, "lastModified": "...", "status": "complete"}]}
openspec list --specs --json: BUG in 1.4.1: --json is ignored, prints text:
Specs:
data-export requirements 3
...
Use the deprecated openspec spec list --json instead (prints a deprecation warning on stderr):
[{"id": "data-export", "title": "data-export", "requirementCount": 3}, ...]
(title is always the id, never the H1.) openspec change list --json (deprecated) is the change equivalent:
[{"id","title","deltaCount","taskStatus":{"total","completed"}}], proposal-having changes only, sorted by id.
openspec show <change> --json (always prints Warning: Ignoring flags not applicable to change: scenarios on stderr):
{"id": "harden-session-security", "title": "harden-session-security", "deltaCount": 4,
"deltas": [
{"spec": "user-auth", "operation": "ADDED",
"description": "Add requirement: Users SHALL be able to list ...",
"requirement": {"text": "Users SHALL ...", "scenarios": [{"rawText": "- **WHEN** ...\n- **THEN** ..."}]},
"requirements": [ /* same object again */ ]},
{"spec": "user-auth", "operation": "MODIFIED", "description": "Modify requirement: ...", "requirement": {...}, "requirements": [...]},
{"spec": "user-auth", "operation": "REMOVED", "description": "Remove requirement: **Reason**: ...",
"requirement": {"text": "**Reason**: ...", "scenarios": []}, "requirements": [...]},
{"spec": "user-auth", "operation": "RENAMED", "description": "Rename requirement from \"Password login\" to \"Password authentication\"",
"rename": {"from": "Password login", "to": "Password authentication"}}]}
Lossy: requirement names (the ### Requirement: text) and scenario names are NOT in the JSON; text is the first body line
and scenarios are raw bodies. If no specs/ deltas exist it falls back to parsing - **cap**: desc bullets in What Changes
(add-due-dates => deltaCount: 0). Errors (no Why / What Changes) abort with exit 1.
openspec show <spec> --json --type spec:
{"id": "user-auth", "title": "user-auth", "overview": "<Purpose text>", "requirementCount": 4,
"requirements": [{"text": "The system SHALL authenticate ...", "scenarios": [{"rawText": "- **WHEN** ...\n- **THEN** ..."}]}],
"metadata": {"version": "1.0.0", "format": "openspec"}}
Same lossiness (no requirement/scenario names). Flags: --requirements (drop scenarios), --no-scenarios, -r <1-based index>.
openspec status --change <name> --json:
{"changeName": "add-csv-export-filters", "schemaName": "spec-driven",
"planningHome": {"kind": "repo", "root": "/abs/fixture", "changesDir": "/abs/fixture/openspec/changes", "defaultSchema": "spec-driven"},
"changeRoot": "/abs/.../changes/add-csv-export-filters",
"artifactPaths": {"specs": {"outputPath": "specs/**/*.md", "resolvedOutputPath": "/abs/.../specs/**/*.md",
"existingOutputPaths": ["/abs/.../specs/data-export/spec.md"]}, "proposal": {...}, ...},
"isComplete": false, "applyRequires": ["tasks"],
"nextSteps": ["Run openspec instructions design --change \"add-csv-export-filters\" --json before writing that artifact."],
"actionContext": {"mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts": [...], "linkedContext": [],
"allowedEditRoots": ["/abs/fixture"], "requiresAffectedAreaSelection": false, "constraints": [...]},
"artifacts": [
{"id": "proposal", "outputPath": "proposal.md", "status": "done"},
{"id": "design", "outputPath": "design.md", "status": "ready"},
{"id": "specs", "outputPath": "specs/**/*.md", "status": "done"},
{"id": "tasks", "outputPath": "tasks.md", "status": "done"}]}
Blocked entries add "missingDeps": ["design", "specs"]. Optional keys: initiative, affectedAreas (workspace mode).
status without --change and no changes: {"changes": [], "message": "No active changes."}; with changes it errors.
openspec validate --all --json:
{"items": [
{"id": "add-due-dates", "type": "change", "valid": false, "durationMs": 4,
"issues": [{"level": "ERROR", "path": "file", "message": "Change must have at least one delta. ..."}]},
{"id": "data-export", "type": "spec", "valid": true, "issues": [], "durationMs": 2}],
"summary": {"totals": {"items": 8, "passed": 7, "failed": 1},
"byType": {"change": {"items": 4, "passed": 3, "failed": 1}, "spec": {"items": 4, "passed": 4, "failed": 0}}},
"version": "1.0"}
Issue level: ERROR|WARNING|INFO; path is file, a JSON-ish path (requirements[0].scenarios) or <cap>/; spec
structure issues may add line.
Also captured: instructions apply --change X --json (progress {total,complete,remaining}, tasks[], state, contextFiles).
10. Timings (Apple M5 Pro, Node 22.22, fixture repo, median of 10)
| command | OPENSPEC_TELEMETRY=0 |
default (telemetry on) |
|---|---|---|
node -e '' baseline |
19 ms | |
openspec --version |
105 ms | |
list --json |
110 ms | ~330 ms |
spec list --json |
112 ms | |
show <change> --json |
109 ms | |
show <spec> --json --type spec |
109 ms | |
status --change X --json |
121 ms | ~350 ms |
validate --all --json |
115 ms | |
instructions apply --change X --json |
121 ms |
- Cost is essentially all Node startup + module loading (~100 ms); parsing is <10 ms. A scaled copy with 45 changes / 44 specs took the same ~110-120 ms per command.
- Telemetry is on by default (PostHog, flushed at exit, adds ~220 ms and a network call per invocation). If you shell out,
set
OPENSPEC_TELEMETRY=0(orDO_NOT_TRACK=1) in the child env. - Full refresh of the fixture (12 processes: list, spec list, validate --all, 5x status, 4x show): ~1.6 s sequential, ~250 ms with all 12 in parallel.
Verdict: shelling out per file change is viable only if debounced (>= 300 ms), parallelised, scoped to the affected change, and telemetry is disabled. Reading the files directly in Go is cheap, gives you data the JSON drops (requirement and scenario names), and has no cwd/discovery quirks. Use the CLI only for rare things like validate.
11. Other 1.4.1 things a viewer should know
- Global config:
~/(XDG_CONFIG_HOME; holds profile, delivery, workflows, telemetry id). Global data dir.config/ openspec/ config.json ~/(XDG_DATA_HOME): user schemas,.local/ share/ openspec/ workspaces/,registry.yaml context-stores/. - Workspaces (
openspec workspace setup|link|...): a workspace root is a dir containing.openspec-workspace/(view.yaml version: 1, name, links: {name: path|null}, ...) pluschanges/directly at the root (noopenspec/dir), optionally a<name>.code-workspace(written byworkspace open). Default schema there isworkspace-planning; area specs go underspecs/.<area>/ <cap>/ spec.md statuschooses the nearest planning home: a workspace wins if it is at least as deep as the nearest repoopenspec/..openspec.yamlin workspace changes may carrygoalandaffected_areas. - Context stores (
openspec context-store setup|register|...): store dirs (default~/) contain.local/ share/ openspec/ context-stores/ <id>/ .openspec-store/; registry atstore.yaml context-stores/.registry.yaml - Initiatives (
openspec initiative create|show|list): live inside a store at<store>/withinitiatives/ <id>/ initiative.yaml(version: 1, id, title, summary, status: exploring|active|complete|archived, created: YYYY-MM-DD, owners[], metadata{}) andrequirements.md, design.md, decisions.md, questions.md, tasks.md. A repo change links via.openspec.yamlinitiative: {store, id}(openspec set change <name> --initiative <id> --store <id>);statusthen includesinitiative. - config.yaml:
schema(string),context(string, <= 50 KB, injected into AI instructions),rules({artifactId: string[]}). Parsed leniently field-by-field;.ymlalso accepted. - Schemas dir: project-local
openspec/(schemas/ <name>/ schema.yaml openspec schema init|fork) can define different artifact ids/paths, so a viewer that wants to be schema-aware should readschema.yamlartifacts[].{id,generates,requires}and resolve per-change schema as in section 3 rather than hard-coding the 4 artifacts. openspec view: dashboard; same discovery aslist(any non-archive dir), buckets changes as draft (0 tasks), active (partial), completed (all tasks done).- Archived changes are invisible to
list,show,status,validate(no CLI command lists them); readchanges/directly. The archive date prefix is the only archive timestamp on disk.archive/ */ - Line endings: all parsers normalise
\r\n?to\n(except the task counter, whose regex is CRLF-safe anyway).