1# OpenSpec 1.4.1 on-disk format (for lazyopenspec)23Source of truth: the `@fission-ai/openspec` npm package (v1.4.1, Node CLI, `dist/` is plain readable JS).4Everything below was confirmed by reading `dist/` and running the CLI against the fixture.56Fixture repo: `testdata/fixture` (copied without its git history).78---910## 1. `openspec init`1112`openspec init --tools none .` (non-interactive) creates **only empty dirs**:1314```15openspec/16openspec/specs/17openspec/changes/18openspec/changes/archive/19```2021`config.yaml` is skipped in non-interactive mode ("Config: skipped (non-interactive mode)") unless you pass `--force`22(or run interactively). `openspec init --tools none --force .` adds `openspec/config.yaml`:2324```yaml25schema: spec-driven2627# Project context (optional)28# ... (commented examples for `context: |` and `rules: {proposal: [...], tasks: [...]}`)29```3031With tools (`--tools claude` etc.) it also writes agent skills/commands outside `openspec/` (e.g. `.claude/skills/openspec-*/`,32`.claude/commands/opsx/*`). Not relevant to a viewer. **1.4.1 does not create `project.md` or `openspec/AGENTS.md`**; those are330.x legacy files (init only prints a migration hint for `project.md`, which is superseded by `config.yaml` `context:`).3435Note: git does not track empty dirs, so a fresh clone may lack `changes/archive/` (or even `specs/`). Handle missing dirs.3637## 2. Directory tree (fixture)3839```40openspec/41 config.yaml # schema + optional context/rules42 specs/ # "main" (source-of-truth) specs43 data-export/spec.md44 notifications/spec.md # created by archive (Purpose still "TBD - ...")45 task-management/spec.md46 user-auth/spec.md47 changes/48 add-workspace-invites/ # scaffold only (just ran `openspec new change`)49 .openspec.yaml50 add-due-dates/ # proposal only (+ README.md from --description)51 .openspec.yaml README.md proposal.md52 add-task-search/ # proposal + delta specs (one new capability, one modified)53 .openspec.yaml proposal.md54 specs/search/spec.md # ADDED (new capability)55 specs/task-management/spec.md # MODIFIED56 harden-session-security/ # proposal+design+specs+tasks, tasks 4/1057 .openspec.yaml proposal.md design.md tasks.md58 specs/user-auth/spec.md # ADDED + MODIFIED + REMOVED + RENAMED59 add-csv-export-filters/ # proposal+specs+tasks (8/8), NO design.md60 .openspec.yaml proposal.md tasks.md61 specs/data-export/spec.md # ADDED + MODIFIED62 archive/63 2026-08-19-add-user-auth/ # full change dir moved verbatim64 .openspec.yaml proposal.md design.md tasks.md specs/user-auth/spec.md65 2026-09-23-add-email-notifications/66 .openspec.yaml proposal.md tasks.md specs/{notifications,task-management}/spec.md67```6869Optional extra dirs a viewer may see: `openspec/schemas/<name>/schema.yaml` (+ `templates/*.md`) for project-local workflow70schemas; `openspec/config.yml` (alt extension); legacy `openspec/project.md`, `openspec/AGENTS.md`.7172## 3. Per-change files7374### `.openspec.yaml` (written by `openspec new change`)75```yaml76schema: spec-driven77created: 2026-10-13 # YYYY-MM-DD, optional78# optional, workspace/initiative features only:79goal: "..." # string80affected_areas: [web, api] # string[]81initiative: { store: my-store, id: my-initiative } # kebab-case ids82```83Zod schema in `dist/core/change-metadata/schema.js`. `schema` must name an existing schema or the CLI errors.84Schema resolution for a change: explicit `--schema` > `.openspec.yaml schema` > `config.yaml schema` > `spec-driven`.85The file may be missing (hand-made or pre-1.x changes): treat as optional.8687### `README.md`88Only created by `openspec new change <name> --description "..."`: `# <name>\n\n<description>\n`. Not an artifact.8990### Change names91`new change` enforces `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/`. Archived dirs (`2026-...`) fail this, which is why92`status`/`show` cannot address archived changes (see section 9).9394## 4. Default workflow schema `spec-driven`9596`schemas/spec-driven/schema.yaml` (also `workspace-planning`, same 4 artifact ids, used for workspaces):9798| artifact | generates | requires |99|---|---|---|100| proposal | `proposal.md` | - |101| specs | `specs/**/*.md` (glob) | proposal |102| design | `design.md` | proposal |103| tasks | `tasks.md` | specs, design |104105`apply: { requires: [tasks], tracks: tasks.md }`. Templates live in `schemas/spec-driven/templates/{proposal,spec,design,tasks}.md`106(`openspec templates --json` prints resolved paths). `openspec schemas --json` lists `{name, description, artifacts[], source: package|user|project}`.107108Schema lookup order: `<repo>/openspec/schemas/<name>/` > `$XDG_DATA_HOME/openspec/schemas/<name>/` (default `~/.local/share/openspec/schemas`) > package.109110### How `status` decides an artifact is done (`dist/core/artifact-graph/{state,outputs}.js`)111- **done** = the `generates` path exists as a file. Pure existence; empty file counts. For globs, fast-glob112 `specs/**/*.md` relative to the change dir (dotfiles excluded) must match >= 1 file. Note `specs/foo.md` (no capability dir)113 counts as done for status even though the delta parser only reads `specs/<cap>/spec.md`.114- **ready** = not done and every `requires` id is done.115- **blocked** = otherwise; `missingDeps` = unmet requires (sorted).116- Dependencies do NOT gate "done": `add-csv-export-filters` has tasks.md but no design.md, so status is117 proposal=done, design=ready, specs=done, tasks=done, `isComplete:false`.118- `isComplete` = every artifact done. It has nothing to do with task checkboxes.119- Artifact order in output = topological build order with alphabetical tie-break: `proposal, design, specs, tasks`.120121## 5. Markdown formats122123### proposal.md (template; no H1)124```125## Why126## What Changes (bullets; **BREAKING** marker convention)127## Capabilities128### New Capabilities - `<kebab-name>`: description129### Modified Capabilities130## Impact131```132Change **title** (used by `show`/`change list` JSON) = first match of `/^#\s+(?:Change:\s+)?(.+)$/im` in proposal.md, else the133change id. The template has no H1, so most titles fall back to the id. Fixture: `add-task-search` has `# Change: Full-text task search`;134`add-email-notifications` (archived) also has an H1; others don't.135Parsing a change for `show --json` throws unless `## Why` and `## What Changes` exist (case-insensitive header match, any level).136137### design.md138`## Context`, `## Goals / Non-Goals` (with `**Goals:**`/`**Non-Goals:**`), `## Decisions`, `## Risks / Trade-offs`,139optional `## Migration Plan`, `## Open Questions`. Free-form; never parsed by the CLI.140141### tasks.md and task progress142```143## 1. Group name144145- [ ] 1.1 Task description146- [x] 1.2 Done task147```148Counting (`dist/utils/task-progress.js`, used by `list`, `view`, `archive`, `change list`):149```js150TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i // tested against the raw line, NOT trimmed151COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i152```153- Only column-0 `-`/`*` bullets count. **Indented (nested) checkboxes are ignored entirely.** `+` bullets ignored.154- `[x]` and `[X]` both complete. `[~]`, `[-]` etc. are not tasks.155- Code fences are NOT excluded (checkboxes inside ``` blocks count).156- Missing tasks.md => `{total:0, completed:0}`.157- `list --json` status: `total==0 -> "no-tasks"`, `completed==total -> "complete"`, else `"in-progress"`.158`instructions apply --json` uses a slightly different regex, `/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/` (space after bullet optional,159needs text), and returns per-task `{id: "<1-based ordinal>", description, done}` plus `state: blocked|all_done|ready`.160Fixture `harden-session-security/tasks.md` exercises this: 10 top-level tasks (4 done incl. one `[X]`) plus 2 indented161sub-checkboxes that the CLI ignores (=> `4/10`; counting nested would give `5/12`).162163### Main spec `openspec/specs/<cap>/spec.md`164```165# <Title> Specification (H1 is free text; the CLI never reads it)166167## Purpose168<text> (required; < 50 chars => WARNING)169170## Requirements171### Requirement: <name>172<first body line MUST contain SHALL or MUST>173174#### Scenario: <name>175- **WHEN** ...176- **THEN** ...177- **AND** ...178```179Key regexes (`dist/core/parsers/*.js`):180```js181/^(#{1,6})\s+(.+)$/ // generic header; fenced code (``` or ~~~) is masked out182/^##\s+Requirements\s*$/i // requirements section (ends at next /^##\s+/)183/^###\s*Requirement:\s*(.+)\s*$/i // requirement header; name = trim(capture)184/^####\s+/ // scenario count (any H4 under a requirement)185/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i // forbidden in main specs (validation ERROR)186```187Section lookup for `Purpose`/`Requirements`/`Why`/`What Changes` is case-insensitive on the title and searches nested188sections, so `## Purpose` vs `### Purpose` both work. Requirement "text" = first non-empty line after the header (before any `#`).189190Archive-created specs use this skeleton (note: archive also strips blank lines around `## Requirements` in specs it rewrites):191```192# <cap-id> Specification193194## Purpose195TBD - created by archiving change <change-name>. Update Purpose after archive.196## Requirements197### Requirement: ...198```199Fixture `notifications` keeps the TBD purpose; `task-management` shows the blank-line stripping after a MODIFIED merge.200201### Delta spec `openspec/changes/<change>/specs/<cap>/spec.md`202No H1/Purpose. Top-level `##` sections (case-insensitive):203```204## ADDED Requirements205### Requirement: Session revocation206Users SHALL ... (SHALL/MUST on first body line; `**Key**:` metadata lines skipped)207#### Scenario: ... (>= 1 required for ADDED/MODIFIED)208209## MODIFIED Requirements210### Requirement: Session expiry (name must match the main spec header; FULL replacement block)211...212213## REMOVED Requirements214### Requirement: Remember me215**Reason**: ...216**Migration**: ...217 (alt form also accepted: - `### Requirement: Name`)218219## RENAMED Requirements220- FROM: `### Requirement: Password login`221- TO: `### Requirement: Password authentication`222```223RENAMED regexes: `/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/` and same with `TO:`; pairs are FROM followed by TO.224A requirement block runs from its `### Requirement:` line to the next requirement header or `## ` line.225Only `specs/<dir>/spec.md` files are read for deltas (one level deep, file name must be `spec.md`).226227### Archive (`openspec archive <name> -y`)228Applies deltas into `openspec/specs/<cap>/spec.md` (creating the skeleton for new caps), validates the rebuilt spec,229then moves the whole change dir to `openspec/changes/archive/<YYYY-MM-DD>-<name>/` (UTC date from `toISOString()`).230Archived dirs keep `.openspec.yaml`, all artifacts and the delta specs. Fixture archive dirs were renamed to backdated231dates after a real archive run.232233## 6. Discovery rules differ per command (important for parity)234235| command | what counts as a change | what counts as a spec | root |236|---|---|---|---|237| `list` (changes) | any **dir** in `changes/` except `archive` (dot-dirs included, proposal NOT required) | - | `./openspec` (cwd only) |238| `list --specs` | - | any dir in `specs/` (missing/unparseable spec.md => 0 requirements) | cwd only |239| `spec list` | - | any dir in `specs/` that has `spec.md` (dot-dirs included) | cwd only |240| `show`, `validate`, `change list` | dir not starting with `.`, not `archive`, **has proposal.md** | dir not starting with `.`, has `spec.md` | cwd only |241| `status`, `instructions`, `new change` | dir under `changesDir` matching kebab name | - | walks up ancestors to the nearest dir containing `openspec/` (or a workspace) |242243So `add-workspace-invites` (no proposal.md) appears in `list --json` but not in `change list`, `show`, or `validate`.244`list`/`show`/`validate` fail from a subdirectory ("No OpenSpec changes directory found"); run them with cwd = repo root.245246## 7. "last modified" / `--sort recent`247248`dist/core/list.js getLastModified(dir)`: recursive walk of the change dir, max `stat.mtime` over **files** (incl.249`.openspec.yaml`, nested `specs/**`); if no files, the dir's own mtime. Output as `toISOString()` (UTC, ms). `--sort recent`250(default) = desc by that; `--sort name` = `localeCompare`. Git checkout resets mtimes, so on a fresh clone all changes tie.251(The fixture's mtimes were set with `touch -t` locally to give a meaningful order; they won't survive a clone.)252253## 8. Validation (`openspec validate`)254255- Change validation only looks at **delta specs** (`validateChangeDeltaSpecs`), never at proposal.md content.256 ERRORs: zero deltas total ("Change must have at least one delta..."), a `specs/<cap>/spec.md` with no delta headers,257 delta headers with no parsed entries, ADDED/MODIFIED without SHALL/MUST (`/\b(SHALL|MUST)\b/` on first body line) or without258 an `####` scenario, duplicates within a section, same name in MODIFIED+REMOVED / MODIFIED+ADDED / ADDED+REMOVED,259 MODIFIED using a RENAMED old name, RENAMED TO colliding with ADDED.260- Spec validation: needs `## Purpose` + `## Requirements`, >=1 requirement, each with SHALL/MUST (substring, case-sensitive) and261 >=1 non-empty scenario; delta headers or requirement headers outside `## Requirements` are ERRORs; Purpose <50 chars WARNING;262 requirement text >500 chars INFO; `--strict` makes WARNINGs fail.263- Exit code 1 if any item fails. Bulk results sorted by id (changes and specs interleaved).264265**Fixture result: 7 passed, 1 failed.** `add-due-dates` (proposal-only) fails with "Change must have at least one delta". This is266inherent: any change before its specs exist fails validation. `add-workspace-invites` is not validated at all (no proposal.md).267All 4 main specs and the other 3 changes pass.268269## 9. JSON output shapes (trimmed)270271`openspec list --json` (only `changes`; `{"changes": []}` when empty):272```json273{"changes": [274 {"name": "harden-session-security", "completedTasks": 4, "totalTasks": 10,275 "lastModified": "2026-10-13T17:30:00.000Z", "status": "in-progress"},276 {"name": "add-workspace-invites", "completedTasks": 0, "totalTasks": 0, "lastModified": "...", "status": "no-tasks"},277 {"name": "add-csv-export-filters", "completedTasks": 8, "totalTasks": 8, "lastModified": "...", "status": "complete"}]}278```279280`openspec list --specs --json`: **BUG in 1.4.1: `--json` is ignored, prints text**:281```282Specs:283 data-export requirements 3284 ...285```286Use the deprecated `openspec spec list --json` instead (prints a deprecation warning on stderr):287```json288[{"id": "data-export", "title": "data-export", "requirementCount": 3}, ...]289```290(`title` is always the id, never the H1.) `openspec change list --json` (deprecated) is the change equivalent:291`[{"id","title","deltaCount","taskStatus":{"total","completed"}}]`, proposal-having changes only, sorted by id.292293`openspec show <change> --json` (always prints `Warning: Ignoring flags not applicable to change: scenarios` on stderr):294```json295{"id": "harden-session-security", "title": "harden-session-security", "deltaCount": 4,296 "deltas": [297 {"spec": "user-auth", "operation": "ADDED",298 "description": "Add requirement: Users SHALL be able to list ...",299 "requirement": {"text": "Users SHALL ...", "scenarios": [{"rawText": "- **WHEN** ...\n- **THEN** ..."}]},300 "requirements": [ /* same object again */ ]},301 {"spec": "user-auth", "operation": "MODIFIED", "description": "Modify requirement: ...", "requirement": {...}, "requirements": [...]},302 {"spec": "user-auth", "operation": "REMOVED", "description": "Remove requirement: **Reason**: ...",303 "requirement": {"text": "**Reason**: ...", "scenarios": []}, "requirements": [...]},304 {"spec": "user-auth", "operation": "RENAMED", "description": "Rename requirement from \"Password login\" to \"Password authentication\"",305 "rename": {"from": "Password login", "to": "Password authentication"}}]}306```307**Lossy:** requirement names (the `### Requirement:` text) and scenario names are NOT in the JSON; `text` is the first body line308and scenarios are raw bodies. If no `specs/` deltas exist it falls back to parsing `- **cap**: desc` bullets in What Changes309(`add-due-dates` => `deltaCount: 0`). Errors (no Why / What Changes) abort with exit 1.310311`openspec show <spec> --json --type spec`:312```json313{"id": "user-auth", "title": "user-auth", "overview": "<Purpose text>", "requirementCount": 4,314 "requirements": [{"text": "The system SHALL authenticate ...", "scenarios": [{"rawText": "- **WHEN** ...\n- **THEN** ..."}]}],315 "metadata": {"version": "1.0.0", "format": "openspec"}}316```317Same lossiness (no requirement/scenario names). Flags: `--requirements` (drop scenarios), `--no-scenarios`, `-r <1-based index>`.318319`openspec status --change <name> --json`:320```json321{"changeName": "add-csv-export-filters", "schemaName": "spec-driven",322 "planningHome": {"kind": "repo", "root": "/abs/fixture", "changesDir": "/abs/fixture/openspec/changes", "defaultSchema": "spec-driven"},323 "changeRoot": "/abs/.../changes/add-csv-export-filters",324 "artifactPaths": {"specs": {"outputPath": "specs/**/*.md", "resolvedOutputPath": "/abs/.../specs/**/*.md",325 "existingOutputPaths": ["/abs/.../specs/data-export/spec.md"]}, "proposal": {...}, ...},326 "isComplete": false, "applyRequires": ["tasks"],327 "nextSteps": ["Run openspec instructions design --change \"add-csv-export-filters\" --json before writing that artifact."],328 "actionContext": {"mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts": [...], "linkedContext": [],329 "allowedEditRoots": ["/abs/fixture"], "requiresAffectedAreaSelection": false, "constraints": [...]},330 "artifacts": [331 {"id": "proposal", "outputPath": "proposal.md", "status": "done"},332 {"id": "design", "outputPath": "design.md", "status": "ready"},333 {"id": "specs", "outputPath": "specs/**/*.md", "status": "done"},334 {"id": "tasks", "outputPath": "tasks.md", "status": "done"}]}335```336Blocked entries add `"missingDeps": ["design", "specs"]`. Optional keys: `initiative`, `affectedAreas` (workspace mode).337`status` without `--change` and no changes: `{"changes": [], "message": "No active changes."}`; with changes it errors.338339`openspec validate --all --json`:340```json341{"items": [342 {"id": "add-due-dates", "type": "change", "valid": false, "durationMs": 4,343 "issues": [{"level": "ERROR", "path": "file", "message": "Change must have at least one delta. ..."}]},344 {"id": "data-export", "type": "spec", "valid": true, "issues": [], "durationMs": 2}],345 "summary": {"totals": {"items": 8, "passed": 7, "failed": 1},346 "byType": {"change": {"items": 4, "passed": 3, "failed": 1}, "spec": {"items": 4, "passed": 4, "failed": 0}}},347 "version": "1.0"}348```349Issue `level`: `ERROR|WARNING|INFO`; `path` is `file`, a JSON-ish path (`requirements[0].scenarios`) or `<cap>/spec.md`; spec350structure issues may add `line`.351352Also captured: `instructions apply --change X --json` (`progress {total,complete,remaining}`, `tasks[]`, `state`, `contextFiles`).353354## 10. Timings (Apple M5 Pro, Node 22.22, fixture repo, median of 10)355356| command | `OPENSPEC_TELEMETRY=0` | default (telemetry on) |357|---|---|---|358| `node -e ''` baseline | 19 ms | |359| `openspec --version` | 105 ms | |360| `list --json` | 110 ms | ~330 ms |361| `spec list --json` | 112 ms | |362| `show <change> --json` | 109 ms | |363| `show <spec> --json --type spec` | 109 ms | |364| `status --change X --json` | 121 ms | ~350 ms |365| `validate --all --json` | 115 ms | |366| `instructions apply --change X --json` | 121 ms | |367368- Cost is essentially all Node startup + module loading (~100 ms); parsing is <10 ms. A scaled copy with 45 changes / 44 specs369 took the same ~110-120 ms per command.370- **Telemetry is on by default** (PostHog, flushed at exit, adds ~220 ms and a network call per invocation). If you shell out,371 set `OPENSPEC_TELEMETRY=0` (or `DO_NOT_TRACK=1`) in the child env.372- Full refresh of the fixture (12 processes: list, spec list, validate --all, 5x status, 4x show): ~1.6 s sequential, ~250 ms373 with all 12 in parallel.374375Verdict: shelling out per file change is viable only if debounced (>= 300 ms), parallelised, scoped to the affected change, and376telemetry is disabled. Reading the files directly in Go is cheap, gives you data the JSON drops (requirement and scenario names),377and has no cwd/discovery quirks. Use the CLI only for rare things like validate.378379## 11. Other 1.4.1 things a viewer should know380381- **Global config**: `~/.config/openspec/config.json` (XDG_CONFIG_HOME; holds profile, delivery, workflows, telemetry id). Global382 data dir `~/.local/share/openspec/` (XDG_DATA_HOME): user schemas, `workspaces/registry.yaml`, `context-stores/`.383- **Workspaces** (`openspec workspace setup|link|...`): a workspace root is a dir containing `.openspec-workspace/view.yaml`384 (`version: 1, name, links: {name: path|null}, ...`) plus `changes/` directly at the root (**no `openspec/` dir**), optionally a385 `<name>.code-workspace` (written by `workspace open`). Default schema there is `workspace-planning`; area specs go under `specs/<area>/<cap>/spec.md`.386 `status` chooses the nearest planning home: a workspace wins if it is at least as deep as the nearest repo `openspec/`.387 `.openspec.yaml` in workspace changes may carry `goal` and `affected_areas`.388- **Context stores** (`openspec context-store setup|register|...`): store dirs (default `~/.local/share/openspec/context-stores/<id>/`)389 contain `.openspec-store/store.yaml`; registry at `context-stores/registry.yaml`.390- **Initiatives** (`openspec initiative create|show|list`): live inside a store at `<store>/initiatives/<id>/` with391 `initiative.yaml` (`version: 1, id, title, summary, status: exploring|active|complete|archived, created: YYYY-MM-DD, owners[], metadata{}`)392 and `requirements.md, design.md, decisions.md, questions.md, tasks.md`. A repo change links via `.openspec.yaml`393 `initiative: {store, id}` (`openspec set change <name> --initiative <id> --store <id>`); `status` then includes `initiative`.394- **config.yaml**: `schema` (string), `context` (string, <= 50 KB, injected into AI instructions), `rules` (`{artifactId: string[]}`).395 Parsed leniently field-by-field; `.yml` also accepted.396- **Schemas dir**: project-local `openspec/schemas/<name>/schema.yaml` (`openspec schema init|fork`) can define different397 artifact ids/paths, so a viewer that wants to be schema-aware should read `schema.yaml` `artifacts[].{id,generates,requires}`398 and resolve per-change schema as in section 3 rather than hard-coding the 4 artifacts.399- **`openspec view`**: dashboard; same discovery as `list` (any non-archive dir), buckets changes as draft (0 tasks), active (partial), completed (all tasks done).400- Archived changes are invisible to `list`, `show`, `status`, `validate` (no CLI command lists them); read401 `changes/archive/*/` directly. The archive date prefix is the only archive timestamp on disk.402- Line endings: all parsers normalise `\r\n?` to `\n` (except the task counter, whose regex is CRLF-safe anyway).