diff --git a/.gitignore b/.gitignore index 3a952f090..77104954f 100644 --- a/.gitignore +++ b/.gitignore @@ -146,6 +146,9 @@ vite.config.ts.timestamp-* CLAUDE.md .DS_Store +# Cursor +.cursor/ + # Pnpm .pnpm-store/ result diff --git a/docs/cli.md b/docs/cli.md index fea903988..1989b3d94 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -163,7 +163,7 @@ openspec update ### `openspec list` -List changes or specs in your project. +List changes, specs, or archived changes in your project. ``` openspec list [options] @@ -175,9 +175,12 @@ openspec list [options] |--------|-------------| | `--specs` | List specs instead of changes | | `--changes` | List changes (default) | +| `--archive` | List archived changes | | `--sort ` | Sort by `recent` (default) or `name` | | `--json` | Output as JSON | +Only one mode applies per run. If you pass more than one of `--changes`, `--specs`, or `--archive`, precedence is: `--archive` over `--specs` over default (changes). With `--json`, the root key matches the mode: `{ "changes": [...] }`, `{ "specs": [...] }` (each item has `id`, `requirementCount`), or `{ "archivedChanges": [...] }` (same shape as changes: name, completedTasks, totalTasks, lastModified, status). + **Examples:** ```bash @@ -189,16 +192,27 @@ openspec list --specs # JSON output for scripts openspec list --json + +# List archived changes +openspec list --archive + +# JSON for specs (script/agent use) +openspec list --specs --json + +# JSON for archived changes +openspec list --archive --json ``` **Output (text):** ``` -Active changes: - add-dark-mode UI theme switching support - fix-login-bug Session timeout handling +Changes: + add-dark-mode 2/5 tasks 2h ago + fix-login-bug ✓ Complete 1d ago ``` +With `--archive`, the header is "Archived changes:" and rows show archived change names with task progress and last modified. + --- ### `openspec view` diff --git a/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/.openspec.yaml b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/.openspec.yaml new file mode 100644 index 000000000..e99c55a9a --- /dev/null +++ b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-13 diff --git a/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/design.md b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/design.md new file mode 100644 index 000000000..3b8743616 --- /dev/null +++ b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/design.md @@ -0,0 +1,68 @@ +# Design: Add Spec title and list JSON fields + +## Context + +The Spec type is defined in `src/core/schemas/spec.schema.ts` (name, overview, requirements, metadata). `MarkdownParser.parseSpec(name)` in `src/core/parsers/markdown-parser.ts` builds a Spec from spec.md: it requires `## Purpose` and `## Requirements`, maps Purpose content to `overview`, and passes through the `name` argument (spec id) as the spec’s `name`. The document’s first `# H1` is not currently parsed or stored. Multiple callers depend on the Spec shape: validation (SpecSchema.safeParse), list command (parseSpec for specs mode), view dashboard, deprecated spec show/list, and json-converter. Changing the schema or parser return shape can break any of these if not updated consistently. + +## Goals / Non-Goals + +**Goals:** + +- Add a required `title` field to the Spec type, set from the first `# H1` in the spec document, with fallback to the spec id (`name`) when H1 is missing or unparseable. +- Keep `openspec list --specs --json` output unchanged (each item: `id`, `requirementCount` only). Add a `--detail` flag so that when used (e.g. `openspec list --specs --json --detail`), each spec entry additionally includes `title` and `overview`, enabling LLMs and scripts to optionally discover and select specs without reading each file. +- Ensure all existing callers of SpecSchema and `parseSpec()` are identified, updated if they construct or consume Spec, and verified so that existing behavior is preserved and new behavior is consistent. + +**Non-Goals:** + +- Adding a separate `summary` field (overview remains the description). +- Changing required sections (Purpose, Requirements) or validation rules beyond accommodating `title`. +- Frontmatter or other overrides for title in this change. + +## Call-site audit (mandatory before implementation) + +Before changing the schema or parser, every consumer of the Spec type or `parseSpec()` must be checked and updated as needed so that existing functionality is not broken. + +| Location | Usage | Required change / check | +|----------|--------|---------------------------| +| `src/core/schemas/spec.schema.ts` | Defines Spec type | Add required `title: z.string().min(1, …)`. | +| `src/core/parsers/markdown-parser.ts` | `parseSpec(name): Spec` | Extract first level-1 heading; set `title` to that or `name`. Return `title` in the Spec object. | +| `src/core/validation/validator.ts` | `parser.parseSpec(specName)` then `SpecSchema.safeParse(spec)`; `applySpecRules(spec)` uses `spec.overview` | Parser will always return `title`. Validator only needs to pass through; confirm no code builds a Spec manually without `title`. | +| `src/core/list.ts` | `parser.parseSpec(id)`; uses `spec.requirements.length` for specs mode JSON | Default (no `--detail`): keep current shape (id, requirementCount only). When `options.json` and `options.detail` are both true, add `title: spec.title` and `overview: spec.overview` to each item in the `specs` array. CLI: add `--detail` option to list command. | +| `src/core/view.ts` | `parser.parseSpec(entry.name)`; uses `spec.requirements.length` | No change required for this change; optional later: show `spec.title` in dashboard. | +| `src/commands/spec.ts` | `parseSpecFromFile` → `parseSpec`; `show` uses `parsed.name` as `title`, `parsed.overview`; `list` uses `spec.name` as `title` | **show**: Use `parsed.title` for the output `title` field instead of `parsed.name`. **list**: Use `spec.title` instead of `spec.name` for the displayed/list title. | +| `src/core/converters/json-converter.ts` | `parser.parseSpec(specName)` then spreads `...spec` into JSON | No code change; once parser returns `title`, the converted JSON will include it. | + +**Implementation order:** (1) Schema + parser (so every Spec has `title`), (2) list.ts (add `--detail` option and conditional title/overview in specs JSON), (3) spec.ts (deprecated show/list use `title`), (4) run tests and any code that constructs Spec or validates with SpecSchema to confirm no regressions. + +## Decisions + +1. **Title required in schema** + `title` is a required field so that every Spec is guaranteed to have a display name. The parser is the single place that sets it (H1 or fallback to name), so no caller has to handle missing title. + +2. **H1 extraction in parser** + Reuse the existing section parse: the first top-level section with `level === 1` from `parseSections()` is the document title. Use its `title` property (the heading text). If there is no level-1 section, use the `name` argument. This avoids a separate first-line regex and keeps parsing in one place. + +3. **List JSON: default unchanged; `--detail` adds title and overview** + Default `openspec list --specs --json` is unchanged: each element in `specs` has only `id` and `requirementCount`. When `--detail` is passed (e.g. `openspec list --specs --json --detail`), each element additionally includes `title` and `overview`. Use `overview` (not a new name like `purpose`) so the field name matches the Spec type and existing `openspec spec show --json` output. + +4. **Deprecated spec list / show** + `openspec spec show --json` already outputs `title` and `overview`; switch the source of `title` from `parsed.name` to `parsed.title`. `openspec spec list` (and --long/--json) currently use `spec.name` as the display title; use `spec.title` so behavior aligns with the new model and with list --specs --json. + +## Risks / Trade-offs + +- **[Risk]** Existing tests or code that build a Spec object manually (e.g. mocks) may omit `title` and fail validation. + **Mitigation:** Grep for `Spec` construction and `SpecSchema.safeParse`/`parseSpec`; add `title` to any fixture or mock that returns a Spec. + +- **[Risk]** Specs with no `# H1` (only `## Purpose`, etc.) will get `title = name`; that may be less readable than a proper H1. + **Mitigation:** Acceptable; conventions can recommend adding an H1. No change to required sections. + +- **[Trade-off]** Parser does slightly more work (scan for first H1). Cost is one pass over already-parsed sections; negligible. + +## Migration Plan + +- No data migration. Existing spec.md files need no change; missing H1 is handled by fallback to name. +- After implementation: run full test suite; manually run `openspec list --specs --json` (confirm unchanged: id, requirementCount only), `openspec list --specs --json --detail` (confirm title and overview present), `openspec spec show --json`, and `openspec spec list --json` to confirm shape and that title/overview appear as expected where applicable. + +## Open Questions + +- None for this change. Optional follow-up: document in openspec-conventions that the first `# H1` is the canonical display title and is used in list/APIs. diff --git a/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/proposal.md b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/proposal.md new file mode 100644 index 000000000..285dca7d5 --- /dev/null +++ b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/proposal.md @@ -0,0 +1,27 @@ +## Why + +`openspec list --specs --json` currently returns only `id` and `requirementCount` per spec. LLMs and scripts sometimes need a human-readable title and a short description (e.g. from the spec document) to discover and select relevant specs without opening each spec file. The spec document already has a top-level heading (`# H1`) and a Purpose section (`## Purpose`). We should (1) add a required `title` to the Spec model and (2) allow list JSON to optionally include `title` and `overview` via a new flag, without changing the default list output. + +## What Changes + +- **Spec schema**: Add a required `title` field to the Spec type. When parsing a spec, set `title` from the document's first `# H1`; when that is missing or unparseable, fall back to the spec id (`name`). No new `summary` field — keep using `overview` (## Purpose) for description. +- **Parser**: In `MarkdownParser.parseSpec()` (or the code that builds the Spec object), extract the first level-1 heading and assign it to `title`; otherwise use the passed-in `name`. +- **List JSON**: Keep `openspec list --specs --json` unchanged: each spec entry continues to have only `id` and `requirementCount`. Add a new `--detail` flag; when used with `--specs --json` (e.g. `openspec list --specs --json --detail`), include `title` and `overview` in each spec entry so tooling can optionally get display name and Purpose text. +- **Deprecated `spec list`**: Align with the new model: `openspec spec list --long` and its JSON output should use the parsed `title` (from H1 or name) so behavior is consistent. + +## Capabilities + +### New Capabilities + +None. This change extends the existing spec model and list command only. + +### Modified Capabilities + +- **openspec-conventions**: Document that a spec's document title (first `# H1`) is the canonical display title and is exposed in list/APIs; when H1 is absent, the spec id is used. No change to required sections (Purpose, Requirements). +- **cli-list**: Extend the list command specification: (1) `openspec list --specs --json` remains unchanged (each item has `id`, `requirementCount` only). (2) Add a `--detail` option; when `openspec list --specs --json --detail` is used, each item in the `specs` array additionally includes `title` (string) and `overview` (string). + +## Impact + +- **Code**: `src/core/schemas/spec.schema.ts` (add required `title`), `src/core/parsers/markdown-parser.ts` (extract first H1, set title; fallback to name), `src/core/list.ts` (add `--detail`; when `--specs --json --detail`, include `title` and `overview` in each spec entry; default list --specs --json unchanged), `src/commands/spec.ts` (use parsed title in deprecated spec list when available). +- **Validation**: Ensure validators that construct or validate Spec objects supply `title`; existing specs without an explicit H1 will get `title = name` after the parser fallback. +- **Tests**: Update or add tests for parser (H1 → title, no H1 → name), list JSON without --detail (unchanged shape), list JSON with --detail (title, overview), and deprecated spec list output. diff --git a/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/specs/cli-list/spec.md b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/specs/cli-list/spec.md new file mode 100644 index 000000000..fac6fa299 --- /dev/null +++ b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/specs/cli-list/spec.md @@ -0,0 +1,60 @@ +## MODIFIED Requirements + +### Requirement: Output Format +The command SHALL display items in a clear, readable table format with mode-appropriate progress or counts when `--json` is not provided, or output JSON when `--json` is provided. + +#### Scenario: Displaying change list (default) +- **WHEN** displaying the list of changes without `--json` +- **THEN** show a table with columns: + - Change name (directory name) + - Task progress (e.g., "3/5 tasks" or "✓ Complete") + +#### Scenario: Displaying spec list +- **WHEN** displaying the list of specs without `--json` +- **THEN** show a table with columns: + - Spec id (directory name) + - Requirement count (e.g., "requirements 12") + +#### Scenario: JSON output for specs +- **WHEN** `openspec list --specs --json` is executed without `--detail` +- **THEN** output a JSON object with key `specs` and an array of objects with `id` and `requirementCount` only +- **AND** output `{ "specs": [] }` when no specs exist + +#### Scenario: JSON output for specs with detail +- **WHEN** `openspec list --specs --json --detail` is executed +- **THEN** output a JSON object with key `specs` and an array of objects with `id`, `requirementCount`, `title`, and `overview` +- **AND** `title` SHALL be the spec's display title (from document H1 or spec id) +- **AND** `overview` SHALL be the spec's Purpose section content +- **AND** output `{ "specs": [] }` when no specs exist + +#### Scenario: Displaying archive list +- **WHEN** displaying the list of archived changes without `--json` +- **THEN** show a table with columns: archived change name (directory name), task progress, and last modified (e.g. relative time) + +#### Scenario: JSON output for archive +- **WHEN** `openspec list --archive --json` is executed +- **THEN** output a JSON object with key `archivedChanges` and an array of objects with `name`, `completedTasks`, `totalTasks`, `lastModified` (ISO string), and `status` + +### Requirement: Flags +The command SHALL accept flags to select the noun being listed. When more than one of `--changes`, `--specs`, or `--archive` is provided, the effective mode SHALL be determined by precedence: `--archive` overrides `--specs`, `--specs` overrides default (changes). The command SHALL accept a `--detail` flag that, when used with `--specs --json`, causes each spec entry to include `title` and `overview`. + +#### Scenario: Selecting specs +- **WHEN** `--specs` is provided +- **THEN** list specs instead of changes + +#### Scenario: Selecting changes +- **WHEN** `--changes` is provided +- **THEN** list changes explicitly (same as default behavior) + +#### Scenario: Selecting archive +- **WHEN** `--archive` is provided +- **THEN** list archived changes (directories under openspec/changes/archive/) + +#### Scenario: Mode precedence +- **WHEN** more than one of `--changes`, `--specs`, or `--archive` is provided +- **THEN** the effective mode SHALL be determined by precedence: `--archive` overrides `--specs`, `--specs` overrides default (changes) + +#### Scenario: Requesting detail for spec list JSON +- **WHEN** `--detail` is provided together with `--specs --json` +- **THEN** each object in the `specs` array SHALL include `title` and `overview` in addition to `id` and `requirementCount` +- **AND** when `--detail` is omitted, spec list JSON SHALL remain unchanged (id and requirementCount only) diff --git a/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/specs/openspec-conventions/spec.md b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/specs/openspec-conventions/spec.md new file mode 100644 index 000000000..56889b53c --- /dev/null +++ b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/specs/openspec-conventions/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Spec document title as canonical display title + +A spec's document title SHALL be the first level-1 heading (`# H1`) in the spec file. This title SHALL be the canonical display title for the spec and SHALL be exposed in list output and APIs (e.g. when listing specs with a detail option). When the document has no level-1 heading or it cannot be parsed, the spec id (directory name) SHALL be used as the display title. Required sections (Purpose, Requirements) SHALL remain unchanged. + +#### Scenario: Document with H1 + +- **WHEN** a spec file has a first level-1 heading (e.g. `# List Command Specification`) +- **THEN** that heading text SHALL be used as the spec's display title +- **AND** tooling that exposes spec titles (e.g. list with detail) SHALL show this title + +#### Scenario: Document without H1 + +- **WHEN** a spec file has no level-1 heading (e.g. only `## Purpose`, `## Requirements`) +- **THEN** the spec id (capability directory name) SHALL be used as the display title +- **AND** tooling that exposes spec titles SHALL show the spec id diff --git a/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/tasks.md b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/tasks.md new file mode 100644 index 000000000..b3a0fe446 --- /dev/null +++ b/openspec/changes/archive/2025-02-12-add-spec-title-and-list-json-fields/tasks.md @@ -0,0 +1,22 @@ +## 1. Schema and parser + +- [x] 1.1 Add required `title` field to Spec type in `src/core/schemas/spec.schema.ts` +- [x] 1.2 In `markdown-parser.parseSpec()`, extract first level-1 heading and set `title`; fallback to `name` when H1 is missing or unparseable +- [x] 1.3 Add `title` to any fixtures or mocks that construct a Spec (grep for SpecSchema.safeParse / parseSpec / Spec construction) + +## 2. List command and --detail + +- [x] 2.1 Add `--detail` option to list command (CLI flag and options type passed to list) +- [x] 2.2 In `src/core/list.ts` specs-mode JSON: when `options.json` and `options.detail` are true, include `title` and `overview` in each spec entry; when `--detail` is omitted, keep output shape unchanged (id, requirementCount only) + +## 3. Deprecated spec command + +- [x] 3.1 In `openspec spec show`: use `parsed.title` for the output title field instead of `parsed.name` +- [x] 3.2 In `openspec spec list` (and --long/--json): use `spec.title` for the displayed/list title instead of `spec.name` + +## 4. Tests and verification + +- [x] 4.1 Add or update parser tests: document with H1 → title set from heading; document without H1 → title equals name +- [x] 4.2 Add or update list tests: `openspec list --specs --json` output shape unchanged (id, requirementCount only); `openspec list --specs --json --detail` includes title and overview per spec +- [x] 4.3 Update or add tests for deprecated spec list/show to assert title comes from parsed title +- [x] 4.4 Run full test suite; manually run `openspec list --specs --json`, `openspec list --specs --json --detail`, `openspec spec show --json`, and `openspec spec list --json` to confirm shapes and title/overview where applicable diff --git a/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/.openspec.yaml b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/.openspec.yaml new file mode 100644 index 000000000..95d284aff --- /dev/null +++ b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-12 diff --git a/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/design.md b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/design.md new file mode 100644 index 000000000..30f8b2838 --- /dev/null +++ b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/design.md @@ -0,0 +1,40 @@ +## Context + +The `openspec list` command lives in `src/core/list.ts` and is invoked from `src/cli/index.ts`. It currently supports two modes: `changes` (default) and `specs`. The CLI passes a single `mode` and `options` (sort, json). For changes, the implementation scans `openspec/changes/`, excludes the `archive` subdirectory, collects task progress and last-modified times, and either prints a table or JSON `{ changes: [...] }`. For specs, it scans `openspec/specs/`, parses each `spec.md` for requirement count, and always prints a human-readable table—the `json` option is never checked in the specs branch. Archived changes live under `openspec/changes/archive/` as dated directories (e.g. `2025-01-13-add-list-command`); there is no CLI surface to list them. All path operations use `path.join` / Node `path` for cross-platform behavior. + +## Goals / Non-Goals + +**Goals:** + +- In specs mode, when `--json` is set, output a single JSON object with key `specs` and an array of `{ id, requirementCount }` (empty array when no specs). +- Add a third mode, archive, so that `--archive` lists directories under `openspec/changes/archive/`, with the same `--sort` and `--json` behavior as active changes. +- Keep JSON shape for archive consistent with active changes where applicable (e.g. `name`, `lastModified`, task counts, `status`) under a root key `archivedChanges`. +- Enforce a single effective mode per run (changes vs specs vs archive); define precedence or reject conflicting flags. + +**Non-Goals:** + +- Changing the layout of `openspec/changes/archive/` or adding new directories. +- Integrating archive list into the `view` dashboard or other commands. +- Adding new list output formats (e.g. CSV or custom columns). + +## Decisions + +1. **Mode type and precedence** + Extend the list mode from `'changes' | 'specs'` to `'changes' | 'specs' | 'archive'`. When the user passes multiple of `--changes`, `--specs`, `--archive`, use a fixed precedence so one mode wins (e.g. `--archive` > `--specs` > default changes). This avoids a breaking “error on conflict” and keeps the CLI simple. **Alternative considered**: Reject multiple flags with an error; we prefer precedence for consistency with other tools and fewer user-facing errors. + +2. **Specs JSON shape** + Output `{ "specs": [ { "id": "", "requirementCount": number } ] }`. Reuse the existing in-memory `SpecInfo`-like structure; only add a branch that, when `options.json` is true, serializes that array under the key `specs` and returns (no table). Empty list: `{ "specs": [] }`. No new fields (e.g. no lastModified for specs in this change). **Alternative**: Add optional fields (e.g. spec title from frontmatter); deferred to keep scope minimal. + +3. **Archive data source and JSON shape** + Scan only `path.join(targetPath, 'openspec', 'changes', 'archive')` for direct child directories. Reuse `getTaskProgressForChange` and `getLastModified` so each archived change has the same fields as active changes: `name`, `completedTasks`, `totalTasks`, `lastModified` (ISO string in JSON), and derived `status`. Root key: `archivedChanges`. This allows scripts to handle active and archived lists with the same schema. **Alternative**: Minimal archive (name only); we chose parity with changes for consistency and future scripting. + +4. **Empty and error behavior for archive** + If `openspec/changes/` or `openspec/changes/archive/` is missing, treat as “no archived changes” and output empty list / “No archived changes found.” (no exit code 1). Only fail hard when the project is not an OpenSpec root (e.g. no `openspec/changes/` at all), matching current list behavior for changes. **Alternative**: Fail if archive directory is missing; we prefer not to require archive to exist. + +5. **Path handling** + All new and touched paths use `path.join()` (or equivalent) and the Node `path` module; no hardcoded slashes. Tests that assert paths use `path.join` for expected values to stay cross-platform. + +## Risks / Trade-offs + +- **[Risk]** Multiple list-mode flags (e.g. `--specs --archive`) might surprise users. **[Mitigation]** Document precedence in help and in `openspec/specs/cli-list/spec.md`; keep precedence simple and stable (e.g. archive > specs > changes). +- **[Trade-off]** Archive mode reuses task counting and lastModified; archived changes with broken or missing `tasks.md` will show as “no tasks” or similar, which is acceptable and consistent with active changes. diff --git a/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/proposal.md b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/proposal.md new file mode 100644 index 000000000..858a243b5 --- /dev/null +++ b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/proposal.md @@ -0,0 +1,25 @@ +## Why + +`openspec list --json` correctly returns structured JSON for active changes, but `openspec list --specs --json` still prints human-readable table output because the specs branch of the list command never checks the `--json` flag. Scripts and tooling that expect consistent JSON from list cannot use specs listing. Separately, there is no CLI way to list archived changes; users must inspect `openspec/changes/archive/` manually. + +## What Changes + +- **Specs mode with JSON**: When `openspec list --specs --json` is run, output a JSON object with a `specs` array (e.g. `{ "specs": [ { "id": "...", "requirementCount": n } ] }`) instead of the "Specs:" table. Empty state: `{ "specs": [] }` when no specs exist. +- **Archive mode**: Add a `--archive` flag to `openspec list` so that listing shows archived changes (directories under `openspec/changes/archive/`). Support the same `--sort` (recent | name) and `--json` options for archive mode. When `--json` is used with `--archive`, output e.g. `{ "archivedChanges": [ ... ] }` with fields consistent with active changes where applicable (name, lastModified, task counts if available). +- **Mode mutual exclusion**: Only one of default (changes), `--specs`, or `--archive` applies per run. Conflicting flags (e.g. `--specs` and `--archive`) can be rejected or resolved by a defined precedence. + +## Capabilities + +### New Capabilities + +None. This change only extends the existing list command. + +### Modified Capabilities + +- **cli-list**: Extend the list command specification to require JSON output when `--json` is used in specs mode (output shape and empty state), and to add an archive listing mode when `--archive` is provided (source directory, output format, and interaction with `--sort` and `--json`). + +## Impact + +- **Code**: `src/core/list.ts` (specs branch: add JSON branch; new archive mode and JSON/sort handling), `src/cli/index.ts` (add `--archive` option and pass mode to ListCommand). +- **Spec**: `openspec/specs/cli-list/spec.md` (new/updated requirements and scenarios for JSON output in specs mode and for archive mode). +- **Tests**: Add or extend tests for `openspec list --specs --json` output shape and for `openspec list --archive` / `openspec list --archive --json` (and sort behavior as needed). diff --git a/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/specs/cli-list/spec.md b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/specs/cli-list/spec.md new file mode 100644 index 000000000..2813d6794 --- /dev/null +++ b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/specs/cli-list/spec.md @@ -0,0 +1,146 @@ +# List Command Specification (Delta) + +This delta extends the list command with JSON output for specs mode and an archive listing mode. + +## ADDED Requirements + +### Requirement: JSON output for specs mode + +When `--json` is provided in specs mode, the command SHALL output a single JSON object to stdout with root key `specs` and an array of objects, each with `id` (string) and `requirementCount` (number). When no specs exist, the command SHALL output `{ "specs": [] }`. + +#### Scenario: JSON output in specs mode + +- **WHEN** `openspec list --specs --json` is executed +- **THEN** output a JSON object with key `specs` and an array of objects each with `id` and `requirementCount` +- **AND** use path.join (or equivalent) when resolving spec directories so behavior is correct on Windows, macOS, and Linux + +#### Scenario: JSON output when no specs exist + +- **WHEN** `openspec list --specs --json` is executed and no specs exist +- **THEN** output exactly `{ "specs": [] }` + +### Requirement: Archive listing mode + +The command SHALL support a third mode, archive, so that listing shows archived changes (directories under `openspec/changes/archive/` on the target path). Archive mode SHALL support the same `--sort` and `--json` options as changes mode. + +#### Scenario: Scanning for archived changes + +- **WHEN** `openspec list --archive` is executed +- **THEN** scan the archive directory (constructed with path.join for the platform) for direct child directories +- **AND** list each directory as an archived change +- **AND** parse each change's tasks.md and last-modified time when producing output + +#### Scenario: Archive mode with JSON + +- **WHEN** `openspec list --archive --json` is executed +- **THEN** output a JSON object with key `archivedChanges` and an array of objects with `name`, `completedTasks`, `totalTasks`, `lastModified` (ISO 8601 string), and `status` +- **AND** when no archived changes exist, output `{ "archivedChanges": [] }` + +#### Scenario: Archive empty or missing directory + +- **WHEN** `openspec list --archive` is executed and the archive directory is missing or contains no subdirectories +- **THEN** display "No archived changes found." (without --json) or output `{ "archivedChanges": [] }` (with --json) +- **AND** exit with code 0 + +### Requirement: Mode precedence + +When more than one of `--changes`, `--specs`, or `--archive` is provided, the command SHALL use a single effective mode according to fixed precedence: `--archive` overrides `--specs`, and `--specs` overrides default (changes). + +#### Scenario: Precedence when multiple flags given + +- **WHEN** the user passes both `--specs` and `--archive` +- **THEN** the effective mode SHALL be archive + +## MODIFIED Requirements + +### Requirement: Flags + +The command SHALL accept flags to select the noun being listed. + +#### Scenario: Selecting specs + +- **WHEN** `--specs` is provided +- **THEN** list specs instead of changes + +#### Scenario: Selecting changes + +- **WHEN** `--changes` is provided +- **THEN** list changes explicitly (same as default behavior) + +#### Scenario: Selecting archive + +- **WHEN** `--archive` is provided +- **THEN** list archived changes (directories under openspec/changes/archive/) + +#### Scenario: Mode precedence + +- **WHEN** more than one of `--changes`, `--specs`, or `--archive` is provided +- **THEN** the effective mode SHALL be determined by precedence: `--archive` overrides `--specs`, `--specs` overrides default (changes) + +### Requirement: Output Format + +The command SHALL display items in a clear, readable table format with mode-appropriate progress or counts when --json is not provided, or output JSON when --json is provided. + +#### Scenario: Displaying change list (default) + +- **WHEN** displaying the list of changes without --json +- **THEN** show a table with columns: + - Change name (directory name) + - Task progress (e.g., "3/5 tasks" or "✓ Complete") + +#### Scenario: Displaying spec list + +- **WHEN** displaying the list of specs without --json +- **THEN** show a table with columns: + - Spec id (directory name) + - Requirement count (e.g., "requirements 12") + +#### Scenario: JSON output for specs + +- **WHEN** `openspec list --specs --json` is executed +- **THEN** output a JSON object with key `specs` and an array of objects with `id` and `requirementCount` +- **AND** output `{ "specs": [] }` when no specs exist + +#### Scenario: Displaying archive list + +- **WHEN** displaying the list of archived changes without --json +- **THEN** show a table with columns: archived change name (directory name), task progress, and last modified (e.g. relative time) + +#### Scenario: JSON output for archive + +- **WHEN** `openspec list --archive --json` is executed +- **THEN** output a JSON object with key `archivedChanges` and an array of objects with `name`, `completedTasks`, `totalTasks`, `lastModified` (ISO string), and `status` + +### Requirement: Empty State + +The command SHALL provide clear feedback when no items are present for the selected mode. + +#### Scenario: Handling empty state (changes) + +- **WHEN** no active changes exist (only archive/ or empty changes/) +- **THEN** display: "No active changes found." + +#### Scenario: Handling empty state (specs) + +- **WHEN** no specs directory exists or contains no capabilities +- **THEN** display: "No specs found." + +#### Scenario: Handling empty state (archive) + +- **WHEN** `openspec list --archive` is executed and the archive directory is missing or has no subdirectories +- **THEN** display: "No archived changes found." (without --json) or output `{ "archivedChanges": [] }` (with --json) +- **AND** exit with code 0 + +### Requirement: Sorting + +The command SHALL maintain consistent ordering for predictable output. For changes and archive mode, order SHALL follow the `--sort` option (default "recent": by last modified descending; "name": alphabetical by name). For specs mode, order SHALL be alphabetical by spec id. + +#### Scenario: Ordering changes + +- **WHEN** displaying multiple changes +- **THEN** sort them according to --sort: "recent" (default) by last modified descending, or "name" by change name alphabetical + +#### Scenario: Ordering archived changes + +- **WHEN** displaying multiple archived changes +- **THEN** sort them according to --sort: "recent" (default) or "name", same as for active changes diff --git a/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/tasks.md b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/tasks.md new file mode 100644 index 000000000..f72a80b48 --- /dev/null +++ b/openspec/changes/archive/2026-02-12-add-list-specs-json-and-archive/tasks.md @@ -0,0 +1,25 @@ +## 1. CLI and mode + +- [x] 1.1 Add `--archive` option to `openspec list` in `src/cli/index.ts` and document in description/help +- [x] 1.2 Compute effective mode with precedence (archive > specs > changes) and pass mode `'changes' | 'specs' | 'archive'` to ListCommand + +## 2. ListCommand: specs JSON + +- [x] 2.1 In `src/core/list.ts` specs branch, when `options.json` is true, output `{ "specs": [ { "id", "requirementCount" } ] }` and return; output `{ "specs": [] }` when no specs + +## 3. ListCommand: archive mode + +- [x] 3.1 Extend ListCommand.execute mode type to `'changes' | 'specs' | 'archive'` +- [x] 3.2 Implement archive branch: scan `path.join(targetPath, 'openspec', 'changes', 'archive')` for direct child directories; reuse getTaskProgressForChange and getLastModified for each +- [x] 3.3 Apply --sort (recent | name) to archive list and output human-readable table when not --json +- [x] 3.4 When options.json in archive mode, output `{ "archivedChanges": [ ... ] }` with name, completedTasks, totalTasks, lastModified (ISO), status; empty list when archive missing or empty, exit 0 + +## 4. Spec and docs + +- [x] 4.1 Update `openspec/specs/cli-list/spec.md` with new requirements and scenarios from this change delta (JSON for specs, --archive, precedence, empty archive) + +## 5. Tests + +- [x] 5.1 Add or extend tests for `openspec list --specs --json`: output shape, `specs` array elements have id and requirementCount, empty `{ "specs": [] }` when no specs +- [x] 5.2 Add tests for `openspec list --archive` and `openspec list --archive --json`: output shape, archivedChanges array, empty state; use path.join for expected paths in assertions +- [x] 5.3 Add test for mode precedence (e.g. --specs --archive results in archive list) diff --git a/openspec/specs/cli-list/spec.md b/openspec/specs/cli-list/spec.md index b11ab4921..825a443d9 100644 --- a/openspec/specs/cli-list/spec.md +++ b/openspec/specs/cli-list/spec.md @@ -2,13 +2,13 @@ ## Purpose -The `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status. +The `openspec list` command SHALL provide developers with a quick overview of all active changes, specs, or archived changes in the project, showing names and task/requirement completion as appropriate. ## Requirements ### Requirement: Command Execution -The command SHALL scan and analyze either active changes or specs based on the selected mode. +The command SHALL scan and analyze active changes, specs, or archived changes based on the selected mode. #### Scenario: Scanning for changes (default) -- **WHEN** `openspec list` is executed without flags +- **WHEN** `openspec list` is executed without flags (or with `--changes`) - **THEN** scan the `openspec/changes/` directory for change directories - **AND** exclude the `archive/` subdirectory from results - **AND** parse each change's `tasks.md` file to count task completion @@ -19,6 +19,11 @@ The command SHALL scan and analyze either active changes or specs based on the s - **AND** read each capability's `spec.md` - **AND** parse requirements to compute requirement counts +#### Scenario: Scanning for archived changes +- **WHEN** `openspec list --archive` is executed +- **THEN** scan the archive directory (`openspec/changes/archive/` on the target path, using path.join for the platform) for direct child directories +- **AND** list each directory as an archived change and parse tasks.md and last-modified time when producing output + ### Requirement: Task Counting The command SHALL accurately count task completion status using standard markdown checkbox patterns. @@ -32,22 +37,42 @@ The command SHALL accurately count task completion status using standard markdow - **AND** calculate total tasks as the sum of completed and incomplete ### Requirement: Output Format -The command SHALL display items in a clear, readable table format with mode-appropriate progress or counts. +The command SHALL display items in a clear, readable table format with mode-appropriate progress or counts when `--json` is not provided, or output JSON when `--json` is provided. #### Scenario: Displaying change list (default) -- **WHEN** displaying the list of changes +- **WHEN** displaying the list of changes without `--json` - **THEN** show a table with columns: - Change name (directory name) - Task progress (e.g., "3/5 tasks" or "✓ Complete") #### Scenario: Displaying spec list -- **WHEN** displaying the list of specs +- **WHEN** displaying the list of specs without `--json` - **THEN** show a table with columns: - Spec id (directory name) - Requirement count (e.g., "requirements 12") +#### Scenario: JSON output for specs +- **WHEN** `openspec list --specs --json` is executed without `--detail` +- **THEN** output a JSON object with key `specs` and an array of objects with `id` and `requirementCount` only +- **AND** output `{ "specs": [] }` when no specs exist + +#### Scenario: JSON output for specs with detail +- **WHEN** `openspec list --specs --json --detail` is executed +- **THEN** output a JSON object with key `specs` and an array of objects with `id`, `requirementCount`, `title`, and `overview` +- **AND** `title` SHALL be the spec's display title (from document H1 or spec id) +- **AND** `overview` SHALL be the spec's Purpose section content +- **AND** output `{ "specs": [] }` when no specs exist + +#### Scenario: Displaying archive list +- **WHEN** displaying the list of archived changes without `--json` +- **THEN** show a table with columns: archived change name (directory name), task progress, and last modified (e.g. relative time) + +#### Scenario: JSON output for archive +- **WHEN** `openspec list --archive --json` is executed +- **THEN** output a JSON object with key `archivedChanges` and an array of objects with `name`, `completedTasks`, `totalTasks`, `lastModified` (ISO string), and `status` + ### Requirement: Flags -The command SHALL accept flags to select the noun being listed. +The command SHALL accept flags to select the noun being listed. When more than one of `--changes`, `--specs`, or `--archive` is provided, the effective mode SHALL be determined by precedence: `--archive` overrides `--specs`, `--specs` overrides default (changes). The command SHALL accept a `--detail` flag that, when used with `--specs --json`, causes each spec entry to include `title` and `overview`. #### Scenario: Selecting specs - **WHEN** `--specs` is provided @@ -57,6 +82,19 @@ The command SHALL accept flags to select the noun being listed. - **WHEN** `--changes` is provided - **THEN** list changes explicitly (same as default behavior) +#### Scenario: Selecting archive +- **WHEN** `--archive` is provided +- **THEN** list archived changes (directories under openspec/changes/archive/) + +#### Scenario: Mode precedence +- **WHEN** more than one of `--changes`, `--specs`, or `--archive` is provided +- **THEN** the effective mode SHALL be determined by precedence: `--archive` overrides `--specs`, `--specs` overrides default (changes) + +#### Scenario: Requesting detail for spec list JSON +- **WHEN** `--detail` is provided together with `--specs --json` +- **THEN** each object in the `specs` array SHALL include `title` and `overview` in addition to `id` and `requirementCount` +- **AND** when `--detail` is omitted, spec list JSON SHALL remain unchanged (id and requirementCount only) + ### Requirement: Empty State The command SHALL provide clear feedback when no items are present for the selected mode. @@ -66,7 +104,12 @@ The command SHALL provide clear feedback when no items are present for the selec #### Scenario: Handling empty state (specs) - **WHEN** no specs directory exists or contains no capabilities -- **THEN** display: "No specs found." +- **THEN** display: "No specs found." (or output `{ "specs": [] }` with `--json`) + +#### Scenario: Handling empty state (archive) +- **WHEN** `openspec list --archive` is executed and the archive directory is missing or has no subdirectories +- **THEN** display: "No archived changes found." (without `--json`) or output `{ "archivedChanges": [] }` (with `--json`) +- **AND** exit with code 0 ### Requirement: Error Handling @@ -85,12 +128,17 @@ The command SHALL gracefully handle missing files and directories with appropria ### Requirement: Sorting -The command SHALL maintain consistent ordering of changes for predictable output. +The command SHALL maintain consistent ordering for predictable output. For changes and archive mode, order SHALL follow the `--sort` option (default "recent": by last modified descending; "name": alphabetical by name). For specs mode, order SHALL be alphabetical by spec id. #### Scenario: Ordering changes - **WHEN** displaying multiple changes -- **THEN** sort them in alphabetical order by change name +- **THEN** sort them according to `--sort`: "recent" (default) by last modified descending, or "name" by change name alphabetical + +#### Scenario: Ordering archived changes + +- **WHEN** displaying multiple archived changes +- **THEN** sort them according to `--sort`: "recent" (default) or "name", same as for active changes ## Why diff --git a/openspec/specs/openspec-conventions/spec.md b/openspec/specs/openspec-conventions/spec.md index 52974366f..6019764e0 100644 --- a/openspec/specs/openspec-conventions/spec.md +++ b/openspec/specs/openspec-conventions/spec.md @@ -245,6 +245,22 @@ OpenSpec CLI design SHALL use verbs as top-level commands with nouns provided as - **THEN** `openspec show` and `openspec validate` SHALL accept `--type spec|change` - **AND** the help text SHALL document this clearly +### Requirement: Spec document title as canonical display title + +A spec's document title SHALL be the first level-1 heading (`# H1`) in the spec file. This title SHALL be the canonical display title for the spec and SHALL be exposed in list output and APIs (e.g. when listing specs with a detail option). When the document has no level-1 heading or it cannot be parsed, the spec id (directory name) SHALL be used as the display title. Required sections (Purpose, Requirements) SHALL remain unchanged. + +#### Scenario: Document with H1 + +- **WHEN** a spec file has a first level-1 heading (e.g. `# List Command Specification`) +- **THEN** that heading text SHALL be used as the spec's display title +- **AND** tooling that exposes spec titles (e.g. list with detail) SHALL show this title + +#### Scenario: Document without H1 + +- **WHEN** a spec file has no level-1 heading (e.g. only `## Purpose`, `## Requirements`) +- **THEN** the spec id (capability directory name) SHALL be used as the display title +- **AND** tooling that exposes spec titles SHALL show the spec id + ## Core Principles The system SHALL follow these principles: diff --git a/src/cli/index.ts b/src/cli/index.ts index 8947736f7..0d1ce4e54 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -171,17 +171,21 @@ program program .command('list') - .description('List items (changes by default). Use --specs to list specs.') + .description('List items (changes by default). Use --specs to list specs, --archive to list archived changes.') .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') + .option('--archive', 'List archived changes') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => { + .option('--detail', 'With --specs --json: include title and overview in each spec entry') + .action(async (options?: { specs?: boolean; changes?: boolean; archive?: boolean; sort?: string; json?: boolean; detail?: boolean }) => { try { const listCommand = new ListCommand(); - const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes'; + // Precedence: archive > specs > changes + const mode: 'changes' | 'specs' | 'archive' = + options?.archive ? 'archive' : options?.specs ? 'specs' : 'changes'; const sort = options?.sort === 'name' ? 'name' : 'recent'; - await listCommand.execute('.', mode, { sort, json: options?.json }); + await listCommand.execute('.', mode, { sort, json: options?.json, detail: options?.detail }); } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d28052f14..cee4a7e38 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -49,6 +49,7 @@ function filterSpec(spec: Spec, options: ShowOptions): Spec { return { name: spec.name, + title: spec.title, overview: spec.overview, requirements: filteredRequirements, metadata, @@ -95,7 +96,7 @@ export class SpecCommand { const filtered = filterSpec(parsed, options); const output = { id: specId, - title: parsed.name, + title: parsed.title, overview: parsed.overview, requirementCount: filtered.requirements.length, requirements: filtered.requirements, @@ -158,7 +159,7 @@ export function registerSpecCommand(rootProgram: typeof program) { return { id: dirent.name, - title: spec.name, + title: spec.title, requirementCount: spec.requirements.length }; } catch { diff --git a/src/core/list.ts b/src/core/list.ts index 3f40829a6..634dba17b 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -15,6 +15,7 @@ interface ChangeInfo { interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; + detail?: boolean; } /** @@ -75,8 +76,8 @@ function formatRelativeTime(date: Date): string { } export class ListCommand { - async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { - const { sort = 'recent', json = false } = options; + async execute(targetPath: string = '.', mode: 'changes' | 'specs' | 'archive' = 'changes', options: ListOptions = {}): Promise { + const { sort = 'recent', json = false, detail = false } = options; if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); @@ -151,23 +152,31 @@ export class ListCommand { return; } - // specs mode - const specsDir = path.join(targetPath, 'openspec', 'specs'); + if (mode === 'specs') { + const specsDir = path.join(targetPath, 'openspec', 'specs'); try { await fs.access(specsDir); } catch { - console.log('No specs found.'); + if (json) { + console.log(JSON.stringify({ specs: [] }, null, 2)); + } else { + console.log('No specs found.'); + } return; } const entries = await fs.readdir(specsDir, { withFileTypes: true }); const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); if (specDirs.length === 0) { - console.log('No specs found.'); + if (json) { + console.log(JSON.stringify({ specs: [] }, null, 2)); + } else { + console.log('No specs found.'); + } return; } - type SpecInfo = { id: string; requirementCount: number }; + type SpecInfo = { id: string; requirementCount: number; title?: string; overview?: string }; const specs: SpecInfo[] = []; for (const id of specDirs) { const specPath = join(specsDir, id, 'spec.md'); @@ -175,7 +184,12 @@ export class ListCommand { const content = readFileSync(specPath, 'utf-8'); const parser = new MarkdownParser(content); const spec = parser.parseSpec(id); - specs.push({ id, requirementCount: spec.requirements.length }); + const entry: SpecInfo = { id, requirementCount: spec.requirements.length }; + if (json && detail) { + entry.title = spec.title; + entry.overview = spec.overview; + } + specs.push(entry); } catch { // If spec cannot be read or parsed, include with 0 count specs.push({ id, requirementCount: 0 }); @@ -183,6 +197,10 @@ export class ListCommand { } specs.sort((a, b) => a.id.localeCompare(b.id)); + if (json) { + console.log(JSON.stringify({ specs }, null, 2)); + return; + } console.log('Specs:'); const padding = ' '; const nameWidth = Math.max(...specs.map(s => s.id.length)); @@ -190,5 +208,72 @@ export class ListCommand { const padded = spec.id.padEnd(nameWidth); console.log(`${padding}${padded} requirements ${spec.requirementCount}`); } + return; + } + + // archive mode + const archiveDir = path.join(targetPath, 'openspec', 'changes', 'archive'); + try { + await fs.access(archiveDir); + } catch { + if (json) { + console.log(JSON.stringify({ archivedChanges: [] }, null, 2)); + } else { + console.log('No archived changes found.'); + } + return; + } + + const entries = await fs.readdir(archiveDir, { withFileTypes: true }); + const archiveDirs = entries.filter(e => e.isDirectory()).map(e => e.name); + if (archiveDirs.length === 0) { + if (json) { + console.log(JSON.stringify({ archivedChanges: [] }, null, 2)); + } else { + console.log('No archived changes found.'); + } + return; + } + + const changes: ChangeInfo[] = []; + for (const changeDir of archiveDirs) { + const progress = await getTaskProgressForChange(archiveDir, changeDir); + const changePath = path.join(archiveDir, changeDir); + const lastModified = await getLastModified(changePath); + changes.push({ + name: changeDir, + completedTasks: progress.completed, + totalTasks: progress.total, + lastModified + }); + } + + if (sort === 'recent') { + changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + } else { + changes.sort((a, b) => a.name.localeCompare(b.name)); + } + + if (json) { + const jsonOutput = changes.map(c => ({ + name: c.name, + completedTasks: c.completedTasks, + totalTasks: c.totalTasks, + lastModified: c.lastModified.toISOString(), + status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' + })); + console.log(JSON.stringify({ archivedChanges: jsonOutput }, null, 2)); + return; + } + + console.log('Archived changes:'); + const padding = ' '; + const nameWidth = Math.max(...changes.map(c => c.name.length)); + for (const change of changes) { + const paddedName = change.name.padEnd(nameWidth); + const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); + const timeAgo = formatRelativeTime(change.lastModified); + console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`); + } } } \ No newline at end of file diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index 8bd59d1ae..bc3282b5d 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -23,6 +23,8 @@ export class MarkdownParser { parseSpec(name: string): Spec { const sections = this.parseSections(); + const titleSection = sections.find(s => s.level === 1); + const title = titleSection?.title?.trim() || name; const purpose = this.findSection(sections, 'Purpose')?.content || ''; const requirementsSection = this.findSection(sections, 'Requirements'); @@ -39,6 +41,7 @@ export class MarkdownParser { return { name, + title, overview: purpose.trim(), requirements, metadata: { diff --git a/src/core/schemas/spec.schema.ts b/src/core/schemas/spec.schema.ts index b9ed3d5ae..48c0db159 100644 --- a/src/core/schemas/spec.schema.ts +++ b/src/core/schemas/spec.schema.ts @@ -4,6 +4,7 @@ import { VALIDATION_MESSAGES } from '../validation/constants.js'; export const SpecSchema = z.object({ name: z.string().min(1, VALIDATION_MESSAGES.SPEC_NAME_EMPTY), + title: z.string().min(1, VALIDATION_MESSAGES.SPEC_TITLE_EMPTY), overview: z.string().min(1, VALIDATION_MESSAGES.SPEC_PURPOSE_EMPTY), requirements: z.array(RequirementSchema) .min(1, VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS), diff --git a/src/core/validation/constants.ts b/src/core/validation/constants.ts index a6cf0de60..c9fe89a17 100644 --- a/src/core/validation/constants.ts +++ b/src/core/validation/constants.ts @@ -19,6 +19,7 @@ export const VALIDATION_MESSAGES = { REQUIREMENT_NO_SHALL: 'Requirement must contain SHALL or MUST keyword', REQUIREMENT_NO_SCENARIOS: 'Requirement must have at least one scenario', SPEC_NAME_EMPTY: 'Spec name cannot be empty', + SPEC_TITLE_EMPTY: 'Spec title cannot be empty', SPEC_PURPOSE_EMPTY: 'Purpose section cannot be empty', SPEC_NO_REQUIREMENTS: 'Spec must have at least one requirement', CHANGE_NAME_EMPTY: 'Change name cannot be empty', diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index b8f90fabe..a0a11b448 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -81,6 +81,7 @@ The system SHALL process credit card payments securely`; const json = JSON.parse(output); expect(json.id).toBe('auth'); + // Test spec has no # H1, so title falls back to spec id expect(json.title).toBe('auth'); expect(json.overview).toContain('test specification'); expect(json.requirements).toHaveLength(2); @@ -90,6 +91,18 @@ The system SHALL process credit card payments securely`; } }); + it('should output title from parsed spec (spec show --json)', async () => { + // auth spec has no # H1, so title falls back to id; parser unit tests cover H1 vs fallback + const output = execSync(`node ${openspecBin} spec show auth --json`, { + encoding: 'utf-8', + cwd: testDir + }); + const json = JSON.parse(output); + expect(json).toHaveProperty('title'); + expect(json.title).toBe('auth'); + expect(json.id).toBe('auth'); + }); + it('should filter to show only requirements with --requirements flag (JSON only)', () => { const originalCwd = process.cwd(); try { @@ -187,6 +200,9 @@ The system SHALL process credit card payments securely`; expect(json.find((s: any) => s.id === 'auth')).toBeDefined(); expect(json.find((s: any) => s.id === 'payment')).toBeDefined(); expect(json[0].requirementCount).toBeDefined(); + // Title comes from parsed spec (H1 or fallback to id) + expect(json.find((s: any) => s.id === 'auth').title).toBe('auth'); + expect(json.find((s: any) => s.id === 'payment').title).toBe('payment'); } finally { process.chdir(originalCwd); } diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5a678919a..270e3acdf 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { ListCommand } from '../../src/core/list.js'; +import { runCLI } from '../helpers/run-cli.js'; describe('ListCommand', () => { let tempDir: string; @@ -161,5 +162,167 @@ Regular text that should be ignored expect(logOutput.some(line => line.includes('partial') && line.includes('1/3 tasks'))).toBe(true); expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true); }); + + it('should output JSON for specs mode with id and requirementCount only (no detail)', async () => { + const specsDir = path.join(tempDir, 'openspec', 'specs'); + await fs.mkdir(path.join(specsDir, 'cap-a'), { recursive: true }); + await fs.writeFile( + path.join(specsDir, 'cap-a', 'spec.md'), + '## Purpose\nTest\n## Requirements\n### Requirement: X\nThe system SHALL do X\n#### Scenario: Y\n- **WHEN** a\n- **THEN** b\n' + ); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'specs', { json: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed).toHaveProperty('specs'); + expect(Array.isArray(parsed.specs)).toBe(true); + expect(parsed.specs.length).toBe(1); + expect(parsed.specs[0]).toMatchObject({ id: 'cap-a', requirementCount: expect.any(Number) }); + expect(parsed.specs[0]).not.toHaveProperty('title'); + expect(parsed.specs[0]).not.toHaveProperty('overview'); + }); + + it('should include title and overview in specs JSON when detail is true', async () => { + const specsDir = path.join(tempDir, 'openspec', 'specs'); + await fs.mkdir(path.join(specsDir, 'cap-b'), { recursive: true }); + await fs.writeFile( + path.join(specsDir, 'cap-b', 'spec.md'), + '# My Cap B Title\n\n## Purpose\nThis is the overview for cap-b.\n\n## Requirements\n### Requirement: R\nSHALL\n#### Scenario: S\n- **WHEN** a\n- **THEN** b\n' + ); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'specs', { json: true, detail: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed.specs.length).toBe(1); + expect(parsed.specs[0]).toMatchObject({ + id: 'cap-b', + requirementCount: 1, + title: 'My Cap B Title', + overview: 'This is the overview for cap-b.', + }); + }); + + it('should output empty specs array when no specs exist (specs mode + json)', async () => { + const specsDir = path.join(tempDir, 'openspec', 'specs'); + await fs.mkdir(specsDir, { recursive: true }); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'specs', { json: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed).toEqual({ specs: [] }); + }); + + it('should output empty specs array when specs dir missing (specs mode + json)', async () => { + await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); + // no openspec/specs + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'specs', { json: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed).toEqual({ specs: [] }); + }); + + it('should list archived changes and output table when not json', async () => { + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + await fs.mkdir(path.join(archiveDir, '2025-01-13-add-list-command'), { recursive: true }); + await fs.writeFile( + path.join(archiveDir, '2025-01-13-add-list-command', 'tasks.md'), + '- [x] Task 1\n- [ ] Task 2\n' + ); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'archive'); + + expect(logOutput).toContain('Archived changes:'); + expect(logOutput.some(line => line.includes('2025-01-13-add-list-command'))).toBe(true); + }); + + it('should output archivedChanges JSON when archive mode + json', async () => { + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + await fs.mkdir(path.join(archiveDir, 'archived-one'), { recursive: true }); + await fs.writeFile( + path.join(archiveDir, 'archived-one', 'tasks.md'), + '- [x] Done\n' + ); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'archive', { json: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed).toHaveProperty('archivedChanges'); + expect(Array.isArray(parsed.archivedChanges)).toBe(true); + expect(parsed.archivedChanges.length).toBe(1); + expect(parsed.archivedChanges[0]).toMatchObject({ + name: 'archived-one', + completedTasks: 1, + totalTasks: 1, + status: 'complete' + }); + expect(parsed.archivedChanges[0]).toHaveProperty('lastModified'); + }); + + it('should output empty archivedChanges when archive dir missing (archive + json)', async () => { + await fs.mkdir(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + // no archive subdir + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'archive', { json: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed).toEqual({ archivedChanges: [] }); + }); + + it('should output empty archivedChanges when archive dir empty (archive + json)', async () => { + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'archive', { json: true }); + + const out = logOutput.join('\n'); + const parsed = JSON.parse(out); + expect(parsed).toEqual({ archivedChanges: [] }); + }); + + it('should display No archived changes found when archive empty and not json', async () => { + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'archive'); + + expect(logOutput).toContain('No archived changes found.'); + }); + + it('should apply mode precedence so archive wins over specs when both flags passed (CLI)', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + const archiveDir = path.join(changesDir, 'archive'); + await fs.mkdir(changesDir, { recursive: true }); + await fs.mkdir(path.join(archiveDir, 'precedence-archive'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs', 'some-spec'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, 'openspec', 'specs', 'some-spec', 'spec.md'), + '## Purpose\nX\n## Requirements\n### Requirement: R\nSHALL\n#### Scenario: S\n- **WHEN** a\n- **THEN** b\n' + ); + + const result = await runCLI(['list', '--specs', '--archive', '--json'], { cwd: tempDir }); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('archivedChanges'); + expect(parsed.archivedChanges.some((c: { name: string }) => c.name === 'precedence-archive')).toBe(true); + }); }); }); \ No newline at end of file diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index 502f575b4..41e47989c 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -31,6 +31,7 @@ Then they see an error message`; const spec = parser.parseSpec('user-auth'); expect(spec.name).toBe('user-auth'); + expect(spec.title).toBe('User Authentication Spec'); expect(spec.overview).toContain('requirements for user authentication'); expect(spec.requirements).toHaveLength(2); @@ -102,6 +103,46 @@ This is a test spec`; const parser = new MarkdownParser(content); expect(() => parser.parseSpec('test')).toThrow('must have a Requirements section'); }); + + it('should set title from first H1 when present', () => { + const content = `# My Spec Title + +## Purpose +Overview here. + +## Requirements + +### Requirement: One +SHALL do one. + +#### Scenario: S1 +- **WHEN** x +- **THEN** y`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('spec-id'); + expect(spec.title).toBe('My Spec Title'); + expect(spec.name).toBe('spec-id'); + }); + + it('should set title to name when document has no H1', () => { + const content = `## Purpose +Overview only, no H1. + +## Requirements + +### Requirement: One +SHALL do one. + +#### Scenario: S1 +- **WHEN** x +- **THEN** y`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('fallback-id'); + expect(spec.title).toBe('fallback-id'); + expect(spec.name).toBe('fallback-id'); + }); }); describe('parseChange', () => { diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index f7323b36a..b9ffb8ece 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -84,6 +84,7 @@ describe('Validation Schemas', () => { it('should validate a valid spec', () => { const spec = { name: 'user-auth', + title: 'User Authentication', overview: 'This spec defines user authentication requirements', requirements: [ { @@ -104,6 +105,7 @@ describe('Validation Schemas', () => { it('should reject spec without requirements', () => { const spec = { name: 'user-auth', + title: 'User Authentication', overview: 'This spec defines user authentication requirements', requirements: [], };