Skip to content

Commit b8715f0

Browse files
victorhsbclaude
andcommitted
feat: implement hierarchical spec IDs with recursive discovery
Support forward-slash-separated spec IDs (e.g., `cli/show`) alongside flat IDs, enabling logical grouping of related specs into subtrees. - Add specIdToPath/pathToSpecId utilities for ID↔path conversion - Refactor getSpecIds() to use fast-glob recursive `**/spec.md` discovery - Update all CLI commands (show, list, validate, view) for hierarchical IDs - Add subtree prefix filtering with segment-boundary matching to `list` - Extend fuzzy matching with leaf-segment scoring for better suggestions - Update archive/apply pipeline with recursive delta spec discovery - Update validator to include full hierarchical paths in error messages - Create openspec/AGENTS.md with hierarchical spec path documentation - Add unit, command, and integration tests (1370 passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 597115f commit b8715f0

18 files changed

Lines changed: 952 additions & 180 deletions

File tree

openspec/AGENTS.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# OpenSpec Agent Instructions
2+
3+
Quick reference for AI assistants working with this project.
4+
5+
---
6+
7+
## Quick Reference
8+
9+
### Spec IDs
10+
11+
Spec IDs are **forward-slash-separated paths** relative to `openspec/specs/`. Both flat and hierarchical IDs are valid:
12+
13+
| ID | File path |
14+
|----|-----------|
15+
| `auth` | `openspec/specs/auth/spec.md` |
16+
| `cli/show` | `openspec/specs/cli/show/spec.md` |
17+
| `domain/project/feature` | `openspec/specs/domain/project/feature/spec.md` |
18+
19+
IDs always use `/` regardless of operating system.
20+
21+
### CLI Commands
22+
23+
```bash
24+
# List all specs
25+
openspec list --specs
26+
27+
# List specs under a subtree (segment-boundary match)
28+
openspec list --specs cli/
29+
# "cli/" matches "cli/show", "cli/validate" — NOT "client/config"
30+
31+
# Show a spec (flat or hierarchical)
32+
openspec show auth
33+
openspec show cli/show
34+
35+
# Validate a spec
36+
openspec validate cli/show
37+
38+
# Validate all specs
39+
openspec validate --specs
40+
```
41+
42+
### Delta Spec Paths
43+
44+
When writing delta specs for a change, mirror the hierarchical ID under `changes/<name>/specs/`:
45+
46+
```
47+
openspec/changes/my-feature/specs/
48+
├── auth/
49+
│ └── spec.md # delta for spec ID "auth"
50+
└── cli/
51+
├── show/
52+
│ └── spec.md # delta for spec ID "cli/show"
53+
└── validate/
54+
└── spec.md # delta for spec ID "cli/validate"
55+
```
56+
57+
---
58+
59+
## Spec Format Templates
60+
61+
### New Spec (`openspec/specs/<id>/spec.md`)
62+
63+
```markdown
64+
## Purpose
65+
One sentence describing what this capability does.
66+
67+
## Requirements
68+
69+
### Requirement: <Descriptive Name>
70+
The system SHALL <behavior>.
71+
72+
#### Scenario: <Scenario Name>
73+
- **GIVEN** <precondition>
74+
- **WHEN** <trigger>
75+
- **THEN** <expected outcome>
76+
- **AND** <additional outcome>
77+
```
78+
79+
**For hierarchical specs**, the file lives at `openspec/specs/<domain>/<feature>/spec.md` but the content format is identical. Example for spec ID `cli/show`:
80+
81+
```markdown
82+
## Purpose
83+
Display a specification's content by its hierarchical ID.
84+
85+
## Requirements
86+
87+
### Requirement: Hierarchical ID Resolution
88+
The show command SHALL accept forward-slash-separated spec IDs.
89+
90+
#### Scenario: Show nested spec
91+
- **GIVEN** a spec exists at openspec/specs/cli/show/spec.md
92+
- **WHEN** the user runs `openspec show cli/show`
93+
- **THEN** the spec content is displayed
94+
```
95+
96+
### Delta Spec (`changes/<name>/specs/<id>/spec.md`)
97+
98+
```markdown
99+
## ADDED Requirements
100+
101+
### Requirement: <New Requirement Name>
102+
The system SHALL <new behavior>.
103+
104+
#### Scenario: <Scenario Name>
105+
- **WHEN** <trigger>
106+
- **THEN** <outcome>
107+
108+
## MODIFIED Requirements
109+
110+
### Requirement: <Existing Requirement Name>
111+
The system SHALL <updated behavior>. ← (was: <old behavior>)
112+
113+
#### Scenario: <Scenario Name>
114+
- **WHEN** <trigger>
115+
- **THEN** <updated outcome>
116+
117+
## REMOVED Requirements
118+
119+
### Requirement: <Requirement To Remove>
120+
Reason: <why it is being removed>
121+
122+
## RENAMED Requirements
123+
- FROM: `### Requirement: Old Name`
124+
- TO: `### Requirement: New Name`
125+
```
126+
127+
---
128+
129+
## Workflow
130+
131+
1. **Propose** — create `openspec/changes/<name>/` with `proposal.md`, `tasks.md`, and delta specs under `specs/`
132+
2. **Implement** — follow `tasks.md` checklist
133+
3. **Validate** — run `openspec validate --specs` and `openspec validate --changes`
134+
4. **Archive** — run `openspec archive <name>` to apply deltas and move to `changes/archive/`
135+
136+
---
137+
138+
## Pre-Validation Checklist
139+
140+
Before running `openspec validate`:
141+
142+
- [ ] Every `### Requirement:` block has at least one `#### Scenario:`
143+
- [ ] Scenarios use `**GIVEN**`/`**WHEN**`/`**THEN**`/`**AND**` keywords
144+
- [ ] Delta specs use `## ADDED`/`## MODIFIED`/`## REMOVED`/`## RENAMED` section headers
145+
- [ ] `## MODIFIED` and `## REMOVED` headers exactly match the headers in the current spec
146+
- [ ] Spec IDs use forward slashes (`cli/show`), not backslashes or hyphens as separators
147+
- [ ] Each spec file is at `<id>/spec.md` — e.g., `cli/show/spec.md` for ID `cli/show`
148+
149+
---
150+
151+
## Advanced Topics
152+
153+
### Subtree Filtering
154+
155+
`openspec list --specs <prefix>` filters by **segment boundary**:
156+
157+
```bash
158+
openspec list --specs cli/ # ✓ cli/show, cli/validate
159+
# ✗ client/config (different segment)
160+
openspec spec list cli/ # same, via deprecated spec subcommand
161+
```
162+
163+
### Cross-Platform Paths
164+
165+
Spec IDs always use `/`. The CLI converts to OS-specific paths internally — you never need to use `\` in spec IDs on Windows.
166+
167+
### Coexistence of Flat and Hierarchical IDs
168+
169+
Flat IDs (`auth`, `cli-show`) and hierarchical IDs (`cli/show`) coexist without conflict. Migration is not required; both are discovered automatically.
170+
171+
### Writing Specs: Behavior vs. Implementation
172+
173+
- **Specs** (`spec.md`): externally observable behavior, interfaces, error conditions, constraints — no library/framework choices
174+
- **Design** (`design.md`): internal architecture, library choices, data structures
175+
- **Tasks** (`tasks.md`): implementation steps and checklist items
Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,44 @@
11
## 1. Core Discovery & Resolution
22

3-
- [ ] 1.1 Create `specIdToPath(specId: string, baseDir: string): string` and `pathToSpecId(filePath: string, specsDir: string): string` utility functions in `src/utils/` with `/`-normalization on all platforms
4-
- [ ] 1.2 Refactor `getSpecIds()` in `src/utils/item-discovery.ts` to recursively discover specs using `fast-glob` pattern `**/spec.md` under `openspec/specs/`, deriving IDs from relative paths
5-
- [ ] 1.3 Update all direct path construction (`path.join(SPECS_DIR, specId, 'spec.md')`) across the codebase to use `specIdToPath()`
6-
- [ ] 1.4 Add unit tests for `specIdToPath` and `pathToSpecId` including Windows path separator normalization
7-
- [ ] 1.5 Add unit tests for recursive `getSpecIds()` with flat, nested, mixed, and hidden directory fixtures
3+
- [x] 1.1 Create `specIdToPath(specId: string, baseDir: string): string` and `pathToSpecId(filePath: string, specsDir: string): string` utility functions in `src/utils/` with `/`-normalization on all platforms
4+
- [x] 1.2 Refactor `getSpecIds()` in `src/utils/item-discovery.ts` to recursively discover specs using `fast-glob` pattern `**/spec.md` under `openspec/specs/`, deriving IDs from relative paths
5+
- [x] 1.3 Update all direct path construction (`path.join(SPECS_DIR, specId, 'spec.md')`) across the codebase to use `specIdToPath()`
6+
- [x] 1.4 Add unit tests for `specIdToPath` and `pathToSpecId` including Windows path separator normalization
7+
- [x] 1.5 Add unit tests for recursive `getSpecIds()` with flat, nested, mixed, and hidden directory fixtures
88

99
## 2. CLI Commands — Spec Subcommands
1010

11-
- [ ] 2.1 Update `src/commands/spec.ts` `show` subcommand to accept and resolve hierarchical spec IDs (e.g., `cli/show`)
12-
- [ ] 2.2 Update `src/commands/spec.ts` `list` subcommand to display full hierarchical IDs and accept an optional subtree prefix argument
13-
- [ ] 2.3 Update `src/commands/spec.ts` `validate` subcommand to accept and resolve hierarchical spec IDs
14-
- [ ] 2.4 Update interactive selection prompts in `spec show` and `spec validate` to display hierarchical IDs
11+
- [x] 2.1 Update `src/commands/spec.ts` `show` subcommand to accept and resolve hierarchical spec IDs (e.g., `cli/show`)
12+
- [x] 2.2 Update `src/commands/spec.ts` `list` subcommand to display full hierarchical IDs and accept an optional subtree prefix argument
13+
- [x] 2.3 Update `src/commands/spec.ts` `validate` subcommand to accept and resolve hierarchical spec IDs
14+
- [x] 2.4 Update interactive selection prompts in `spec show` and `spec validate` to display hierarchical IDs
1515

1616
## 3. CLI Commands — Top-Level Show, Validate, List
1717

18-
- [ ] 3.1 Update `src/commands/show.ts` type detection to resolve hierarchical spec IDs and include them in fuzzy-match suggestions
19-
- [ ] 3.2 Update `src/commands/validate.ts` type detection and bulk validation (`--all`, `--specs`) to use recursive spec discovery
20-
- [ ] 3.3 Update `src/core/list.ts` to use recursive spec discovery and support subtree filtering argument for `--specs`
21-
- [ ] 3.4 Update `src/core/view.ts` dashboard to display hierarchical spec IDs in the specifications section
18+
- [x] 3.1 Update `src/commands/show.ts` type detection to resolve hierarchical spec IDs and include them in fuzzy-match suggestions
19+
- [x] 3.2 Update `src/commands/validate.ts` type detection and bulk validation (`--all`, `--specs`) to use recursive spec discovery
20+
- [x] 3.3 Update `src/core/list.ts` to use recursive spec discovery and support subtree filtering argument for `--specs`
21+
- [x] 3.4 Update `src/core/view.ts` dashboard to display hierarchical spec IDs in the specifications section
2222

2323
## 4. Fuzzy Matching
2424

25-
- [ ] 4.1 Extend `src/utils/match.ts` to support leaf-segment matching — when no exact match, search for specs whose last path segment matches the query
26-
- [ ] 4.2 Add tests for fuzzy matching with hierarchical IDs (full path typo, leaf match, multiple leaf matches)
25+
- [x] 4.1 Extend `src/utils/match.ts` to support leaf-segment matching — when no exact match, search for specs whose last path segment matches the query
26+
- [x] 4.2 Add tests for fuzzy matching with hierarchical IDs (full path typo, leaf match, multiple leaf matches)
2727

2828
## 5. Archive & Delta Spec Handling
2929

30-
- [ ] 5.1 Update delta spec discovery in `src/core/archive.ts` / `src/core/specs-apply.ts` to recursively walk `changes/<name>/specs/` for delta specs at any depth
31-
- [ ] 5.2 Update archive confirmation display to show full hierarchical paths for new and updated specs
32-
- [ ] 5.3 Ensure archive creates intermediate directories when applying delta specs to new hierarchical paths
33-
- [ ] 5.4 Add tests for archiving changes with hierarchical delta specs
30+
- [x] 5.1 Update delta spec discovery in `src/core/archive.ts` / `src/core/specs-apply.ts` to recursively walk `changes/<name>/specs/` for delta specs at any depth
31+
- [x] 5.2 Update archive confirmation display to show full hierarchical paths for new and updated specs
32+
- [x] 5.3 Ensure archive creates intermediate directories when applying delta specs to new hierarchical paths
33+
- [x] 5.4 Add tests for archiving changes with hierarchical delta specs
3434

3535
## 6. Validation & Edge Cases
3636

37-
- [ ] 6.1 Update `src/commands/validate.ts` error messages and file path references to include full hierarchical spec paths
38-
- [ ] 6.2 Update `src/core/validation/validator.ts` to handle hierarchical spec IDs in structured location paths
39-
- [ ] 6.3 Add integration tests: mixed flat + hierarchical specs coexisting, subtree listing, and cross-platform path handling
37+
- [x] 6.1 Update `src/commands/validate.ts` error messages and file path references to include full hierarchical spec paths
38+
- [x] 6.2 Update `src/core/validation/validator.ts` to handle hierarchical spec IDs in structured location paths
39+
- [x] 6.3 Add integration tests: mixed flat + hierarchical specs coexisting, subtree listing, and cross-platform path handling
4040

4141
## 7. Documentation & Cleanup
4242

43-
- [ ] 7.1 Update `openspec/AGENTS.md` with examples of hierarchical spec paths in templates and references
44-
- [ ] 7.2 Update CLI help text for `spec list` to document subtree filtering syntax
43+
- [x] 7.1 Update `openspec/AGENTS.md` with examples of hierarchical spec paths in templates and references
44+
- [x] 7.2 Update CLI help text for `spec list` to document subtree filtering syntax

src/cli/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,18 @@ program
170170
});
171171

172172
program
173-
.command('list')
174-
.description('List items (changes by default). Use --specs to list specs.')
173+
.command('list [prefix]')
174+
.description('List items (changes by default). Use --specs to list specs, optionally filtered by subtree prefix (e.g. "cli/" shows only specs under cli/).')
175175
.option('--specs', 'List specs instead of changes')
176176
.option('--changes', 'List changes explicitly (default)')
177177
.option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent')
178178
.option('--json', 'Output as JSON (for programmatic use)')
179-
.action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => {
179+
.action(async (prefix?: string, options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => {
180180
try {
181181
const listCommand = new ListCommand();
182182
const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';
183183
const sort = options?.sort === 'name' ? 'name' : 'recent';
184-
await listCommand.execute('.', mode, { sort, json: options?.json });
184+
await listCommand.execute('.', mode, { sort, json: options?.json, specsPrefix: prefix });
185185
} catch (error) {
186186
console.log(); // Empty line for spacing
187187
ora().fail(`Error: ${(error as Error).message}`);

src/commands/spec.ts

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { program } from 'commander';
2-
import { existsSync, readdirSync, readFileSync } from 'fs';
3-
import { join } from 'path';
2+
import { existsSync, readFileSync } from 'fs';
43
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
54
import { Validator } from '../core/validation/validator.js';
65
import type { Spec } from '../core/schemas/index.js';
76
import { isInteractive } from '../utils/interactive.js';
87
import { getSpecIds } from '../utils/item-discovery.js';
8+
import { specIdToPath } from '../utils/spec-paths.js';
99

1010
const SPECS_DIR = 'openspec/specs';
1111

@@ -82,7 +82,7 @@ export class SpecCommand {
8282
}
8383
}
8484

85-
const specPath = join(this.SPECS_DIR, specId, 'spec.md');
85+
const specPath = specIdToPath(specId, this.SPECS_DIR);
8686
if (!existsSync(specPath)) {
8787
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
8888
}
@@ -137,42 +137,42 @@ export function registerSpecCommand(rootProgram: typeof program) {
137137
});
138138

139139
specCommand
140-
.command('list')
141-
.description('List all available specifications')
140+
.command('list [prefix]')
141+
.description('List all available specifications. Optionally filter by subtree prefix (e.g. "cli/" lists only specs under cli/)')
142+
.addHelpText('after', `
143+
Subtree filtering:
144+
openspec spec list List all specs
145+
openspec spec list cli/ List only specs whose ID starts with "cli/" (segment boundary)
146+
Matches: cli/show, cli/validate
147+
Does not match: client/config
148+
149+
Spec IDs use forward-slash separators (e.g. "cli/show", "domain/project/feature").
150+
The prefix must end with "/" or one is appended automatically.`)
142151
.option('--json', 'Output as JSON')
143152
.option('--long', 'Show id and title with counts')
144-
.action((options: { json?: boolean; long?: boolean }) => {
153+
.action(async (prefix: string | undefined, options: { json?: boolean; long?: boolean }) => {
145154
try {
146-
if (!existsSync(SPECS_DIR)) {
147-
console.log('No items found');
148-
return;
155+
let allIds = await getSpecIds();
156+
157+
if (prefix) {
158+
// Segment-boundary filtering: prefix must end with "/" to denote a boundary.
159+
// "cli/" matches "cli/foo" and "cli/bar/baz" but NOT "client/foo".
160+
const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
161+
allIds = allIds.filter(id => id.startsWith(normalizedPrefix));
149162
}
150163

151-
const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
152-
.filter(dirent => dirent.isDirectory())
153-
.map(dirent => {
154-
const specPath = join(SPECS_DIR, dirent.name, 'spec.md');
155-
if (existsSync(specPath)) {
156-
try {
157-
const spec = parseSpecFromFile(specPath, dirent.name);
158-
159-
return {
160-
id: dirent.name,
161-
title: spec.name,
162-
requirementCount: spec.requirements.length
163-
};
164-
} catch {
165-
return {
166-
id: dirent.name,
167-
title: dirent.name,
168-
requirementCount: 0
169-
};
170-
}
164+
const specs = allIds.map(id => {
165+
const specPath = specIdToPath(id, SPECS_DIR);
166+
if (existsSync(specPath)) {
167+
try {
168+
const spec = parseSpecFromFile(specPath, id);
169+
return { id, title: spec.name, requirementCount: spec.requirements.length };
170+
} catch {
171+
return { id, title: id, requirementCount: 0 };
171172
}
172-
return null;
173-
})
174-
.filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null)
175-
.sort((a, b) => a.id.localeCompare(b.id));
173+
}
174+
return null;
175+
}).filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null);
176176

177177
if (options.json) {
178178
console.log(JSON.stringify(specs, null, 2));
@@ -217,8 +217,8 @@ export function registerSpecCommand(rootProgram: typeof program) {
217217
}
218218
}
219219

220-
const specPath = join(SPECS_DIR, specId, 'spec.md');
221-
220+
const specPath = specIdToPath(specId, SPECS_DIR);
221+
222222
if (!existsSync(specPath)) {
223223
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
224224
}

0 commit comments

Comments
 (0)