Skip to content

feat: add hierarchical spec structure support#660

Open
lsmonki wants to merge 54 commits intoFission-AI:mainfrom
lsmonki:feature/hierarchical-specs-support
Open

feat: add hierarchical spec structure support#660
lsmonki wants to merge 54 commits intoFission-AI:mainfrom
lsmonki:feature/hierarchical-specs-support

Conversation

@lsmonki
Copy link

@lsmonki lsmonki commented Feb 4, 2026

OpenSpec currently assumes a flat spec structure (specs/{capability}/spec.md) where each capability is a direct subdirectory under specs/. This works for simple projects but becomes limiting as projects grow. Teams need hierarchical organization for:

  • Domain-driven design: Grouping specs by bounded context (specs/payments/checkout/spec.md, specs/auth/oauth/spec.md)
  • Monorepos: Organizing specs by package or scope (specs/packages/core/spec.md)
  • Cross-cutting concerns: Separating global specs from feature-specific ones (specs/_global/testing/spec.md vs specs/features/export/spec.md)
  • Team namespaces: Large teams organizing specs by ownership (specs/team-platform/infra/spec.md)
  • Scale: Projects with 50+ specs become unmanageable in a flat structure

Commands like openspec list --specs, validation, sync, and archive fail to discover or work with hierarchically organized specs, forcing teams to use workarounds or manual processes.

What Changes

  • Core spec discovery: Implement recursive search for spec.md files at any depth, replacing the current single-level directory scan
  • Capability naming: Change capability identifier from directory name to full relative path from specs/ (e.g., _global/testing instead of just testing)
  • Delta mapping: Enable 1:1 path-based mapping between change deltas and main specs using replicated directory structure
  • Configuration: Add optional specStructure section in openspec/config.yaml or globally in ~/.config/openspec/config.json to control structure mode, depth limits, and validation behavior
    • structure: 'flat' | 'hierarchical' | 'auto' (default: auto - auto-detect)
    • maxDepth: Maximum hierarchy depth (default: 4, recommended: 2-3)
    • allowMixed: Allow mixing flat and hierarchical specs (default: true)
    • validatePaths: Enforce naming conventions (default: true)
  • Display: Update list and view commands to show hierarchical specs
  • Validation: Add validation rules to prevent orphaned specs (no spec.md at intermediate levels), enforce depth limits (warn at 4, hard limit at 10), and check naming conventions (lowercase alphanumeric with hyphens/underscores)
  • Backward compatibility: Auto-detect flat vs hierarchical structure and support both transparently
  • Templates: Update all skill prompts and templates to reference specs using relative paths
  • Commands: Adapt list, validate, sync, archive commands to work with hierarchical paths

Benefits:

  • Better organization: Group related specs by domain, team, or concern rather than forcing flat structure
  • Domain-driven design: Natural alignment with bounded contexts and domain models
  • Monorepo support: Organize specs by package, scope, or workspace
  • Scalability: Projects with 50+ specs remain navigable and maintainable
  • Team collaboration: Large teams can namespace their specs to avoid conflicts
  • Cross-cutting separation: Distinguish global concerns (_global/, shared/, utils/) from feature-specific specs
  • Code alignment: Spec structure can mirror code structure when beneficial
  • No workarounds: Eliminates need for custom schemas or hacks
  • Optional strictness: Configuration for validation during migrations or in large teams
  • Zero configuration: Works automatically with auto-detection, no setup required
  • Full compatibility: Existing flat projects continue working unchanged

Examples

Directory structures

Flat (current behavior):

openspec/specs/
  auth/spec.md
  api/spec.md
  payments/spec.md
  notifications/spec.md

Hierarchical (new):

openspec/specs/
  _global/
    testing/spec.md              # cross-cutting: testing standards
    security/spec.md             # cross-cutting: security requirements
    monitoring/spec.md           # cross-cutting: monitoring
  platform/
    services/
      api/spec.md                # service: API
      auth/spec.md               # service: authentication
      database/spec.md           # service: database
    infrastructure/
      deployment/spec.md         # infra: deployment
      scaling/spec.md            # infra: scaling
  frontend/
    components/
      forms/spec.md
      navigation/spec.md
    state-management/spec.md

Mixed (flat + hierarchical coexist):

openspec/specs/
  auth/spec.md                   # flat (depth 1)
  payments/spec.md               # flat (depth 1)
  _global/
    testing/spec.md              # hierarchical (depth 2)
    security/spec.md             # hierarchical (depth 2)
  platform/
    services/
      api/spec.md                # hierarchical (depth 3)

Change deltas mirror main structure 1:1

openspec/changes/add-rate-limiting/
  proposal.md
  specs/
    platform/
      services/
        api/spec.md              # delta for platform/services/api
    _global/
      security/spec.md           # delta for _global/security

CLI works with full paths

openspec validate platform/services/api    # validate a deep spec
openspec validate _global/testing          # validate a global spec
openspec validate --specs                  # validate all (auto-discovers)
openspec list --specs                      # list all with grouping

Output example (openspec list --specs)

Specs:
  _global/adr-workflow                  18 requirements
  _global/architecture                  12 requirements
  _global/testing                        8 requirements

  dev/mcp-server                        23 requirements
  dev/mcp-context-builder               14 requirements

  pel/expression-language                6 requirements

Configuration (optional, works without it)

In openspec/config.yaml:

specStructure:
  structure: auto        # auto | flat | hierarchical
  maxDepth: 4            # max nesting depth (1-10)
  allowMixed: true       # allow flat + hierarchical together
  validatePaths: true    # enforce naming conventions

Or globally in ~/.config/openspec/config.json:

{
  "specStructure": {
    "structure": "auto",
    "maxDepth": 4,
    "allowMixed": true,
    "validatePaths": true
  }
}

References: #662 #581

Summary by CodeRabbit

  • New Features

    • Hierarchical specs support (flat|hierarchical|auto) with configurable depth, mixed-structure options, and per-project overrides.
  • Validation / CLI

    • Spec-structure validation enforced and reported (including JSON output); affects exit codes. Listing/viewing now render hierarchical grouping and full spec paths; validate honors project-level overrides.
  • Documentation

    • New organizing, migration, and troubleshooting guides; README and changelog updated.
  • Examples

    • Added hierarchical example project and sample specs.
  • Tests

    • Extensive unit, integration, compatibility, and performance tests added.
  • Chores

    • .gitignore updated to ignore local Codex files.

Add support for organizing specifications in nested directory hierarchies
alongside the existing flat structure. This enables better organization
for complex projects while maintaining full backward compatibility.

Features:
- Recursive spec discovery with findAllSpecs() utility
- Auto-detection of flat vs hierarchical structures
- Structure validation with configurable depth limits and naming conventions
- 1:1 delta replication (change deltas mirror main spec structure)
- Cross-platform path handling (Windows, macOS, Linux)
- Updated all commands: list, view, validate, show, archive
- Updated skill templates and workflow schema with hierarchical examples
- Comprehensive documentation and migration guide
- Example project with hierarchical structure

Implementation notes:
- Fixed getSpecIds() to use recursive discovery
- Updated list display to show full paths with uniform padding
- Updated workflow schema instructions to use CLI commands
@lsmonki lsmonki requested a review from TabishB as a code owner February 4, 2026 10:22
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Adds hierarchical spec support: new spec-discovery utility (findAllSpecs, findSpecUpdates, validateSpecStructure), specStructure config with project-level overrides, capability-path semantics across CLI/core (list/view/validate/archive/apply), extensive docs/examples/migration/troubleshooting, many tests, and a .gitignore entry.

Changes

Cohort / File(s) Summary
Top-level & docs
\.gitignore, CHANGELOG.md, README.md
Add .codex/ ignore; update changelog and README links/formatting.
Guides & troubleshooting
docs/organizing-specs.md, docs/migration-flat-to-hierarchical.md, docs/troubleshooting-hierarchical-specs.md
New docs describing flat vs hierarchical layouts, migration steps, config options, and troubleshooting.
Examples & sample specs
examples/hierarchical-specs/*, examples/hierarchical-specs/openspec/specs/...
Add hierarchical example project, README, and sample spec files demonstrating hierarchical layouts and workflows.
Spec discovery utility
src/utils/spec-discovery.ts
New centralized discovery/validation module exposing DiscoveredSpec, SpecUpdate, SpecStructureConfig, findAllSpecs, isSpecStructureHierarchical, findSpecUpdates, validateSpecStructure.
Config schemas & APIs
src/core/config-schema.ts, src/core/global-config.ts, src/core/project-config.ts
Add SpecStructureConfig schema and defaults; extend GlobalConfig; add getSpecStructureConfig(projectOverrides?); add resilient per-field project config parsing.
CLI / core integration
src/commands/spec.ts, src/commands/validate.ts, src/core/list.ts, src/core/view.ts, src/core/archive.ts, src/core/specs-apply.ts, src/core/validation/validator.ts, src/core/parsers/change-parser.ts, src/utils/item-discovery.ts
Replace ad-hoc fs scans with discovery utility, use capability-path identifiers, integrate structure validation into validate flow, and update delta discovery/application and listing/view rendering for hierarchical structures.
Templates & schemas
src/core/templates/skill-templates.ts, schemas/spec-driven/schema.yaml
Update templates and schema guidance to use hierarchical capability-paths (specs/<capability-path>/spec.md) and explicit delta mapping rules.
Design / proposals / archives
openspec/changes/archive/.../*
Add design docs, proposals, tasks, and archives covering recursive discovery, 1:1 delta mapping, enforcement modes, project-level overrides, and rollout plan.
Tests
test/** (e.g., test/utils/spec-discovery.test.ts, test/core/*.test.ts, test/commands/*.test.ts)
Large test additions/updates: discovery unit tests, performance tests, config-schema/global-config/project-config tests, and many list/view/archive/validate/parsers tests for hierarchical scenarios (note: some duplicated test blocks present).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant CLI as CLI (list/validate/archive)
  participant Disc as SpecDiscovery\n(findAllSpecs / findSpecUpdates)
  participant FS as Filesystem
  participant Val as Validator\n(validateSpecStructure)

  CLI->>Disc: request discovered specs or spec updates (baseDir / changeDir)
  Disc->>FS: traverse directories & read `spec.md` files
  FS-->>Disc: file paths & contents
  Disc-->>CLI: DiscoveredSpec[] / SpecUpdate[]
  CLI->>Val: validateSpecStructure(DiscoveredSpec[], config)
  Val-->>CLI: ValidationIssue[] (may affect exit code)
  CLI->>FS: read/write spec files when applying changes (using capability paths)
  FS-->>CLI: success / failure
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • TabishB

Poem

🐰 I hopped through folders, mapped each branching way,

Turned scattered specs to paths so tidy they now lay,
Deltas mirror branches, neat and easy to keep,
I nudged the validator awake from its flat-sleep,
Now discovery hums — the rabbit’s work is complete!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add hierarchical spec structure support' clearly and directly summarizes the main change—adding hierarchical spec structure support to the OpenSpec project. It is concise, specific, and accurately reflects the primary feature being introduced.
Docstring Coverage ✅ Passed Docstring coverage is 86.21% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/core/templates/skill-templates.ts (1)

1224-1256: ⚠️ Potential issue | 🟡 Minor

Keep the mkdir example aligned with <capability-path>.

The save path now uses <capability-path>, but the mkdir example still uses <capability-name>, which won’t create nested directories for hierarchical specs.

✏️ Doc fix
-# Unix/macOS
-mkdir -p openspec/changes/<name>/specs/<capability-name>
-# Windows (PowerShell)
-# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-name>"
+# Unix/macOS
+mkdir -p openspec/changes/<name>/specs/<capability-path>
+# Windows (PowerShell)
+# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-path>"
src/commands/validate.ts (1)

118-129: ⚠️ Potential issue | 🟠 Major

Normalize spec IDs for Windows compatibility when comparing user input.

getSpecCapabilities() returns spec IDs with platform-specific path separators (backslashes on Windows, forward slashes on Unix). When hierarchical specs are enabled (default with specStructure.structure: 'auto'), the direct comparison at line 120 fails on Windows if users input paths with forward slashes—e.g., openspec validate platform/services/api won't match the discovered ID platform\services\api.

The codebase already has a toPosixPath() utility in src/utils/file-system.ts. Consider normalizing both itemName and specs to forward slashes before comparison, and apply the same normalization to the candidates passed to nearestMatches() to ensure suggestions work correctly.

🤖 Fix all issues with AI agents
In `@CHANGELOG.md`:
- Around line 3-28: Consolidate duplicate "Unreleased" sections by merging this
hierarchical-specs entry under the single top-level "Unreleased" header (remove
any other duplicate "Unreleased" headers), and update the performance statement:
either remove the "< 100ms" claim or replace it with a verifiable phrasing and
reference to a benchmark (e.g., "Handles 1000+ specs efficiently — see benchmark
XYZ"); ensure cross-references like the `findAllSpecs()` utility and
`specStructure` config remain intact and docs links are preserved.

In `@docs/migration-flat-to-hierarchical.md`:
- Around line 31-37: Several fenced code blocks (for example the block
containing the directory list starting with "_global/          # Cross-cutting
concerns" and the block starting with "openspec/specs/" as well as the other
unlabeled lists later in the document) are missing language identifiers; update
each unlabeled triple-backtick fence to include a language tag (use "text" for
plain directory/list blocks) so each fence becomes ```text ... ```; apply this
change to all unlabeled fences referenced by the reviewer (the examples around
the directory listings and the other unlabeled listing blocks) to satisfy MD040.

In `@docs/troubleshooting-hierarchical-specs.md`:
- Around line 31-33: Several fenced code blocks lack language tags (markdownlint
MD040); update each unlabeled triple-backtick fence containing error
messages—e.g., the blocks with the lines 'ERROR: Invalid segment "Auth" in
capability "platform/Auth"', 'ERROR: Spec "platform/services/api/rest/v1"
exceeds maximum depth 4', and the other error-message blocks referenced—to
include a language identifier such as text (change ``` to ```text) so
markdownlint no longer flags them; apply the same change to all unlabeled fences
listed in the comment.

In `@src/core/validation/validator.ts`:
- Around line 134-136: The code constructs entryPath by concatenating
spec.capability with "/spec.md" which mixes separators on Windows; update the
logic where entryPath is created (referencing parseDeltaSpec and
spec.capability) to use a platform-safe join (e.g., path.join or
FileSystemUtils.join) so the resulting path is consistent across OSes, and
ensure any callers expecting a POSIX-style path are adjusted or normalized as
needed.

In `@src/utils/spec-discovery.ts`:
- Around line 114-123: The capability calculation incorrectly sets capability to
'spec.md' when spec.md is in the base directory because relativePath is empty;
update the logic in src/utils/spec-discovery.ts where you handle entry.isFile()
and entry.name === 'spec.md' to explicitly skip spec.md at the root (i.e., if
relativePath is falsy or equals '' then continue/return early) instead of
pushing a malformed record into specs; ensure you reference the existing
variables capability, relativePath, entry, fullPath, and depth so the push only
happens for valid capability directories.
- Around line 30-39: Remove the duplicate SpecUpdate interface declaration from
specs-apply.ts and rely on the exported SpecUpdate from spec-discovery.ts (which
already has JSDoc). In specs-apply.ts, keep the existing import "type SpecUpdate
as SpecUpdateUtil" and remove the local interface block; if you need to expose
it from specs-apply.ts, re-export the imported type (e.g., export type
SpecUpdateUtil = SpecUpdate) rather than redefining it so SpecUpdate in
spec-discovery.ts remains the single source of truth.

In `@test/utils/spec-discovery.test.ts`:
- Around line 177-185: Rename the test case to reflect actual behavior: change
the 'it' description from "should not find orphaned specs at intermediate
levels" to something like "should find all specs including intermediate spec;
validation of orphaned specs happens separately", and update the inline comment
above the expect to explain that findAllSpecs(hierarchicalFixtureDir)
intentionally returns the intermediate spec (expect(specs).toHaveLength(6)) and
that orphaned-spec validation is handled in a separate step; locate this test
block in test/utils/spec-discovery.test.ts (the it(...) containing the
fs.writeFileSync(... '_global' 'spec.md') and the call to findAllSpecs) and only
adjust the test name and comment text.
🧹 Nitpick comments (9)
examples/hierarchical-specs/README.md (1)

7-20: Add language specifiers to fenced code blocks.

The directory structure code blocks are missing language specifiers. While this doesn't affect rendering, it triggers markdown linting warnings (MD040).

📝 Proposed fix
-```
+```text
 openspec/specs/
   _global/
     testing/spec.md        - Global testing standards

And similarly for the block at line 53:

-```
+```text
 openspec/changes/add-rate-limiting/
   proposal.md
   specs/

Also applies to: 53-60

docs/organizing-specs.md (1)

9-16: Add language specifiers to directory structure code blocks.

Multiple fenced code blocks showing directory structures are missing language specifiers (MD040). Use text or plaintext for consistency with markdown linting rules.

📝 Proposed fix for affected blocks

Change opening fence from triple backticks to:

-```
+```text

Apply to code blocks at lines 9, 27, 71, 77, and 88.

Also applies to: 27-46, 71-74, 77-80, 88-98

src/core/specs-apply.ts (1)

322-323: Potential cross-platform path display inconsistency.

The log message uses a forward slash (/) to construct the display path, but update.capability may contain platform-specific separators (path.sep). On Windows, this could result in mixed separators like openspec/specs/_global\security/spec.md.

Consider normalizing capability to use forward slashes for display purposes:

🔧 Proposed fix for consistent display paths
   // Use full capability path for hierarchical support
-  console.log(`Applying changes to openspec/specs/${update.capability}/spec.md:`);
+  const displayPath = update.capability.replace(/\\/g, '/');
+  console.log(`Applying changes to openspec/specs/${displayPath}/spec.md:`);
src/core/view.ts (1)

248-255: Consider aligning hierarchical indentation to depth - 1.

Depth 1 specs get an extra indent, which makes roots appear more indented than flat view. A small tweak keeps root items aligned.

💡 Optional indentation tweak
-      const indent = '  '.repeat(node.depth);
+      const indent = '  '.repeat(Math.max(0, node.depth - 1));
src/core/global-config.ts (1)

160-170: Apply deep-merge for specStructure to ensure defaults are preserved for partial configs.

Currently, getGlobalConfig() uses shallow merge, which means if a user's config file contains only { "specStructure": { "maxDepth": 8 } }, the returned specStructure will have only maxDepth and lose the other defaults. While getSpecStructureConfig() currently mitigates this with defensive fallbacks, direct accesses to getGlobalConfig().specStructure (whether now or in the future) would receive incomplete values.

Apply the same deep-merge pattern used for featureFlags:

Deep-merge specStructure in getGlobalConfig()
     return {
       ...DEFAULT_CONFIG,
       ...parsed,
       // Deep merge featureFlags
       featureFlags: {
         ...DEFAULT_CONFIG.featureFlags,
         ...(parsed.featureFlags || {})
-      }
+      },
+      // Deep merge specStructure to preserve defaults for partial configs
+      specStructure: {
+        ...DEFAULT_CONFIG.specStructure,
+        ...(parsed.specStructure || {})
+      }
     };
src/core/config-schema.ts (2)

21-21: Minor: Extra leading space in comment.

There's a leading space before the /** that breaks the alignment with other doc comments in the file.

Suggested fix
- /**
+/**

262-264: Minor: Redundant type assertion.

The as z.ZodError cast on line 263 is unnecessary since the instanceof z.ZodError check on line 262 already narrows the type.

Suggested fix
     if (error instanceof z.ZodError) {
-      const zodError = error as z.ZodError;
-      const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
+      const messages = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
       return { success: false, error: messages.join('; ') };
     }
openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md (1)

189-192: Add language specifiers to fenced code blocks.

The code blocks at lines 189-192 and 214-224 are missing language specifiers. Even for display format examples, adding a specifier (e.g., text or plaintext) improves consistency and helps rendering tools.

Suggested fix for line 189
-```
+```text
 ✗ specs/auth/spec.md           ← Has spec at intermediate level
   specs/auth/oauth/spec.md     ← Also has spec in child
</details>

<details>
<summary>Suggested fix for line 214</summary>

```diff
-```
+```text
 Specifications:
   _global/
     architecture          42 requirements

Also applies to: 214-224

src/utils/spec-discovery.ts (1)

294-294: Minor: .gitignore in RESERVED_NAMES is unnecessary.

The RESERVED_NAMES list includes .gitignore, which is typically a file, not a directory. Since capability paths represent directory names, this entry is unlikely to match anything. Consider removing it or replacing with more relevant directory-specific reserved names if needed.

Suggested fix
-    const RESERVED_NAMES = ['..', '.', '.git', '.gitignore', 'node_modules', '.openspec'];
+    const RESERVED_NAMES = ['..', '.', '.git', 'node_modules', '.openspec'];

@TabishB
Copy link
Contributor

TabishB commented Feb 4, 2026

Can we align on the proposal first before diving into implementation?

This is a relatively large change. From the contributing section:

Larger changes — For new features, significant refactors, or architectural changes, please submit an OpenSpec change proposal first so we can align on intent and goals before implementation begins.

@lsmonki
Copy link
Author

lsmonki commented Feb 4, 2026

Can we align on the proposal first before diving into implementation?

This is a relatively large change. From the contributing section:

Larger changes — For new features, significant refactors, or architectural changes, please submit an OpenSpec change proposal first so we can align on intent and goals before implementation begins.

Sure, no problem at all 👍
I opened the PR because this is a change we already have prepared for internal use and it’s what we currently need on our side, but I understand it may not be fully aligned with the project direction.
I’ll open an issue to describe the proposal properly and we can align on it first before moving forward.

…ection in archive

findAllSpecs() produced invalid capability "spec.md" for spec.md files
at baseDir root. Archive delta detection only checked one directory
level deep, missing hierarchical deltas at depth 2+.
Replace string interpolation with path.join() in validator, specs-apply,
and spec-discovery to avoid mixed separators on Windows.
Remove duplicated SpecUpdate interface from specs-apply.ts, re-export
from spec-discovery.ts. Rename Spec to DiscoveredSpec to avoid
collision with the schema Spec type.
…play

Replace inline path.sep checks with the shared utility function in
list.ts and view.ts. Fix view.ts to sort by path when hierarchical
and show full capability paths instead of leaf names only.
… reports

specStructure was shallow-merged, losing default values when only some
fields were set. Orphan detection could report the same parent multiple
times when several children shared the same orphaned ancestor.
Support specStructure in openspec/config.yaml with field-level merge
precedence (project > global > defaults). Uses sub-field-by-field
resilient parsing consistent with rules parsing pattern.
validateSpecStructure() was ignoring config.structure and
config.allowMixed fields. Flat mode now rejects depth > 1,
hierarchical mode rejects depth === 1, and auto mode with
allowMixed: false detects mixed flat/hierarchical specs.
…rsing

SpecStructureConfig already has all fields optional, making
Partial<SpecStructureConfig> identical. Remove the unnecessary
Partial wrapper and the corresponding `as` cast.
The wrapper delegates to a sync function from spec-discovery.
Remove async/await from the wrapper and its two call sites
since the operation is entirely synchronous.
isSpecStructureHierarchical now accepts a pre-discovered
DiscoveredSpec[] to avoid re-traversing the filesystem.
list.ts and view.ts pass already-discovered specs instead of
triggering a redundant directory walk.
runBulkValidation called findAllSpecs twice: once via
getSpecCapabilities() and again for structure validation.
Now discovers specs once and derives both the capability list
and structure validation from the same result.
getGlobalConfig() was deep-merging raw JSON specStructure
without validation, allowing invalid values like
structure:"invalid" to pass through silently. Add sub-field
Zod validation consistent with project-config parsing.
The _structure pseudo-item inflated item counts and was
inconsistent with real spec items. Structure validation
issues are now in a dedicated structureValidation field
in JSON output and displayed separately in human output.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/specs-apply.ts (1)

157-162: ⚠️ Potential issue | 🟡 Minor

Use full capability in the “no operations” error.
The current message only reports the leaf directory, which is ambiguous for hierarchical specs with repeated leaf names.

Proposed fix
-      `Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
+      `Delta parsing found no operations for ${update.capability}. ` +
🧹 Nitpick comments (7)
openspec/changes/archive/2026-02-04-project-level-spec-structure/specs/global-config/spec.md (1)

8-10: Add language specifier to fenced code block.

The static analysis tool flagged this code block for missing a language specifier. Since this shows a text-based precedence chain, consider adding a language identifier.

Proposed fix
-```
+```text
 project override → global config → default
</details>

</blockquote></details>
<details>
<summary>openspec/specs/global-config/spec.md (1)</summary><blockquote>

`107-109`: **Add language specifier to fenced code block.**

Same as the archived change spec, this code block needs a language identifier.


<details>
<summary>Proposed fix</summary>

```diff
-```
+```text
 project override → global config → default
</details>

</blockquote></details>
<details>
<summary>openspec/changes/archive/2026-02-04-project-level-spec-structure/design.md (2)</summary><blockquote>

`26-31`: **Add language specifier to YAML example.**

The static analysis tool flagged this code block. Since it shows YAML configuration, add the `yaml` language identifier.


<details>
<summary>Proposed fix</summary>

```diff
-```
+```yaml
 specStructure:
   structure: flat        ← valid, kept
   maxDepth: "very deep"  ← invalid, warned + skipped
   validatePaths: false   ← valid, kept
</details>

---

`41-44`: **Add language specifier to pseudo-code block.**

This code block also needs a language identifier. Consider `text` or `typescript` depending on the intent.


<details>
<summary>Proposed fix</summary>

```diff
-```
+```text
 getSpecStructureConfig(projectOverrides?)
   → project value ?? global value ?? default
</details>

</blockquote></details>
<details>
<summary>test/core/project-config.test.ts (1)</summary><blockquote>

`672-689`: **Consider adding upper boundary test for maxDepth.**

The test validates `maxDepth: 0` is rejected. Consider adding a test for `maxDepth: 11` (above max of 10) to ensure both boundaries are enforced.



<details>
<summary>📝 Suggested additional test case</summary>

```typescript
it('should reject maxDepth above valid range', () => {
  const configDir = path.join(tempDir, 'openspec');
  fs.mkdirSync(configDir, { recursive: true });
  fs.writeFileSync(
    path.join(configDir, 'config.yaml'),
    `schema: spec-driven
specStructure:
  maxDepth: 11
`
  );

  const config = readProjectConfig(tempDir);

  expect(config?.specStructure).toBeUndefined();
  expect(consoleWarnSpy).toHaveBeenCalledWith(
    expect.stringContaining("Invalid 'specStructure.maxDepth'")
  );
});
src/core/project-config.ts (1)

167-223: Consider extracting shared specStructure validation logic.

The sub-field parsing logic here is nearly identical to the validation in src/core/global-config.ts (lines 112-132). While acceptable for now, consider extracting a shared helper to reduce duplication if this pattern expands.

src/commands/validate.ts (1)

13-21: Unnecessary async wrapper around synchronous code.

findAllSpecs is synchronous (uses fs.readdirSync), so wrapping it in an async function adds unnecessary overhead. Consider making this synchronous.

♻️ Suggested refactor
-/**
- * Get all spec capabilities using recursive spec discovery.
- * Supports both flat and hierarchical spec structures.
- */
-async function getSpecCapabilities(): Promise<string[]> {
-  const specsDir = path.join(process.cwd(), 'openspec', 'specs');
-  const discovered = findAllSpecs(specsDir);
-  return discovered.map(spec => spec.capability).sort();
-}
+/**
+ * Get all spec capabilities using recursive spec discovery.
+ * Supports both flat and hierarchical spec structures.
+ */
+function getSpecCapabilities(): string[] {
+  const specsDir = path.join(process.cwd(), 'openspec', 'specs');
+  const discovered = findAllSpecs(specsDir);
+  return discovered.map(spec => spec.capability).sort();
+}

Then update call sites at lines 96 and 119:

-const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecCapabilities()]);
+const [changes, specs] = [await getActiveChangeIds(), getSpecCapabilities()];

Hierarchical specs use paths like _global/testing, not flat names.
CLI input may use / or \ regardless of platform. Normalize to native
path.sep so item names match discovered spec/change IDs on any OS.
…ssage

path.basename loses parent segments for hierarchical specs like
_global/testing, showing only "testing" instead of the full path.
Root-level Zod errors have an empty path array, producing ": <msg>"
instead of a useful prefix. Use <root> as fallback label.
The Zod schema allows maxDepth up to 10 but the hard limit silently
capped it at 6, ignoring user configuration for values 7-10.
readdirSync order is OS-dependent. Sorting entries ensures consistent
discovery order regardless of filesystem.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md`:
- Around line 137-181: The auto-detection in isSpecStructureHierarchical is too
brittle: update isSpecStructureHierarchical (and its callers like
getEffectiveStructure) to handle empty/nonexistent specsDir by returning flat
when no specs, compute hierarchicalCount via findAllSpecs(specsDir) and require
a threshold (e.g., hierarchicalCount/total > 0.20 and hierarchicalCount > 0) to
declare hierarchical to avoid single-directory false positives, and add a
consistency check that flags mixed-depth projects (use config.allowMixed to let
callers accept mixed structures or else surface a warning/error) so detection
isn't triggered by accidental nesting.
- Around line 96-136: Update the design to explicitly define sync semantics:
extend findSpecUpdates to represent deletions (e.g., tombstone files or explicit
delete entries) and state whether absence means "no change" or "delete"; specify
applySpecs behavior to create parent directories for new specs and optionally
remove empty dirs after deletions; define atomicity/transaction model
(all-or-nothing using a staging directory + atomic swap or documented
partial-sync behavior with rollback/retry rules) and explain error handling when
one delta fails; add conflict-resolution rules for applySpecs/findSpecUpdates
(detect concurrent changes via checksums/mtimes or require version stamps, and
surface warnings or reject conflicting deltas); add these operational rules into
this design doc or a new "Sync Behavior" decision and reference the functions
findSpecUpdates and applySpecs where you describe the required changes.
- Around line 183-208: Update the "Naming Conventions" rule to specify that the
regex `/^[a-z0-9-_]+$/` applies to each individual path segment (directory name)
rather than the full capability path, and explicitly forbid leading/trailing
separators; extend that rule to include Windows reserved names (CON, PRN, AUX,
NUL, COM1–COM9, LPT1–LPT9) as disallowed segment values; add a new
"Cross-platform path safety" validation that (a) detects case-only collisions
(e.g., `Auth` vs `auth`) and treats them as ERROR, (b) warns when the full path
length exceeds 200 characters (configurable threshold) to protect Windows
260-char limits, and (c) enforces per-segment checks for reserved names and the
segment regex; mention these changes alongside the existing "Depth Limits" and
"No Orphaned Specs" decisions so reviewers can update implementation/validators
accordingly.
🧹 Nitpick comments (3)
openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md (3)

338-360: Consider adding concurrent modification risk.

The risks and trade-offs are well-documented with reasonable mitigations. Consider adding one more:

Risk: Concurrent modifications during multi-spec operations (e.g., user edits spec while openspec sync is running)
Mitigation: Document expected behavior (last-write-wins vs error) or implement file locking for atomic operations.


361-386: Consider providing migration automation.

The migration plan is solid with comprehensive testing and gradual rollout. Two suggestions:

  1. Migration tool - Manual reorganization (line 366) is error-prone for large projects. Consider offering a CLI command like openspec migrate-to-hierarchical that validates and refactors the structure automatically.

  2. Rollback plan - "No rollback needed" (line 384) is optimistic. If a critical bug affects sync operations, users can't easily revert. Consider documenting a rollback procedure (e.g., git revert, config override, or version downgrade steps).


406-435: Excellent post-implementation documentation.

Documenting discovered issues and their fixes is valuable for future reference. These real-world findings validate the importance of end-to-end testing.

Suggestion: Consider adding a "Pre-implementation Checklist" section to help prevent similar integration gaps in future features:

  • Audit all spec-reading code paths for shallow vs recursive assumptions
  • Verify all display code handles variable-length identifiers
  • Check all documentation/schema for hardcoded structure assumptions

Add CON, PRN, AUX, NUL, COM1-9, LPT1-9 as disallowed segment names.
These cause silent failures on Windows filesystems.
…portability

Dual check: warn when full path exceeds 260 chars (Windows MAX_PATH)
or when capability path exceeds 160 chars (portability risk).
@lsmonki lsmonki force-pushed the feature/hierarchical-specs-support branch from ffef439 to a97d7b5 Compare February 4, 2026 18:16
The inner catch in delta spec detection was silently swallowing read
errors. If a spec.md file exists but cannot be read (permissions,
encoding), it was skipped without notice, potentially allowing a
broken change to be archived without validation.
The sync and archive skill templates used specs/*/spec.md which only
matches one level deep, missing hierarchical deltas like
specs/backend/services/api/spec.md.
The test name said "maxDepth of 0" but the config and assertion
both use maxDepth 1.
ViewCommand displays hierarchical specs in discovery order grouped
by domain, not sorted by requirement count. The test incorrectly
asserted descending sort order.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@test/utils/spec-discovery.test.ts`:
- Around line 709-712: The test's reservedNames array contains entries for
capability '..' and '.' whose path fields use path.join which normalizes those
segments; update the path values for the two entries with capability '..' and
'.' to literal/concatenated strings (e.g. '/specs/../spec.md' and
'/specs/./spec.md') or otherwise build them without normalization so the path
fields remain as-written; modify the reservedNames array in
spec-discovery.test.ts (the entries for capability '..' and capability '.')
accordingly.
🧹 Nitpick comments (3)
test/utils/spec-discovery.test.ts (3)

2-2: Unused import: isSpecStructureHierarchical.

This symbol is imported but never referenced in any test case. Either add test coverage for it or remove the import.

♻️ Proposed fix
-import { findAllSpecs, isSpecStructureHierarchical, findSpecUpdates, validateSpecStructure, type DiscoveredSpec } from '../../src/utils/spec-discovery.js';
+import { findAllSpecs, findSpecUpdates, validateSpecStructure, type DiscoveredSpec } from '../../src/utils/spec-discovery.js';

Based on learnings, isSpecStructureHierarchical() is intentionally binary and used only for display formatting — it may still deserve at least a basic test to verify the threshold behavior.


60-69: Inline cleanup risks leaking the fixture on assertion failure.

If expect on line 66 throws, fs.rmSync on line 68 never executes. Consider using a try/finally or moving this into the shared beforeEach/afterEach lifecycle.


853-864: Duplicate tests for default maxDepth.

"should respect config defaults" (line 853) and "should use default maxDepth of 4 when not specified" (line 874) are nearly identical — both pass {} and assert the same depth error message. Consider removing one.

Also applies to: 874-884

The pseudocode used 'relativePath || "."' which implied root-level
spec.md is a valid capability. The actual implementation skips it
with an early continue. Updated snippet to match.
The regex /^[a-z0-9-_]+$/ validates each individual directory segment,
not the full capability path. Added example to prevent confusion.
Filesystem code uses path.join()/path.sep, while docs, prompts,
and display output use forward slashes for consistency.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md`:
- Line 39: The design doc contains a contradiction: the non-goal statement about
"Mixed flat/hierarchical specs" (the sentence at line 39) says mixed specs are
not allowed unless opted-in, but the option declaration allowMixed?: boolean has
a comment saying "// default: true" (line 161). Fix by making the behavior
consistent—either update the non-goal sentence to say mixed flat/hierarchical
specs are allowed by default, or change the allowMixed?: boolean comment to "//
default: false" to require explicit opt-in; reference the non-goal sentence and
the allowMixed? boolean declaration when applying the change so both statements
match.
- Around line 391-398: Document the implicit decisions: add a section stating
that when allowMixed: true the system silently permits mixed flat and
hierarchical specs (no warnings/errors are emitted), and that when allowMixed:
false a mixed layout errors; also clarify the spec creation workflow by noting
there is no dedicated openspec new <spec> command and that spec creation is
performed via the change/delta mechanism (e.g., openspec new change) or by
manual directory configuration under specs/, and explain how users choose flat
vs hierarchical structure (project config or manual directory layout).
🧹 Nitpick comments (1)
openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md (1)

408-437: Consider clarifying the timeline context of "Post-Implementation Fixes".

This section documents fixes for issues discovered during testing, which is valuable. However, since the PR objectives note this is a formal proposal requested by the reviewer, the title "Post-Implementation Fixes" may confuse readers about whether this is a proposal or retrospective documentation.

Consider one of these alternatives:

  1. Retitle to clarify timing: "## Implementation Notes: Issues Discovered During Development"
  2. Add context: "## Post-Implementation Fixes (Documented Retroactively)\n\nSince implementation preceded this formal proposal, these are real issues discovered during development that inform the design:"
  3. Move to a separate "Lessons Learned" section if you want to keep the design decisions separate from implementation experience

This is a documentation structure suggestion - the content itself is valuable and well-documented.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants