Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 45d4b6b207 feat: enhance list command with last modified timestamps and sorting
- Add lastModified field showing when each change was last modified
- Default sort order is now "recent" (most recently modified first)
- Add --sort option to choose between "recent" and "name" ordering
- Add --json option for programmatic access with structured output
- Fall back to directory mtime for empty change directories
- Display relative time (e.g., "2h ago", "3d ago") in human output
2025-12-30 17:21:04 +11:00
Tabish Bidiwale fb7ff527a6 proposal: add artifact workflow CLI commands (Slice 4) (#415)
* proposal: add artifact workflow CLI commands (Slice 4)

Add CLI commands for artifact workflow operations:
- `openspec status --change <id>` - Show artifact completion state
- `openspec next --change <id>` - Show ready artifacts
- `openspec instructions <artifact> --change <id>` - Get enriched template
- `openspec templates --change <id>` - Show template paths
- `openspec new change <name>` - Create new change

Commands are top-level for fluid UX and implemented in isolation
for easy removal (experimental feature).

* fix: remove --change from templates command

Templates are schema-level, not change-level. The command now uses
--schema instead of --change for consistency with how templates
are actually resolved.

* rename: cli-workflow -> cli-artifact-workflow

More specific capability name that clarifies which workflow the CLI
commands are for.

* feat: implement artifact workflow CLI commands (Slice 4)

Add experimental CLI commands for artifact-based workflow management:
- `openspec status --change <id>` - display artifact completion status
- `openspec next --change <id>` - show artifacts ready to create
- `openspec instructions <artifact> --change <id>` - output enriched template
- `openspec templates [--schema <name>]` - show resolved template paths
- `openspec new change <name>` - create new change directory

Features:
- JSON output support (--json flag) for all commands
- Color-coded status indicators (green/yellow/red)
- Progress spinners during loading
- --no-color and NO_COLOR env support
- --schema option for custom schema selection
- Comprehensive error handling with helpful messages

All commands are isolated in src/commands/artifact-workflow.ts for easy
removal if the feature doesn't work out. Help text marks them as experimental.

* fix: update specs glob to match nested directory structure

The schema used specs/*.md but specs are stored as specs/<capability>/spec.md.
Updated to specs/**/*.md so openspec status/next correctly detect spec completion.

* test: update test to match new specs glob pattern

* chore: archive add-artifact-workflow-cli change

- Move change to archive/2025-12-28-add-artifact-workflow-cli
- Create cli-artifact-workflow spec

* feat: unify change state model for scaffolded changes

- Update artifact workflow commands to work with scaffolded changes
- Add draft changes section to dashboard view
- Fix completed changes to require tasks.total > 0
- Archive unify-change-state-model change

* fix: validate change name format to prevent path traversal

Add validation in validateChangeExists() to ensure --change parameter
is a valid kebab-case ID before constructing file paths. This prevents
path traversal attacks like --change "../foo" or --change "/etc/passwd".

- Reuses existing validateChangeName() from change-utils.ts
- Adds 3 tests for path traversal, absolute paths, and slashes
2025-12-29 16:55:56 +11:00
Tabish Bidiwale 11e195575f feat: add instruction loader for template loading and change context (#414)
* feat: add instruction loader for template loading and change context

Add the instruction-loader module that provides:
- loadTemplate: Load templates from schema directories
- loadChangeContext: Combine artifact graph with completion state
- generateInstructions: Enrich templates with change-specific context
- formatChangeStatus: Format change status as readable output

This is Slice 3 of the artifact-graph system, building on the graph
operations (Slice 1) and change creation utilities (Slice 2).

* chore: archive add-instruction-loader change

- Move change to archive as 2025-12-28-add-instruction-loader
- Create instruction-loader spec with 4 requirements

* docs: add purpose description to instruction-loader spec
2025-12-28 17:44:05 +11:00
Tabish Bidiwale ab47cc6b00 feat: restructure schemas as directories with templates (#411)
* feat: restructure schemas as directories with templates

Move built-in schemas from embedded TypeScript objects to a file-based
directory structure. This enables co-located templates alongside schemas.

Changes:
- Remove builtin-schemas.ts (replaced by file-based schemas)
- Add schemas/ directory at package root with spec-driven and tdd schemas
- Update resolveSchema() to load from directory structure
- Resolution checks user dir → package dir

* chore: archive restructure-schema-directories change

* docs: update artifact_poc.md for directory-based schema structure

Update documentation to reflect the new schema structure where schemas
are directories containing schema.yaml and co-located templates/ rather
than single .yaml files with separate template directories.
2025-12-28 17:00:15 +11:00
Tabish Bidiwale 8dcd1707ee proposal: add instruction loader and schema restructure (Slice 3) (#410)
* proposal: add instruction loader and schema restructure (Slice 3)

Adds two change proposals for implementing Slice 3 of the artifact POC:

1. restructure-schema-directories
   - Move schemas from embedded TS objects to self-contained directories
   - Each schema becomes a directory with schema.yaml + templates/
   - Enables co-located templates for user extensibility
   - 2-level resolution: user override → package built-in

2. add-instruction-loader (depends on #1)
   - Load templates from schema directories
   - Enrich templates with change context (dependencies, next steps)
   - Format change status for CLI output
   - New instruction-loader capability

These proposals complete Slice 3 from docs/artifact_poc.md.

* fix: include full requirement block in MODIFIED spec

Update the Schema Loading requirement to include all original scenarios
(modified as needed) per the MODIFIED requirement guidelines. The archiver
replaces the entire requirement with the provided content.
2025-12-26 23:45:36 +11:00
Tabish Bidiwale 4f4af5708d feat: add change creation utilities (#408)
* proposal: add change manager - extract + new functionality

Slice 2 of the artifact tracker POC. Creates ChangeManager module that:

**Extracts existing functionality:**
- `listChanges()` from ListCommand + ChangeCommand.getActiveChanges()
- `changeExists()` from inline fs.access() checks
- `getChangePath()` from inline path.join() calls
- `isInitialized()` from ListCommand directory check

**Adds new functionality:**
- `createChange(name, description?)` - create change directory + README
- `validateName(name)` - enforce kebab-case naming

**Refactors CLI commands to be thin wrappers:**
- ListCommand delegates to ChangeManager
- ChangeCommand delegates to ChangeManager

Also updates docs/artifact_poc.md to reflect XDG decisions from Slice 1.

* proposal: simplify to utility functions only

Remove extraction/refactor scope. Just add:
- createChange(projectRoot, name, description?)
- validateChangeName(name)

Simple utility functions in src/utils/change-utils.ts.
No class, no abstraction layer.

* docs: update artifact_poc.md for simplified Slice 2

- Rename ChangeManager to change-utils (simple utility functions)
- Remove extracted methods (listChanges, getChangePath, etc.)
- Keep only new functionality: createChange(), validateChangeName()
- Update component diagram and summary table

* docs: clarify existing vs new functionality in artifact_poc.md

Audit artifact_poc.md against existing codebase to mark what already
exists vs what's genuinely new:

- Slice 4 CLI table: Added Status column (NEW/EXISTS)
- Added "Existing CLI commands" section listing what's not in scope
- Updated Implementation Order Slice 4 with explicit new vs existing
- Summary table: Updated status + added "What already exists" section

Key finding: The document was already well-simplified. Only truly new
functionality (createChange, validateChangeName, InstructionLoader,
artifact graph CLI commands) is proposed.

* rename: change-manager -> change-creation capability

The capability name "change-manager" implied a manager class abstraction,
but the simplified proposal uses only utility functions. Renamed to
"change-creation" to accurately reflect what the capability provides.

* feat: implement change creation utilities

Add createChange() and validateChangeName() functions for programmatic
change directory creation with kebab-case validation.

- createChange(projectRoot, name) creates openspec/changes/<name>/
- validateChangeName() enforces kebab-case naming conventions
- Comprehensive test coverage (21 tests)

* chore: archive add-change-manager change

Move change to archive and create change-creation spec with
requirements for createChange() and validateChangeName().

* docs: update change-creation spec purpose
2025-12-26 22:14:56 +11:00
Tabish Bidiwale 9822576770 fix(artifact-graph): normalize paths for cross-platform glob compatibility (#407)
Add FileSystemUtils.toPosixPath() utility and consolidate scattered
path normalization patterns. This fixes Windows test failures where
fast-glob couldn't match paths containing backslashes.

Root cause: path.join() uses backslashes on Windows, but fast-glob
requires forward slashes for glob patterns on all platforms.

Changes:
- Add toPosixPath() to FileSystemUtils for cross-platform path handling
- Update artifact-graph/state.ts to normalize glob patterns
- Consolidate path normalization in update.ts, validator.ts, and
  json-converter.ts to use the new utility
2025-12-25 22:42:00 +11:00
Tabish Bidiwale af273b8e0b proposal: add artifact graph core query system (#400)
* proposal: add artifact graph core query system

Add OpenSpec change proposal for Slice 1 of the artifact POC - the core
"What's Ready?" query system. This implements:

- ArtifactGraph class for DAG-based dependency modeling
- Filesystem-based state detection (file existence = completion)
- Topological sort for build order calculation
- Ready/blocked artifact queries

This is a parallel module that will coexist with the current system.

* docs: specify Zod for schema validation in artifact graph proposal

- Add decision section for Zod schema validation in design.md
- Update data structures to show Zod schemas with z.infer<> types
- Update tasks to specify Zod usage for type definitions and parsing

* docs: add 2-level schema resolution and built-in schemas

- Add decision for global → built-in schema resolution pattern
- Add resolver.ts for schema lookup logic
- Add built-in schemas directory (spec-driven.yaml, tdd.yaml)
- Add schema resolution tests
- Follows ESLint/Prettier/Git patterns (defaults baked in package)

* experiment: add vertical slice version of artifact graph change

Creates add-artifact-graph-core-v2 with requirements organized as
vertical slices - each requirement file contains its spec, design
decisions, and tasks bundled together for comparison.

* feat(core): add getGlobalDataDir for XDG-compliant data directory

Add getGlobalDataDir() function following XDG Base Directory Specification
for storing user data like schema overrides:
- XDG_DATA_HOME takes precedence on all platforms
- Unix/macOS fallback: ~/.local/share/openspec/
- Windows fallback: %LOCALAPPDATA%/openspec/

* feat(artifact-graph): add core dependency graph module

Implement Slice 1 ("What's Ready?") of the artifact graph system:

- types.ts: Zod schemas for artifact definitions with derived TypeScript types
- schema.ts: YAML parsing with validation for duplicates, invalid refs, cycles
- graph.ts: ArtifactGraph class with Kahn's algorithm for topological sort
- state.ts: Filesystem-based completion detection with glob pattern support
- resolver.ts: Two-level schema resolution (global override → built-in)
- builtin-schemas.ts: spec-driven and tdd workflow definitions

Key design decisions:
- Filesystem as database (stateless, git-friendly)
- Cycle errors show full path (e.g., "A → B → C → A")
- Deterministic ordering via sorted queues

* test(artifact-graph): add comprehensive test suite

52 tests covering all artifact-graph functionality:

- schema.test.ts: Parsing, validation errors, cycle detection
- graph.test.ts: Build order, ready artifacts, blocked queries
- state.test.ts: File existence, glob patterns, missing directories
- resolver.test.ts: Schema resolution with global overrides

* docs(openspec): archive add-artifact-graph-core change

Archive completed change proposal and create artifact-graph spec with
6 requirements covering schema loading, build order, state detection,
ready queries, completion checks, and blocked queries.

* chore: remove experimental artifact-graph-core-v2 folder

Clean up experimental vertical slice proposal that is no longer needed.

* feat(artifact-graph): validate global schema overrides

Global schema overrides are now validated through the same pipeline as
built-in schemas, catching invalid schemas, cyclic dependencies, and
invalid requires references at load time. Added SchemaLoadError for
better error context with file paths.

* test(artifact-graph): add workflow integration tests

Add end-to-end integration tests that exercise the full artifact-graph
pipeline: resolveSchema → ArtifactGraph → detectCompleted → queries.

Tests cover:
- Complete spec-driven and tdd workflow progressions
- Out-of-order file creation handling
- Glob pattern matching with multiple files
- Build order consistency
- Edge cases (empty/missing directories, non-matching files)

* refactor(artifact-graph): adopt zod v4 error message format

Update custom error messages from string format to zod v4 object format
using `{ error: 'message' }` convention.

* fix(test): prevent hanging vitest threads after test runs

- Add teardownTimeout (3s) to vitest config for forced cleanup
- Add global teardown function to vitest.setup.ts
- Call child.unref() to prevent child processes from blocking event loop
- Explicitly destroy stdio streams on process close/error
2025-12-25 22:09:42 +11:00
Eunsong-Park 3ceef2db72 fix(archive): allow REMOVED requirements when creating new spec files (#403) (#404)
When creating a new spec file, REMOVED requirements are now ignored
with a warning instead of causing archive to fail. This enables
refactoring scenarios where old fields are removed while documenting
a capability for the first time.

Fixes #403
2025-12-25 03:44:34 +11:00
Tabish Bidiwale 2c2599b1f0 docs: add artifact POC analysis document (#398)
Add internal documentation for the artifact-based approach to OpenSpec
core. This document outlines design decisions, terminology, and the
philosophy behind treating dependencies as enablers rather than gates.
2025-12-23 22:21:11 +11:00
github-actions[bot]andTabish Bidiwale c08a53cb21 chore(release): version packages (#397)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-12-23 12:59:10 +11:00
Tabish Bidiwale 455c65f3c4 Add changeset for --no-interactive flag fix (#396) 2025-12-23 12:45:56 +11:00
Tabish Bidiwale 9ac6330430 fix(cli): respect --no-interactive flag in validate command (#395)
* fix(cli): respect --no-interactive flag in validate command

The validate command's spinner was starting regardless of the
--no-interactive flag, causing hangs in pre-commit hooks.

Changes:
- Pass noInteractive option to runBulkValidation
- Handle Commander.js --no-* flag syntax (sets interactive=false)
- Only start ora spinner when in interactive mode
- Add CI environment variable check to isInteractive() for industry
  standard compliance

* test: add unit tests for interactive utilities and CLI flag

- Export resolveNoInteractive() helper for reuse
- Add InteractiveOptions type export for testing
- Refactor validate.ts to use resolveNoInteractive()
- Add 17 unit tests for isInteractive() and resolveNoInteractive()
- Add CLI integration test for --no-interactive flag

This prevents future regressions where Commander.js --no-* flag
parsing is not properly handled.
2025-12-23 12:42:25 +11:00
82 changed files with 7297 additions and 72 deletions
-2
View File
@@ -140,8 +140,6 @@ dist/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Internal Docs
docs/
# Claude
.claude/
+6
View File
@@ -1,5 +1,11 @@
# @fission-ai/openspec
## 0.17.2
### Patch Changes
- 455c65f: Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments
## 0.17.1
### Patch Changes
+597
View File
@@ -0,0 +1,597 @@
# POC-OpenSpec-Core Analysis
---
## Design Decisions & Terminology
### Philosophy: Not a Workflow System
This system is **not** a workflow engine. It's an **artifact tracker with dependency awareness**.
| What it's NOT | What it IS |
|---------------|------------|
| Linear step-by-step progression | Exploratory, iterative planning |
| Bureaucratic checkpoints | Enablers that unlock possibilities |
| "You must complete step 1 first" | "Here's what you could create now" |
| Form-filling | Fluid document creation |
**Key insight:** Dependencies are *enablers*, not *gates*. You can't meaningfully write a design document if there's no proposal to design from - that's not bureaucracy, it's logic.
### Terminology
| Term | Definition | Example |
|------|------------|---------|
| **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` |
| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `spec-driven.yaml` |
| **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` |
| **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` |
### Hierarchy
```
Schema (defines) ──→ Artifacts (guided by) ──→ Templates
```
- **Schema** = the artifact graph (what exists, dependencies)
- **Artifact** = a document to produce
- **Template** = instructions for creating that artifact
### Schema Variations
Schemas can vary across multiple dimensions:
| Dimension | Examples |
|-----------|----------|
| Philosophy | `spec-driven`, `tdd`, `prototype-first` |
| Version | `v1`, `v2`, `v3` |
| Language | `en`, `zh`, `es` |
| Custom | `team-alpha`, `experimental` |
### Schema Resolution (XDG Standard)
Schemas follow the XDG Base Directory Specification with a 2-level resolution:
```
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # Global user override
2. <package>/schemas/<name>/schema.yaml # Built-in defaults
```
**Platform-specific paths:**
- Unix/macOS: `~/.local/share/openspec/schemas/`
- Windows: `%LOCALAPPDATA%/openspec/schemas/`
- All platforms: `$XDG_DATA_HOME/openspec/schemas/` (when set)
**Why XDG?**
- Schemas are workflow definitions (data), not user preferences (config)
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Consistent with modern CLI tooling standards
### Template Inheritance (2 Levels Max)
Templates are co-located with schemas in a `templates/` subdirectory:
```
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
2. <package>/schemas/<schema>/templates/<artifact>.md # Built-in
```
**Rules:**
- User overrides take precedence over package built-ins
- A CLI command shows resolved paths (no guessing)
- No inheritance between schemas (copy if you need to diverge)
- Templates are always co-located with their schema
**Why this matters:**
- Avoids "where does this come from?" debugging
- No implicit magic that works until it doesn't
- Schema + templates form a cohesive unit
---
## Executive Summary
This is an **artifact tracker with dependency awareness** that guides iterative development through a structured artifact pipeline. The core innovation is using the **filesystem as a database** - artifact completion is detected by file existence, making the system stateless and version-control friendly.
The system answers:
- "What artifacts exist for this change?"
- "What could I create next?" (not "what must I create")
- "What's blocking X?" (informational, not prescriptive)
---
## Core Components
### 1. ArtifactGraph (Slice 1 - COMPLETE)
The dependency graph engine with XDG-compliant schema resolution.
| Responsibility | Approach |
|----------------|----------|
| Model artifacts as a DAG | Artifact with `requires: string[]` |
| Track completion state | `Set<string>` for completed artifacts |
| Calculate build order | Kahn's algorithm (topological sort) |
| Find ready artifacts | Check if all dependencies are in `completed` set |
| Resolve schemas | XDG global → package built-ins |
**Key Data Structures (Zod-validated):**
```typescript
// Zod schemas define types + validation
const ArtifactSchema = z.object({
id: z.string().min(1),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
const SchemaYamlSchema = z.object({
name: z.string().min(1),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1),
});
// Derived types
type Artifact = z.infer<typeof ArtifactSchema>;
type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Key Methods:**
- `resolveSchema(name)` - Load schema with XDG fallback
- `ArtifactGraph.fromSchema(schema)` - Build graph from schema
- `detectState(graph, changeDir)` - Scan filesystem for completion
- `getNextArtifacts(graph, completed)` - Find artifacts ready to create
- `getBuildOrder(graph)` - Topological sort of all artifacts
- `getBlocked(graph, completed)` - Artifacts with unmet dependencies
---
### 2. Change Utilities (Slice 2)
Simple utility functions for programmatic change creation. No class, no abstraction layer.
| Responsibility | Approach |
|----------------|----------|
| Create changes | Create dirs under `openspec/changes/<name>/` with README |
| Name validation | Enforce kebab-case naming |
**Key Paths:**
```
openspec/changes/<name>/ → Change instances with artifacts (project-level)
```
**Key Functions** (`src/utils/change-utils.ts`):
- `createChange(projectRoot, name, description?)` - Create new change directory + README
- `validateChangeName(name)` - Validate kebab-case naming, returns `{ valid, error? }`
**Note:** Existing CLI commands (`ListCommand`, `ChangeCommand`) already handle listing, path resolution, and existence checks. No need to extract that logic - it works fine as-is.
---
### 3. InstructionLoader (Slice 3)
Template resolution and instruction enrichment.
| Responsibility | Approach |
|----------------|----------|
| Resolve templates | XDG 2-level fallback (schema-specific → shared → built-in) |
| Build dynamic context | Gather dependency status, change info |
| Enrich templates | Inject context into base templates |
| Generate status reports | Formatted markdown with progress |
**Key Class - ChangeState:**
```
ChangeState {
changeName: string
changeDir: string
graph: ArtifactGraph
completed: Set<string>
// Methods
getNextSteps(): string[]
getStatus(artifactId): ArtifactStatus
isComplete(): boolean
}
```
**Key Functions:**
- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback
- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point
- `getChangeStatus(projectRoot, changeName?)` - Formatted status report
---
### 4. CLI (Slice 4)
User interface layer. **All commands are deterministic** - require explicit `--change` parameter.
| Command | Function | Status |
|---------|----------|--------|
| `status --change <id>` | Show change progress (artifact graph) | **NEW** |
| `next --change <id>` | Show artifacts ready to create | **NEW** |
| `instructions <artifact> --change <id>` | Get enriched instructions for artifact | **NEW** |
| `list` | List all changes | EXISTS (`openspec change list`) |
| `new <name>` | Create change | **NEW** (uses `createChange()`) |
| `init` | Initialize structure | EXISTS (`openspec init`) |
| `templates --change <id>` | Show resolved template paths | **NEW** |
**Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly.
**Existing CLI commands** (not part of this slice):
- `openspec change list` / `openspec change show <id>` / `openspec change validate <id>`
- `openspec list --changes` / `openspec list --specs`
- `openspec view` (dashboard)
- `openspec init` / `openspec archive <change>`
---
### 5. Claude Commands
Integration layer for Claude Code. **Operational commands only** - artifact creation via natural language.
| Command | Purpose |
|---------|---------|
| `/status` | Show change progress |
| `/next` | Show what's ready to create |
| `/run [artifact]` | Execute a specific step (power users) |
| `/list` | List all changes |
| `/new <name>` | Create a new change |
| `/init` | Initialize structure |
**Artifact creation:** Users say "create the proposal" or "write the tests" in natural language. The agent:
1. Infers change from conversation (confirms if uncertain)
2. Infers artifact from request
3. Calls CLI with explicit `--change` parameter
4. Creates artifact following instructions
This works for ANY artifact in ANY schema - no new slash commands needed when schemas change.
**Note:** Legacy commands (`/openspec-proposal`, `/openspec-apply`, `/openspec-archive`) exist in the main project for backward compatibility but are separate from this architecture.
---
## Component Dependency Graph
```
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ CLI │ ←─shell exec───────│ Claude Commands │ │
│ └──────┬───────┘ └────────────────────┘ │
└─────────┼───────────────────────────────────────────────────┘
│ imports
▼
┌─────────────────────────────────────────────────────────────┐
│ ORCHESTRATION LAYER │
│ ┌────────────────────┐ ┌──────────────────────────┐ │
│ │ InstructionLoader │ │ change-utils (Slice 2) │ │
│ │ (Slice 3) │ │ createChange() │ │
│ └─────────┬──────────┘ │ validateChangeName() │ │
│ │ └──────────────────────────┘ │
└────────────┼────────────────────────────────────────────────┘
│ uses
▼
┌─────────────────────────────────────────────────────────────┐
│ CORE LAYER │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ArtifactGraph (Slice 1) │ │
│ │ │ │
│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│ reads from
▼
┌─────────────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌──────────────────┐ ┌────────────────────────────────┐ │
│ │ XDG Schemas │ │ Project Artifacts │ │
│ │ ~/.local/share/ │ │ openspec/changes/<name>/ │ │
│ │ openspec/ │ │ - proposal.md, design.md │ │
│ │ schemas/ │ │ - specs/*.md, tasks.md │ │
│ └──────────────────┘ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Key Design Patterns
### 1. Filesystem as Database
No SQLite, no JSON state files. The existence of `proposal.md` means proposal is complete.
```
// State detection is just file existence checking
if (exists(artifactPath)) {
completed.add(artifactId)
}
```
### 2. Deterministic CLI, Inferring Agent
**CLI layer:** Always deterministic - requires explicit `--change` parameter.
```
openspec status --change add-auth # explicit, works
openspec status # error: "No change specified"
```
**Agent layer:** Infers from conversation, confirms if uncertain, passes explicit `--change`.
This separation means:
- CLI is pure, testable, no state to corrupt
- Agent handles all "smartness"
- No config.yaml tracking of "active change"
### 3. XDG-Compliant Schema Resolution
```
${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
↓ (not found)
<package>/schemas/<name>/schema.yaml # Built-in
↓ (not found)
Error (schema not found)
```
### 4. Two-Level Template Fallback
```
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
↓ (not found)
<package>/schemas/<schema>/templates/<artifact>.md # Built-in
↓ (not found)
Error (no silent fallback to avoid confusion)
```
### 5. Glob Pattern Support
`specs/*.md` allows multiple files to satisfy a single artifact:
```
if (artifact.generates.includes("*")) {
const parentDir = changeDir / patternParts[0]
if (exists(parentDir) && hasFiles(parentDir)) {
completed.add(artifactId)
}
}
```
### 6. Stateless State Detection
Every command re-scans the filesystem. No cached state to corrupt.
---
## Artifact Pipeline (Default Schema)
The default `spec-driven` schema:
```
┌──────────┐
│ proposal │ (no dependencies)
└────┬─────┘
│
▼
┌──────────┐
│ specs │ (requires: proposal)
└────┬─────┘
│
├──────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ design │ │ │
│ │◄──┤ proposal │
└────┬─────┘ └──────────┘
│ (requires: proposal, specs)
▼
┌──────────┐
│ tasks │ (requires: design)
└──────────┘
```
Other schemas (TDD, prototype-first) would have different graphs.
---
## Implementation Order
Structured as **vertical slices** - each slice is independently testable.
---
### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE
**Delivers:** Types + Graph + State Detection + Schema Resolution
**Implementation:** `src/core/artifact-graph/`
- `types.ts` - Zod schemas and derived TypeScript types
- `schema.ts` - YAML parsing with Zod validation
- `graph.ts` - ArtifactGraph class with topological sort
- `state.ts` - Filesystem-based state detection
- `resolver.ts` - XDG-compliant schema resolution
- `builtin-schemas.ts` - Package-bundled default schemas
**Key decisions made:**
- Zod for schema validation (consistent with project)
- XDG for global schema overrides
- `Set<string>` for completion state (immutable, functional)
- `inProgress` and `failed` states deferred (require external tracking)
---
### Slice 2: "Change Creation Utilities"
**Delivers:** Utility functions for programmatic change creation
**Scope:**
- `createChange(projectRoot, name, description?)` → creates directory + README
- `validateChangeName(name)` → kebab-case pattern enforcement
**Not in scope (already exists in CLI commands):**
- `listChanges()` → exists in `ListCommand` and `ChangeCommand.getActiveChanges()`
- `getChangePath()` → simple `path.join()` inline
- `changeExists()` → simple `fs.access()` inline
- `isInitialized()` → simple directory check inline
**Why simplified:** Extracting existing CLI logic into a class would require similar refactoring of `SpecCommand` for consistency. The existing code works fine (~15 lines each). Only truly new functionality is `createChange()` + name validation.
---
### Slice 3: "Get Instructions" (Enrichment)
**Delivers:** Template resolution + context injection
**Testable behaviors:**
- Template fallback: schema-specific → shared → built-in → error
- Context injection: completed deps show ✓, missing show ✗
- Output path shown correctly based on change directory
---
### Slice 4: "CLI + Integration"
**Delivers:** New artifact graph commands (builds on existing CLI)
**New commands:**
- `status --change <id>` - Show artifact completion state
- `next --change <id>` - Show ready-to-create artifacts
- `instructions <artifact> --change <id>` - Get enriched template
- `templates --change <id>` - Show resolved paths
- `new <name>` - Create change (wrapper for `createChange()`)
**Already exists (not in scope):**
- `openspec change list/show/validate` - change management
- `openspec list --changes/--specs` - listing
- `openspec view` - dashboard
- `openspec init` - initialization
**Testable behaviors:**
- Each new command produces expected output
- Commands compose correctly (status → next → instructions flow)
- Error handling for missing changes, invalid artifacts, etc.
---
## Directory Structure
```
# Global (XDG paths - user overrides)
~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/)
%LOCALAPPDATA%/openspec/ # Windows
└── schemas/ # Schema overrides
└── custom-workflow/ # User-defined schema directory
├── schema.yaml # Schema definition
└── templates/ # Co-located templates
└── proposal.md
# Package (built-in defaults)
<package>/
└── schemas/ # Built-in schema definitions
├── spec-driven/ # Default: proposal → specs → design → tasks
│ ├── schema.yaml
│ └── templates/
│ ├── proposal.md
│ ├── design.md
│ ├── spec.md
│ └── tasks.md
└── tdd/ # TDD: tests → implementation → docs
├── schema.yaml
└── templates/
├── test.md
├── implementation.md
├── spec.md
└── docs.md
# Project (change instances)
openspec/
└── changes/ # Change instances
├── add-auth/
│ ├── README.md # Auto-generated on creation
│ ├── proposal.md # Created artifacts
│ ├── design.md
│ └── specs/
│ └── *.md
├── refactor-db/
│ └── ...
└── archive/ # Completed changes
└── 2025-01-01-add-auth/
.claude/
├── settings.local.json # Permissions
└── commands/ # Slash commands
└── *.md
```
---
## Schema YAML Format
```yaml
# Built-in: <package>/schemas/spec-driven/schema.yaml
# Or user override: ~/.local/share/openspec/schemas/spec-driven/schema.yaml
name: spec-driven
version: 1
description: Specification-driven development
artifacts:
- id: proposal
generates: "proposal.md"
description: "Create project proposal document"
template: "proposal.md" # resolves from co-located templates/ directory
requires: []
- id: specs
generates: "specs/*.md" # glob pattern
description: "Create technical specification documents"
template: "specs.md"
requires:
- proposal
- id: design
generates: "design.md"
description: "Create design document"
template: "design.md"
requires:
- proposal
- specs
- id: tasks
generates: "tasks.md"
description: "Create tasks breakdown document"
template: "tasks.md"
requires:
- design
```
---
## Summary
| Layer | Component | Responsibility | Status |
|-------|-----------|----------------|--------|
| Core | ArtifactGraph | Pure dependency logic + XDG schema resolution | ✅ Slice 1 COMPLETE |
| Utils | change-utils | Change creation + name validation only | Slice 2 (new functionality only) |
| Core | InstructionLoader | Template resolution + enrichment | Slice 3 (all new) |
| Presentation | CLI | New artifact graph commands | Slice 4 (new commands only) |
| Integration | Claude Commands | AI assistant glue | Slice 4 |
**What already exists (not in this proposal):**
- `getActiveChangeIds()` in `src/utils/item-discovery.ts` - list changes
- `ChangeCommand.list/show/validate()` in `src/commands/change.ts`
- `ListCommand.execute()` in `src/core/list.ts`
- `ViewCommand.execute()` in `src/core/view.ts` - dashboard
- `src/core/init.ts` - initialization
- `src/core/archive.ts` - archiving
**Key Principles:**
- **Filesystem IS the database** - stateless, version-control friendly
- **Dependencies are enablers** - show what's possible, don't force order
- **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context
- **XDG-compliant paths** - schemas and templates use standard user data directories
- **2-level inheritance** - user override → package built-in (no deeper)
- **Schemas are versioned** - support variations by philosophy, version, language
@@ -0,0 +1,197 @@
## Context
This implements "Slice 1: What's Ready?" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.
This module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.
## Goals / Non-Goals
**Goals:**
- Pure dependency graph logic with no side effects
- Stateless state detection (rescan filesystem each query)
- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)
- Load artifact definitions from YAML schemas
- Calculate topological build order
- Determine "ready" artifacts based on dependency completion
**Non-Goals:**
- CLI commands (Slice 4)
- Multi-change management (Slice 2)
- Template resolution and enrichment (Slice 3)
- Agent integration or Claude commands
- Replacing existing OpenSpec functionality
## Decisions
### Decision: Filesystem as Database
Use file existence for state detection rather than a separate state file.
**Rationale:**
- Stateless - no state corruption possible
- Git-friendly - state derived from committed files
- Simple - no sync issues between state file and actual files
**Alternatives considered:**
- JSON/SQLite state file: More complex, sync issues, not git-friendly
- Git metadata: Too coupled to git, complex implementation
### Decision: Kahn's Algorithm for Topological Sort
Use Kahn's algorithm for computing build order.
**Rationale:**
- Well-understood, O(V+E) complexity
- Naturally detects cycles during execution
- Produces a stable, deterministic order
### Decision: Glob Pattern Support
Support glob patterns like `specs/*.md` in artifact `generates` field.
**Rationale:**
- Allows multiple files to satisfy a single artifact requirement
- Common pattern for spec directories with multiple files
- Uses standard glob syntax
### Decision: Immutable Completed Set
Represent completion state as an immutable Set of completed artifact IDs.
**Rationale:**
- Functional style, easier to reason about
- State derived fresh each query, no mutation needed
- Clear separation between graph structure and runtime state
- Filesystem can only detect binary existence (complete vs not complete)
**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.
### Decision: Zod for Schema Validation
Use Zod for validating YAML schema structure and deriving TypeScript types.
**Rationale:**
- Already a project dependency (v4.0.17) used in `src/core/schemas/`
- Type inference via `z.infer<>` - single source of truth for types
- Runtime validation with detailed error messages
- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)
**Alternatives considered:**
- Manual validation: More code, error-prone, no type inference
- JSON Schema: Would require additional dependency, less TypeScript integration
- io-ts: Not already in project, steeper learning curve
### Decision: Two-Level Schema Resolution
Schemas resolve from global user data directory, falling back to package built-ins.
**Resolution order:**
1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override
2. `<package>/schemas/<name>.yaml` - Built-in defaults
**Rationale:**
- Follows XDG Base Directory Specification (schemas are data, not config)
- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Simple - no project-level overrides (can add later if needed)
**XDG compliance:**
- Uses `XDG_DATA_HOME` env var when set (all platforms)
- Unix/macOS fallback: `~/.local/share/openspec/`
- Windows fallback: `%LOCALAPPDATA%/openspec/`
**Alternatives considered:**
- Project-level overrides: Added complexity, not needed initially
- Auto-copy to user space: Creates drift, harder to update defaults
- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)
### Decision: Template Field Parsed But Not Resolved
The `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.
**Rationale:**
- Slice 1 focuses on "What's Ready?" - dependency and completion queries only
- Template paths are validated syntactically (non-empty string) but not resolved
- Keeps Slice 1 focused and independently testable
### Decision: Cycle Error Format
Cycle errors list all artifact IDs in the cycle for easy debugging.
**Format:** `"Cyclic dependency detected: A → B → C → A"`
**Rationale:**
- Shows the full cycle path, not just that a cycle exists
- Actionable - developer can see exactly which artifacts to fix
- Consistent with Kahn's algorithm which naturally identifies cycle participants
## Data Structures
**Zod Schemas (source of truth):**
```typescript
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, 'Artifact ID is required'),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, 'Schema name is required'),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Runtime State (not Zod - internal only):**
```typescript
// Slice 1: Simple completion tracking via filesystem
type CompletedSet = Set<string>;
// Return type for blocked query
interface BlockedArtifacts {
[artifactId: string]: string[]; // artifact → list of unmet dependencies
}
interface ArtifactGraphResult {
completed: string[];
ready: string[];
blocked: BlockedArtifacts;
buildOrder: string[];
}
```
## File Structure
```
src/core/artifact-graph/
├── index.ts # Public exports
├── types.ts # Zod schemas and type definitions
├── graph.ts # ArtifactGraph class
├── state.ts # State detection logic
├── resolver.ts # Schema resolution (global → built-in)
└── schemas/ # Built-in schema definitions (package level)
├── spec-driven.yaml # Default: proposal → specs → design → tasks
└── tdd.yaml # Alternative: tests → implementation → docs
```
**Schema Resolution Paths:**
- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`
- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |
| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |
| Schema evolution | Version field in schema, validate on load |
## Open Questions
None - all questions resolved in Decisions section.
@@ -0,0 +1,18 @@
## Why
The current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic "what's ready?" queries, making the system more predictable and enabling future features like automated pipeline execution.
## What Changes
- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships
- Add `ArtifactState` type to track completion status (completed, in_progress, failed)
- Add filesystem-based state detection using file existence and glob patterns
- Add schema YAML parser to load artifact definitions
- Implement topological sort (Kahn's algorithm) for build order calculation
- Add `getNextArtifacts()` to find artifacts ready for creation
## Impact
- Affected specs: New `artifact-graph` capability
- Affected code: `src/core/artifact-graph/` (new directory)
- No changes to existing functionality - this is a parallel module
@@ -0,0 +1,103 @@
## ADDED Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
@@ -0,0 +1,61 @@
## 1. Type Definitions
- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`
- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state
## 2. Schema Parser
- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`
- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)
- [x] 2.3 Implement duplicate artifact ID detection
- [x] 2.4 Add cycle detection during schema load (error format: "Cyclic dependency detected: A → B → C → A")
## 3. Artifact Graph Core
- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class
- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file
- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm
- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition
- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts
## 4. State Detection
- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic
- [x] 4.2 Implement file existence checking for simple paths
- [x] 4.3 Implement glob pattern matching for multi-file artifacts
- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet
- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)
## 5. Ready Calculation
- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed
- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done
- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)
## 6. Schema Resolution
- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic
- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)
- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback
## 7. Built-in Schemas
- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)
- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)
## 8. Integration
- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports
## 9. Testing
- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph
- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error
- [x] 9.3 Test: Duplicate artifact IDs throws error
- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID
- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., "A → B → C → A")
- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)
- [x] 9.7 Test: Compute build order handles diamond dependencies correctly
- [x] 9.8 Test: Independent artifacts return in stable order
- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet
- [x] 9.10 Test: File existence marks artifact as completed
- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist
- [x] 9.12 Test: Glob pattern with empty directory not marked complete
- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed
- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed
- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed
- [x] 9.16 Test: isComplete() returns true when all artifacts completed
- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete
- [x] 9.18 Test: Schema resolution finds global override before built-in
- [x] 9.19 Test: Schema resolution falls back to built-in when no global
@@ -0,0 +1,74 @@
## Context
This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.
**Current state:** No programmatic way to create changes. Users must manually create directories.
**Proposed state:** Utility functions for change creation with name validation.
## Goals / Non-Goals
### Goals
- **Add** `createChange()` function to create change directories
- **Add** `validateChangeName()` function for kebab-case validation
- **Enable** automation (Claude commands, scripts) to create changes
### Non-Goals
- Refactor existing CLI commands (they work fine)
- Create abstraction layers or manager classes
- Change how `ListCommand` or `ChangeCommand` work
## Decisions
### Decision 1: Simple Utility Functions
**Choice**: Add functions to `src/utils/change-utils.ts` - no class.
```typescript
// src/utils/change-utils.ts
export function validateChangeName(name: string): { valid: boolean; error?: string }
export async function createChange(
projectRoot: string,
name: string
): Promise<void>
```
**Why**:
- Simple, no abstraction overhead
- Easy to test
- Easy to import where needed
- Matches existing utility patterns in `src/utils/`
**Alternatives considered**:
- ChangeManager class: Rejected - over-engineered for 2 functions
- Add to existing command: Rejected - mixes CLI with reusable logic
### Decision 2: Kebab-Case Validation Pattern
**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`
Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`
**Why**:
- Filesystem-safe (no special characters)
- URL-safe (for future web UI)
- Consistent with existing change naming in repo
## File Changes
### New Files
- `src/utils/change-utils.ts` - Utility functions
- `src/utils/change-utils.test.ts` - Unit tests
### Modified Files
- None
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Function might not cover all use cases | Start simple, extend if needed |
| Naming conflicts with future work | Using clear, specific function names |
@@ -0,0 +1,45 @@
## Why
There's no programmatic way to create a new change directory. Users must manually:
1. Create `openspec/changes/<name>/` directory
2. Create a `proposal.md` file
3. Hope they got the naming right
This is error-prone and blocks automation (e.g., Claude commands, scripts).
**This proposal adds:**
1. `createChange(projectRoot, name)` - Create change directories programmatically
2. `validateChangeName(name)` - Enforce kebab-case naming conventions
## What Changes
### New Utilities
| Function | Description |
|----------|-------------|
| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |
| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |
### Name Validation Rules
Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
| Valid | Invalid |
|-------|---------|
| `add-auth` | `Add-Auth` (uppercase) |
| `refactor-db` | `add auth` (spaces) |
| `add-feature-2` | `add_auth` (underscores) |
| `refactor` | `-add-auth` (leading hyphen) |
### Location
New file: `src/utils/change-utils.ts`
Simple utility functions - no class, no abstraction layer.
## Impact
- **Affected specs**: None
- **Affected code**: None (new utilities only)
- **New files**: `src/utils/change-utils.ts`
- **Breaking changes**: None
@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
@@ -0,0 +1,30 @@
## Phase 1: Implement Name Validation
- [x] 1.1 Create `src/utils/change-utils.ts`
- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern
- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
- [x] 1.4 Return `{ valid: boolean; error?: string }`
- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
- [x] 1.6 Add test: uppercase rejected
- [x] 1.7 Add test: spaces rejected
- [x] 1.8 Add test: underscores rejected
- [x] 1.9 Add test: special characters rejected
- [x] 1.10 Add test: leading/trailing hyphens rejected
- [x] 1.11 Add test: consecutive hyphens rejected
## Phase 2: Implement Change Creation
- [x] 2.1 Implement `createChange(projectRoot, name)`
- [x] 2.2 Validate name before creating
- [x] 2.3 Create parent directories if needed (`openspec/changes/`)
- [x] 2.4 Throw if change already exists
- [x] 2.5 Add test: creates directory
- [x] 2.6 Add test: duplicate change throws error
- [x] 2.7 Add test: invalid name throws validation error
- [x] 2.8 Add test: creates parent directories if needed
## Phase 3: Integration
- [x] 3.1 Export functions from `src/utils/index.ts`
- [x] 3.2 Add JSDoc comments
- [x] 3.3 Run all tests to verify no regressions
@@ -0,0 +1,112 @@
## Context
Slice 4 of the artifact workflow POC. The core functionality (ArtifactGraph, InstructionLoader, change-utils) is complete. This slice adds CLI commands to expose the artifact workflow to users.
**Key constraint**: This is experimental. Commands must be isolated for easy removal if the feature doesn't work out.
## Goals / Non-Goals
- **Goals:**
- Expose artifact workflow status and instructions via CLI
- Provide fluid UX with top-level verb commands
- Support both human-readable and JSON output
- Enable agents to programmatically query workflow state
- Keep implementation isolated for easy removal
- **Non-Goals:**
- Interactive artifact creation wizards (future work)
- Schema management commands (deferred)
- Auto-detection of active change (CLI is deterministic, agents infer)
## Decisions
### Command Structure: Top-Level Verbs
Commands are top-level for maximum fluidity:
```
openspec status --change <id>
openspec next --change <id>
openspec instructions <artifact> --change <id>
openspec templates [--schema <name>]
openspec new change <name>
```
**Rationale:**
- Most fluid UX - fewest keystrokes
- Commands are unique enough to avoid conflicts
- Simple mental model for users
**Trade-off accepted:** Slight namespace pollution, but commands are distinct and can be removed cleanly.
### Experimental Isolation
All artifact workflow commands are implemented in a single file:
```
src/commands/artifact-workflow.ts
```
**To remove the feature:**
1. Delete `src/commands/artifact-workflow.ts`
2. Remove ~5 lines from `src/cli/index.ts`
No other files touched, no risk to stable functionality.
### Deterministic CLI with Explicit `--change`
All change-specific commands require `--change <id>`:
```bash
openspec status --change add-auth # explicit, works
openspec status # error: missing --change
```
**Rationale:**
- CLI is pure, testable, no hidden state
- Agents infer change from conversation and pass explicitly
- No config file tracking "active change"
- Consistent with POC design philosophy
### New Change Command Structure
Creating changes uses explicit subcommand:
```bash
openspec new change add-feature
```
**Rationale:**
- `openspec new <name>` is ambiguous (new what?)
- `openspec new change <name>` is clear and extensible
- Can add `openspec new spec <name>` later if needed
### Output Formats
- **Default**: Human-readable text with visual indicators
- Status: `[x]` done, `[ ]` ready, `[-]` blocked
- Colors: green (done), yellow (ready), red (blocked)
- **JSON** (`--json`): Machine-readable for scripts and agents
### Error Handling
- Missing `--change`: Error listing available changes
- Unknown change: Error with suggestion
- Unknown artifact: Error listing valid artifacts
- Missing schema: Error with schema resolution details
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Top-level commands pollute namespace | Commands are distinct; isolated for easy removal |
| `status` confused with git | Context (`--change`) makes it clear |
| Feature doesn't work out | Single file deletion removes everything |
## Implementation Notes
- All commands in `src/commands/artifact-workflow.ts`
- Imports from `src/core/artifact-graph/` for all operations
- Uses `getActiveChangeIds()` from `item-discovery.ts` for change listing
- Follows existing CLI patterns (ora spinners, commander.js options)
- Help text marks commands as "Experimental"
@@ -0,0 +1,33 @@
## Why
The ArtifactGraph (Slice 1) and InstructionLoader (Slice 3) provide programmatic APIs for artifact-based workflow management. Users currently have no CLI interface to:
- See artifact completion status for a change
- Discover what artifacts are ready to create
- Get enriched instructions for creating artifacts
- Create new changes with proper validation
This proposal adds CLI commands that expose the artifact workflow functionality to users and agents.
## What Changes
- **NEW**: `openspec status --change <id>` shows artifact completion state
- **NEW**: `openspec next --change <id>` shows artifacts ready to create
- **NEW**: `openspec instructions <artifact> --change <id>` outputs enriched template
- **NEW**: `openspec templates [--schema <name>]` shows resolved template paths
- **NEW**: `openspec new change <name>` creates a new change directory
All commands are top-level for fluid UX. They integrate with existing core modules:
- Uses `loadChangeContext()`, `formatChangeStatus()`, `generateInstructions()` from instruction-loader
- Uses `ArtifactGraph`, `detectCompleted()` from artifact-graph
- Uses `createChange()`, `validateChangeName()` from change-utils
**Experimental isolation**: All commands are implemented in a single file (`src/commands/artifact-workflow.ts`) for easy removal if the feature doesn't work out. Help text marks them as experimental.
## Impact
- Affected specs: NEW `cli-artifact-workflow` capability
- Affected code:
- `src/cli/index.ts` - register new commands
- `src/commands/artifact-workflow.ts` - new command implementations
- No changes to existing commands or specs
- Builds on completed Slice 1, 2, and 3 implementations
@@ -0,0 +1,153 @@
# cli-artifact-workflow Specification
## Purpose
CLI commands for artifact workflow operations, exposing the artifact graph and instruction loader functionality to users and agents. Commands are top-level for fluid UX and implemented in isolation for easy removal.
## ADDED Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change.
#### Scenario: Show status with all states
- **WHEN** user runs `openspec status --change <id>`
- **THEN** the system displays each artifact with status indicator:
- `[x]` for completed artifacts
- `[ ]` for ready artifacts
- `[-]` for blocked artifacts (with missing dependencies listed)
#### Scenario: Status shows completion summary
- **WHEN** user runs `openspec status --change <id>`
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Missing change parameter
- **WHEN** user runs `openspec status` without `--change`
- **THEN** the system displays an error with list of available changes
#### Scenario: Unknown change
- **WHEN** user runs `openspec status --change unknown-id`
- **THEN** the system displays an error indicating the change does not exist
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact.
#### Scenario: Show enriched instructions
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
- **THEN** the system outputs:
- Artifact metadata (ID, output path, description)
- Template content
- Dependency status (done/missing)
- Unlocked artifacts (what becomes available after completion)
#### Scenario: Instructions JSON output
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
- **THEN** the system outputs JSON matching ArtifactInstructions interface
#### Scenario: Unknown artifact
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
- **THEN** the system displays an error listing valid artifact IDs for the schema
#### Scenario: Artifact with unmet dependencies
- **WHEN** user requests instructions for a blocked artifact
- **THEN** the system displays instructions with a warning about missing dependencies
### Requirement: Templates Command
The system SHALL show resolved template paths for all artifacts in a schema.
#### Scenario: List template paths with default schema
- **WHEN** user runs `openspec templates`
- **THEN** the system displays each artifact with its resolved template path using the default schema
#### Scenario: List template paths with custom schema
- **WHEN** user runs `openspec templates --schema tdd`
- **THEN** the system displays template paths for the specified schema
#### Scenario: Templates JSON output
- **WHEN** user runs `openspec templates --json`
- **THEN** the system outputs JSON mapping artifact IDs to template paths
#### Scenario: Template resolution source
- **WHEN** displaying template paths
- **THEN** the system indicates whether each template is from user override or package built-in
### Requirement: New Change Command
The system SHALL create new change directories with validation.
#### Scenario: Create valid change
- **WHEN** user runs `openspec new change add-feature`
- **THEN** the system creates `openspec/changes/add-feature/` directory
#### Scenario: Invalid change name
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
- **THEN** the system displays validation error with guidance
#### Scenario: Duplicate change name
- **WHEN** user runs `openspec new change existing-change` for an existing change
- **THEN** the system displays an error indicating the change already exists
#### Scenario: Create with description
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
- **THEN** the system creates the change directory with description in README.md
### Requirement: Schema Selection
The system SHALL support custom schema selection for workflow commands.
#### Scenario: Default schema
- **WHEN** user runs workflow commands without `--schema`
- **THEN** the system uses the "spec-driven" schema
#### Scenario: Custom schema
- **WHEN** user runs `openspec status --change <id> --schema tdd`
- **THEN** the system uses the specified schema for artifact graph
#### Scenario: Unknown schema
- **WHEN** user specifies an unknown schema
- **THEN** the system displays an error listing available schemas
### Requirement: Output Formatting
The system SHALL provide consistent output formatting.
#### Scenario: Color output
- **WHEN** terminal supports colors
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
#### Scenario: No color output
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
- **THEN** output uses text-only indicators without ANSI colors
#### Scenario: Progress indication
- **WHEN** loading change state takes time
- **THEN** the system displays a spinner during loading
### Requirement: Experimental Isolation
The system SHALL implement artifact workflow commands in isolation for easy removal.
#### Scenario: Single file implementation
- **WHEN** artifact workflow feature is implemented
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
#### Scenario: Help text marking
- **WHEN** user runs `--help` on any artifact workflow command
- **THEN** help text indicates the command is experimental
@@ -0,0 +1,48 @@
## 1. Core Command Implementation
- [x] 1.1 Create `src/commands/artifact-workflow.ts` with all commands
- [x] 1.2 Implement `status` command with text output
- [x] 1.3 Implement `next` command with text output
- [x] 1.4 Implement `instructions` command with text output
- [x] 1.5 Implement `templates` command with text output
- [x] 1.6 Implement `new change` subcommand using createChange()
## 2. CLI Registration
- [x] 2.1 Register `status` command in `src/cli/index.ts`
- [x] 2.2 Register `next` command in `src/cli/index.ts`
- [x] 2.3 Register `instructions` command in `src/cli/index.ts`
- [x] 2.4 Register `templates` command in `src/cli/index.ts`
- [x] 2.5 Register `new` command group with `change` subcommand
## 3. Output Formatting
- [x] 3.1 Add `--json` flag support to all commands
- [x] 3.2 Add color-coded status indicators (done/ready/blocked)
- [x] 3.3 Add progress spinner for loading operations
- [x] 3.4 Support `--no-color` flag
## 4. Error Handling
- [x] 4.1 Handle missing `--change` parameter with helpful error
- [x] 4.2 Handle unknown change names with list of available changes
- [x] 4.3 Handle unknown artifact names with valid options
- [x] 4.4 Handle schema resolution errors
## 5. Options and Flags
- [x] 5.1 Add `--schema` option for custom schema selection
- [x] 5.2 Add `--description` option to `new change` command
- [x] 5.3 Ensure options follow existing CLI patterns
## 6. Testing
- [x] 6.1 Add smoke tests for each command
- [x] 6.2 Test error cases (missing change, unknown artifact)
- [x] 6.3 Test JSON output format
- [x] 6.4 Test with different schemas
## 7. Documentation
- [x] 7.1 Add help text for all commands marked as "Experimental"
- [ ] 7.2 Update AGENTS.md with new commands (post-archive)
@@ -0,0 +1,149 @@
## Context
This is Slice 3 of the artifact-graph POC. We have:
- `ArtifactGraph` class with graph operations (Slice 1)
- `detectCompleted()` for filesystem-based state detection (Slice 1)
- `resolveSchema()` for XDG schema resolution (Slice 1)
- `createChange()` and `validateChangeName()` utilities (Slice 2)
After `restructure-schema-directories` is implemented, schemas will be self-contained directories:
```
schemas/<name>/
├── schema.yaml
└── templates/
└── *.md
```
This proposal adds template loading and instruction enrichment on top of that structure.
## Goals / Non-Goals
**Goals:**
- Load templates from schema directories
- Enrich templates with change-specific context (dependency status)
- Format change status for CLI output
**Non-Goals:**
- Template authoring UI
- Dynamic template compilation/execution
- Caching (keep it stateless like the rest)
## Decisions
### 1. Pure functions over classes
Follow the pattern in `resolver.ts` and `state.ts`. Use a simple `ChangeContext` interface with pure functions:
```typescript
interface ChangeContext {
changeName: string;
changeDir: string;
schemaName: string;
graph: ArtifactGraph;
completed: CompletedSet;
}
function loadChangeContext(projectRoot: string, changeName: string, schemaName?: string): ChangeContext
function loadTemplate(schemaName: string, templatePath: string): string
function getInstructions(artifactId: string, context: ChangeContext): string
function formatStatus(context: ChangeContext): string
```
**Why:** Matches existing codebase patterns. Easier to test. No hidden state.
### 2. Template resolution from schema directory
Templates are loaded from the schema's `templates/` subdirectory:
```typescript
function loadTemplate(schemaName: string, templatePath: string): string {
const schemaDir = getSchemaDir(schemaName); // From resolver.ts
const fullPath = path.join(schemaDir, 'templates', templatePath);
return fs.readFileSync(fullPath, 'utf-8');
}
```
Resolution is handled by `getSchemaDir()` which already checks user override → package built-in.
**Why:** Leverages existing schema resolution. Templates are co-located with schemas.
### 3. Template path from artifact definition
The artifact's `template` field is a path relative to the schema's `templates/` directory:
```yaml
artifacts:
- id: proposal
template: "proposal.md" # → schemas/<schema>/templates/proposal.md
```
**Why:** Explicit, simple, no magic.
### 4. Minimal context injection
Templates are markdown. Injection prepends a header section with context:
```markdown
---
change: add-auth
artifact: proposal
schema: spec-driven
output: openspec/changes/add-auth/proposal.md
---
## Dependencies
- [x] (none - this is a root artifact)
## Next Steps
After creating this artifact, you can work on: design, specs
---
[original template content...]
```
**Why:** Simple string concatenation. No template engine dependency. Clear separation.
### 5. Status output format
```markdown
## Change: add-auth (spec-driven)
| Artifact | Status | Output |
|----------|--------|--------|
| proposal | done | proposal.md |
| specs | ready | specs/*.md |
| design | blocked (needs: proposal) | design.md |
| tasks | blocked (needs: specs, design) | tasks.md |
```
**Why:** Markdown table is readable in terminal and docs. Matches CLI output style.
## File Structure
```
src/core/artifact-graph/
├── index.ts # Add new exports
├── template.ts # NEW: Template loading
├── context.ts # NEW: ChangeContext loading
└── instructions.ts # NEW: Enrichment and formatting
```
## Risks / Trade-offs
**Dependency on restructure-schema-directories:**
- This proposal requires the schema restructure to be done first
- Mitigation: Clear dependency documented, implement in order
**No template engine:**
- Pro: Zero dependencies, simple code
- Con: Limited expressiveness
- Mitigation: Current use case only needs static templates + header injection
## Migration Plan
N/A - new capability, no existing code to migrate.
## Open Questions
None.
@@ -0,0 +1,20 @@
## Why
Slice 1 (artifact-graph) provides graph operations and state detection. Slice 2 (change-utils) provides change creation. We now need the ability to load templates for artifacts and enrich them with change-specific context so users/agents know what to create next.
## What Changes
- Add template resolution from schema directories (uses structure from `restructure-schema-directories`)
- Add instruction enrichment that injects change context into templates
- Add status formatting for CLI output
- New `instruction-loader` capability
## Dependencies
- Requires `restructure-schema-directories` to be implemented first (schemas as directories with co-located templates)
## Impact
- Affected specs: New `instruction-loader` spec
- Affected code: `src/core/artifact-graph/` (new files)
- Builds on: `artifact-graph` (Slice 1), uses `ArtifactGraph`, `detectCompleted`, `resolveSchema`
@@ -0,0 +1,70 @@
# instruction-loader Specification
## Purpose
Load templates from schema directories and enrich them with change-specific context for guiding artifact creation.
## ADDED Requirements
### Requirement: Template Loading
The system SHALL load templates from schema directories.
#### Scenario: Load template from schema directory
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
#### Scenario: Template file not found
- **WHEN** a template file does not exist in the schema's templates directory
- **THEN** the system throws an error with the template path
### Requirement: Change Context Loading
The system SHALL load change context combining graph and completion state.
#### Scenario: Load context for existing change
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
- **THEN** the system returns a context with graph, completed set, schema name, and change info
#### Scenario: Load context with custom schema
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
- **THEN** the system uses the specified schema instead of default
#### Scenario: Load context for non-existent change directory
- **WHEN** `loadChangeContext` is called for a non-existent change directory
- **THEN** the system returns context with empty completed set
### Requirement: Template Enrichment
The system SHALL enrich templates with change-specific context.
#### Scenario: Include artifact metadata
- **WHEN** instructions are generated for an artifact
- **THEN** the output includes change name, artifact ID, schema name, and output path
#### Scenario: Include dependency status
- **WHEN** an artifact has dependencies
- **THEN** the output shows each dependency with completion status (done/missing)
#### Scenario: Include unlocked artifacts
- **WHEN** instructions are generated
- **THEN** the output includes which artifacts become available after this one
#### Scenario: Root artifact indicator
- **WHEN** an artifact has no dependencies
- **THEN** the dependency section indicates this is a root artifact
### Requirement: Status Formatting
The system SHALL format change status as readable output.
#### Scenario: All artifacts completed
- **WHEN** all artifacts are completed
- **THEN** status shows all artifacts as "done"
#### Scenario: Mixed completion status
- **WHEN** some artifacts are completed
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
#### Scenario: Blocked artifact details
- **WHEN** an artifact is blocked
- **THEN** status shows which dependencies are missing
#### Scenario: Include output paths
- **WHEN** status is formatted
- **THEN** each artifact shows its output path pattern
@@ -0,0 +1,13 @@
# Tasks
## Implementation Tasks
- [x] Create `instruction-loader` spec in `openspec/specs/instruction-loader/spec.md`
- [x] Implement `loadTemplate` function to load templates from schema directories
- [x] Implement `loadChangeContext` function to combine graph and completion state
- [x] Implement `generateInstructions` function to enrich templates with change context
- [x] Implement `formatChangeStatus` function for readable status output
- [x] Export new functions from `src/core/artifact-graph/index.ts`
- [x] Add comprehensive tests in `test/core/artifact-graph/instruction-loader.test.ts`
- [x] Verify build passes
- [x] Verify all tests pass
@@ -0,0 +1,129 @@
## Context
Built-in schemas are currently embedded as TypeScript objects:
```typescript
// src/core/artifact-graph/builtin-schemas.ts
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
name: 'spec-driven',
version: 1,
artifacts: [...]
};
```
This doesn't support templates co-located with schemas. The instruction loader (Slice 3) needs templates, and the cleanest approach is self-contained schema directories.
## Goals / Non-Goals
**Goals:**
- Schemas as self-contained directories (schema.yaml + templates/)
- User overrides via XDG data directory
- Simple 2-level resolution (user → package)
- Templates co-located with their schema
**Non-Goals:**
- Shared template fallback (intentionally avoiding complexity)
- Runtime schema compilation
- Schema inheritance
## Decisions
### 1. Directory structure
Each schema is a directory containing `schema.yaml` and `templates/`:
```
<package>/schemas/
├── spec-driven/
│ ├── schema.yaml
│ └── templates/
│ ├── proposal.md
│ ├── design.md
│ ├── spec.md
│ └── tasks.md
└── tdd/
├── schema.yaml
└── templates/
├── spec.md
├── test.md
├── implementation.md
└── docs.md
```
**Why:** Self-contained like Helm charts. No cross-schema dependencies. Each schema owns its templates.
### 2. Resolution order (2 levels)
```
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
2. <package>/schemas/<name>/schema.yaml # Built-in
3. Error (not found)
```
**Why:** Simple mental model. User can override entire schema directory or just parts.
### 3. Template path in schema.yaml
The `template` field is relative to the schema's `templates/` directory:
```yaml
# schemas/spec-driven/schema.yaml
artifacts:
- id: proposal
template: "proposal.md" # → schemas/spec-driven/templates/proposal.md
```
**Why:** Paths are relative to the schema, not a global templates directory.
### 4. Resolve package directory via import.meta.url
```typescript
function getPackageSchemasDir(): string {
const currentFile = fileURLToPath(import.meta.url);
// Navigate from src/core/artifact-graph/ to package root
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
}
```
**Why:** Works in ESM. No hardcoded paths.
### 5. Keep schema.yaml format unchanged
The YAML format stays the same - only the storage location changes:
```yaml
name: spec-driven
version: 1
description: Specification-driven development
artifacts:
- id: proposal
generates: "proposal.md"
template: "proposal.md"
requires: []
```
**Why:** No breaking changes to schema format. Just moving from TS to YAML files.
## Migration
1. Create `schemas/` directory at package root
2. Convert `SPEC_DRIVEN_SCHEMA` to `schemas/spec-driven/schema.yaml`
3. Convert `TDD_SCHEMA` to `schemas/tdd/schema.yaml`
4. Update `resolveSchema()` to load from directories
5. Remove `builtin-schemas.ts`
6. Update `listSchemas()` to scan directories
## Risks / Trade-offs
**File I/O at runtime:**
- Previously schemas were in-memory objects
- Now requires reading YAML files
- Mitigation: Schemas are small, loaded once per operation
**Package distribution:**
- Must ensure `schemas/` directory is included in npm package
- Add to `files` in package.json
## Open Questions
None.
@@ -0,0 +1,20 @@
## Why
Currently, built-in schemas are embedded as TypeScript objects in `builtin-schemas.ts`. This works for schemas but doesn't support co-located templates. To enable self-contained schema packages (schema + templates together), we need to restructure schemas as directories.
## What Changes
- **BREAKING (internal):** Move built-in schemas from embedded TS objects to actual directory structure
- Schemas become directories containing `schema.yaml` + `templates/`
- Update `resolveSchema()` to load from directory structure
- Remove `builtin-schemas.ts` (replaced by file-based schemas)
- Update resolution to check user dir → package dir
## Impact
- Affected specs: `artifact-graph` (schema resolution changes)
- Affected code:
- Remove `src/core/artifact-graph/builtin-schemas.ts`
- Update `src/core/artifact-graph/resolver.ts`
- Add `schemas/` directory at package root
- No external API changes (resolution still returns `SchemaYaml`)
@@ -0,0 +1,49 @@
## MODIFIED Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
#### Scenario: Valid schema loaded
- **WHEN** a schema directory contains a valid `schema.yaml` file
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
#### Scenario: Schema directory not found
- **WHEN** resolving a schema name that has no corresponding directory
- **THEN** the system throws an error listing available schemas
## ADDED Requirements
### Requirement: Schema Directory Structure
The system SHALL support self-contained schema directories with co-located templates.
#### Scenario: Schema with templates
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
- **THEN** artifacts can reference templates relative to the schema's templates directory
#### Scenario: User schema override
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
- **THEN** the system uses that directory instead of the built-in
#### Scenario: Built-in schema fallback
- **WHEN** no user override exists for a schema
- **THEN** the system uses the package built-in schema directory
#### Scenario: List available schemas
- **WHEN** listing schemas
- **THEN** the system returns schema names from both user and package directories
@@ -0,0 +1,32 @@
## 1. Create Schema Directories
- [ ] 1.1 Create `schemas/` directory at package root
- [ ] 1.2 Create `schemas/spec-driven/schema.yaml` from `SPEC_DRIVEN_SCHEMA`
- [ ] 1.3 Create `schemas/spec-driven/templates/` with placeholder templates
- [ ] 1.4 Create `schemas/tdd/schema.yaml` from `TDD_SCHEMA`
- [ ] 1.5 Create `schemas/tdd/templates/` with placeholder templates
## 2. Update Schema Resolution
- [ ] 2.1 Add `getPackageSchemasDir()` function using `import.meta.url`
- [ ] 2.2 Add `getSchemaDir(name)` to resolve schema directory path
- [ ] 2.3 Update `resolveSchema()` to load from directory structure
- [ ] 2.4 Update `listSchemas()` to scan directories instead of object keys
- [ ] 2.5 Add tests for user override resolution
- [ ] 2.6 Add tests for built-in fallback
## 3. Cleanup
- [ ] 3.1 Remove `builtin-schemas.ts`
- [ ] 3.2 Update `index.ts` exports (remove `BUILTIN_SCHEMAS`, `SPEC_DRIVEN_SCHEMA`, `TDD_SCHEMA`)
- [ ] 3.3 Update any code that imports removed exports
## 4. Package Distribution
- [ ] 4.1 Add `schemas/` to `files` array in `package.json`
- [ ] 4.2 Verify schemas are included in built package
## 5. Fix Template Paths
- [ ] 5.1 Update `template` field in schema.yaml files (remove `templates/` prefix)
- [ ] 5.2 Ensure template paths are relative to schema's templates directory
@@ -0,0 +1,151 @@
# Design: Unify Change State Model
## Overview
This change fixes two bugs with minimal disruption to the existing system:
1. **View bug**: Empty changes incorrectly shown as "Completed"
2. **Artifact workflow bug**: Commands fail on scaffolded changes
## Key Design Decision: Two Systems, Two Purposes
The task-based and artifact-based systems serve **different purposes** and should coexist:
| System | Purpose | Used By |
|--------|---------|---------|
| **Task Progress** | Track implementation work | `openspec view`, `openspec list` |
| **Artifact Progress** | Track planning/spec work | `openspec status`, `openspec next` |
We do NOT merge these systems. Instead, we fix each to work correctly in its domain.
## Change 1: Fix View Command
### Current Logic (Buggy)
```typescript
// view.ts line 90
if (progress.total === 0 || progress.completed === progress.total) {
completed.push({ name: entry.name });
}
```
Problem: `total === 0` means "no tasks defined yet", not "all tasks done".
### New Logic
```typescript
if (progress.total === 0) {
draft.push({ name: entry.name });
} else if (progress.completed === progress.total) {
completed.push({ name: entry.name });
} else {
active.push({ name: entry.name, progress });
}
```
### View Output Change
**Before:**
```
Completed Changes
─────────────────
✓ add-feature (all tasks done - correct)
✓ test-workflow (no tasks - WRONG)
```
**After:**
```
Draft Changes
─────────────────
○ test-workflow (no tasks yet)
Active Changes
─────────────────
◉ add-scaffold [████░░░░] 3/7 tasks
Completed Changes
─────────────────
✓ add-feature (all tasks done)
```
## Change 2: Fix Artifact Workflow Discovery
### Current Logic (Buggy)
```typescript
// artifact-workflow.ts - validateChangeExists()
const activeChanges = await getActiveChangeIds(projectRoot);
if (!activeChanges.includes(changeName)) {
throw new Error(`Change '${changeName}' not found...`);
}
```
Problem: `getActiveChangeIds()` requires `proposal.md`, but artifact workflow should work on empty directories to help create the first artifact.
### New Logic
```typescript
async function validateChangeExists(changeName: string, projectRoot: string): Promise<string> {
const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
// Check directory existence directly, not proposal.md
if (!fs.existsSync(changePath) || !fs.statSync(changePath).isDirectory()) {
// List available changes for helpful error message
const entries = await fs.promises.readdir(
path.join(projectRoot, 'openspec', 'changes'),
{ withFileTypes: true }
);
const available = entries
.filter(e => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
.map(e => e.name);
if (available.length === 0) {
throw new Error('No changes found. Create one with: openspec new change <name>');
}
throw new Error(`Change '${changeName}' not found. Available:\n ${available.join('\n ')}`);
}
return changeName;
}
```
### Behavior Change
```bash
# Before
$ openspec new change foo
$ openspec status --change foo
Error: Change 'foo' not found.
# After
$ openspec new change foo
$ openspec status --change foo
Change: foo
Progress: 0/4 artifacts complete
[ ] proposal
[-] specs (blocked by: proposal)
[-] design (blocked by: proposal)
[-] tasks (blocked by: specs, design)
```
## What Stays the Same
1. **`getActiveChangeIds()`** - Still requires `proposal.md` (used by validate, show)
2. **`getArchivedChangeIds()`** - Unchanged
3. **Active/Completed semantics** - Still based on task checkboxes
4. **Validation** - Still requires `proposal.md` to have something to validate
## File Changes
| File | Change |
|------|--------|
| `src/core/view.ts` | Add draft category, fix completion logic |
| `src/commands/artifact-workflow.ts` | Update `validateChangeExists()` to use directory existence |
| `test/commands/artifact-workflow.test.ts` | Add tests for scaffolded changes |
## Testing Strategy
1. **Unit test**: `validateChangeExists()` with scaffolded change
2. **View test**: Verify three categories render correctly
3. **Manual test**: Full workflow from `new change` → `status` → `view`
@@ -0,0 +1,101 @@
# Proposal: Unify Change State Model
## Problem Statement
Two bugs create inconsistent behavior when working with changes:
### Bug 1: Empty changes shown as "Completed" in view
```typescript
// view.ts line 90
if (progress.total === 0 || progress.completed === progress.total) {
completed.push({ name: entry.name }); // BUG: total === 0 ≠ completed
}
```
Result: `openspec new change foo && openspec view` shows `foo` as "Completed" when it has no content.
### Bug 2: Artifact workflow commands can't find scaffolded changes
```typescript
// item-discovery.ts - getActiveChangeIds()
const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
await fs.access(proposalPath); // Only returns changes WITH proposal.md
```
Result: `openspec status --change foo` says "not found" even though the directory exists.
## Root Cause
The system conflates two different concepts:
| Concept | Question | Source of Truth |
|---------|----------|-----------------|
| **Planning Progress** | Are all spec documents created? | File existence (ArtifactGraph) |
| **Implementation Progress** | Is the coding work done? | Task checkboxes (tasks.md) |
## Proposed Solution
### Fix 1: Add "Draft" state to view command
Keep Active/Completed with their existing meanings, but fix the bug:
| State | Criteria | Meaning |
|-------|----------|---------|
| **Draft** | No tasks.md OR `tasks.total === 0` | Still planning |
| **Active** | `tasks.total > 0` AND `completed < total` | Implementing |
| **Completed** | `tasks.total > 0` AND `completed === total` | Done |
### Fix 2: Artifact workflow uses directory existence
Update `validateChangeExists()` to check if the directory exists, not if `proposal.md` exists. This allows the artifact workflow to guide users through creating their first artifact.
### Keep existing discovery functions
`getActiveChangeIds()` continues to require `proposal.md` for backward compatibility with validation and other commands.
## What Changes
| Command | Before | After |
|---------|--------|-------|
| `openspec view` | Empty = "Completed" | Empty = "Draft" |
| `openspec status --change X` | Requires proposal.md | Works on any directory |
| `openspec validate X` | Requires proposal.md | Unchanged (still requires it) |
## Breaking Changes
### Minimal Breaking Change
1. **`openspec view` output**: Empty changes move from "Completed" section to new "Draft" section
### Non-Breaking
- Active/Completed semantics unchanged (still task-based)
- `getActiveChangeIds()` unchanged
- `openspec validate` unchanged
- Archived changes unaffected
## Out of Scope
- Merging task-based and artifact-based progress (they serve different purposes)
- Changing what "Completed" means (it stays = all tasks done)
- Adding artifact progress to view command (separate enhancement)
- Shell tab completions for artifact workflow commands (not yet registered)
## Related Commands Analysis
| Command | Uses `getActiveChangeIds()` | Should include scaffolded? | Change needed? |
|---------|-----------------------------|-----------------------------|----------------|
| `openspec view` | No (reads dirs directly) | Yes → Draft section | **Yes** |
| `openspec list` | No (reads dirs directly) | Yes (shows "No tasks") | No |
| `openspec status/next/instructions` | Yes | Yes | **Yes** |
| `openspec validate` | Yes | No (can't validate empty) | No |
| `openspec show` | Yes | No (nothing to show) | No |
| Tab completions | Yes | Future enhancement | No |
## Success Criteria
1. `openspec new change foo && openspec view` shows `foo` in "Draft" section
2. `openspec new change foo && openspec status --change foo` works
3. Changes with all tasks done still show as "Completed"
4. All existing tests pass
@@ -0,0 +1,109 @@
# cli-artifact-workflow Specification Delta
## MODIFIED Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
#### Scenario: Show status with all states
- **WHEN** user runs `openspec status --change <id>`
- **THEN** the system displays each artifact with status indicator:
- `[x]` for completed artifacts
- `[ ]` for ready artifacts
- `[-]` for blocked artifacts (with missing dependencies listed)
#### Scenario: Status shows completion summary
- **WHEN** user runs `openspec status --change <id>`
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Status on scaffolded change
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
- **THEN** system displays all artifacts with their status
- **AND** root artifacts (no dependencies) show as ready `[ ]`
- **AND** dependent artifacts show as blocked `[-]`
#### Scenario: Missing change parameter
- **WHEN** user runs `openspec status` without `--change`
- **THEN** the system displays an error with list of available changes
- **AND** includes scaffolded changes (directories without proposal.md)
#### Scenario: Unknown change
- **WHEN** user runs `openspec status --change unknown-id`
- **AND** directory `openspec/changes/unknown-id/` does not exist
- **THEN** the system displays an error listing all available change directories
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
#### Scenario: Next on scaffolded change
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
#### Scenario: Show enriched instructions
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
- **THEN** the system outputs:
- Artifact metadata (ID, output path, description)
- Template content
- Dependency status (done/missing)
- Unlocked artifacts (what becomes available after completion)
#### Scenario: Instructions JSON output
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
- **THEN** the system outputs JSON matching ArtifactInstructions interface
#### Scenario: Unknown artifact
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
- **THEN** the system displays an error listing valid artifact IDs for the schema
#### Scenario: Artifact with unmet dependencies
- **WHEN** user requests instructions for a blocked artifact
- **THEN** the system displays instructions with a warning about missing dependencies
#### Scenario: Instructions on scaffolded change
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
- **THEN** system outputs template and metadata for creating the proposal
- **AND** does not require any artifacts to already exist
@@ -0,0 +1,60 @@
# cli-view Specification Delta
## ADDED Requirements
### Requirement: Draft Changes Display
The dashboard SHALL display changes without tasks in a separate "Draft" section.
#### Scenario: Draft changes listing
- **WHEN** there are changes with no tasks.md or zero tasks defined
- **THEN** system shows them in a "Draft Changes" section
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
#### Scenario: Draft section ordering
- **WHEN** multiple draft changes exist
- **THEN** system sorts them alphabetically by name
## MODIFIED Requirements
### Requirement: Completed Changes Display
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
#### Scenario: Completed changes listing
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
- **THEN** system shows them with checkmark indicators in a dedicated section
#### Scenario: Mixed completion states
- **WHEN** some changes are complete and others active
- **THEN** system separates them into appropriate sections
#### Scenario: Empty changes not completed
- **WHEN** a change has no tasks.md or zero tasks defined
- **THEN** system does NOT show it in "Completed Changes" section
- **AND** shows it in "Draft Changes" section instead
### Requirement: Summary Section
The dashboard SHALL display a summary section with key project metrics, including draft change count.
#### Scenario: Complete summary display
- **WHEN** dashboard is rendered with specs and changes
- **THEN** system shows total number of specifications and requirements
- **AND** shows number of draft changes
- **AND** shows number of active changes in progress
- **AND** shows number of completed changes
- **AND** shows overall task progress percentage
#### Scenario: Empty project summary
- **WHEN** no specs or changes exist
- **THEN** summary shows zero counts for all metrics
@@ -0,0 +1,25 @@
# Tasks: Unify Change State Model
## Phase 1: Fix Artifact Workflow Discovery
- [x] Update `validateChangeExists()` in `artifact-workflow.ts` to check directory existence instead of using `getActiveChangeIds()`
- [x] Update error message to list all change directories (not just those with proposal.md)
- [x] Add test for `openspec status --change <scaffolded-change>`
- [x] Add test for `openspec next --change <scaffolded-change>`
- [x] Add test for `openspec instructions proposal --change <scaffolded-change>`
## Phase 2: Fix View Command
- [x] Update `getChangesData()` in `view.ts` to return three categories: draft, active, completed
- [x] Fix completion logic: `total === 0` → draft, not completed
- [x] Add "Draft Changes" section to dashboard rendering
- [x] Update summary to include draft count
- [x] Add test for draft changes appearing correctly in view
## Phase 3: Cleanup and Validation
- [x] Clean up test changes (`test-workflow`, `test-workflow-2`)
- [x] Run full test suite
- [x] Manual test: `openspec new change foo && openspec status --change foo`
- [x] Manual test: `openspec new change foo && openspec view` shows foo in Draft
- [x] Validate with `openspec validate unify-change-state-model --strict`
+130
View File
@@ -0,0 +1,130 @@
# artifact-graph Specification
## Purpose
TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive.
## Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
#### Scenario: Valid schema loaded
- **WHEN** a schema directory contains a valid `schema.yaml` file
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
#### Scenario: Schema directory not found
- **WHEN** resolving a schema name that has no corresponding directory
- **THEN** the system throws an error listing available schemas
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
### Requirement: Schema Directory Structure
The system SHALL support self-contained schema directories with co-located templates.
#### Scenario: Schema with templates
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
- **THEN** artifacts can reference templates relative to the schema's templates directory
#### Scenario: User schema override
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
- **THEN** the system uses that directory instead of the built-in
#### Scenario: Built-in schema fallback
- **WHEN** no user override exists for a schema
- **THEN** the system uses the package built-in schema directory
#### Scenario: List available schemas
- **WHEN** listing schemas
- **THEN** the system returns schema names from both user and package directories
+67
View File
@@ -0,0 +1,67 @@
# change-creation Specification
## Purpose
Provide programmatic utilities for creating and validating OpenSpec change directories.
## Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
@@ -0,0 +1,190 @@
# cli-artifact-workflow Specification
## Purpose
TBD - created by archiving change add-artifact-workflow-cli. Update Purpose after archive.
## Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
#### Scenario: Show status with all states
- **WHEN** user runs `openspec status --change <id>`
- **THEN** the system displays each artifact with status indicator:
- `[x]` for completed artifacts
- `[ ]` for ready artifacts
- `[-]` for blocked artifacts (with missing dependencies listed)
#### Scenario: Status shows completion summary
- **WHEN** user runs `openspec status --change <id>`
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Status on scaffolded change
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
- **THEN** system displays all artifacts with their status
- **AND** root artifacts (no dependencies) show as ready `[ ]`
- **AND** dependent artifacts show as blocked `[-]`
#### Scenario: Missing change parameter
- **WHEN** user runs `openspec status` without `--change`
- **THEN** the system displays an error with list of available changes
- **AND** includes scaffolded changes (directories without proposal.md)
#### Scenario: Unknown change
- **WHEN** user runs `openspec status --change unknown-id`
- **AND** directory `openspec/changes/unknown-id/` does not exist
- **THEN** the system displays an error listing all available change directories
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
#### Scenario: Next on scaffolded change
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
#### Scenario: Show enriched instructions
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
- **THEN** the system outputs:
- Artifact metadata (ID, output path, description)
- Template content
- Dependency status (done/missing)
- Unlocked artifacts (what becomes available after completion)
#### Scenario: Instructions JSON output
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
- **THEN** the system outputs JSON matching ArtifactInstructions interface
#### Scenario: Unknown artifact
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
- **THEN** the system displays an error listing valid artifact IDs for the schema
#### Scenario: Artifact with unmet dependencies
- **WHEN** user requests instructions for a blocked artifact
- **THEN** the system displays instructions with a warning about missing dependencies
#### Scenario: Instructions on scaffolded change
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
- **THEN** system outputs template and metadata for creating the proposal
- **AND** does not require any artifacts to already exist
### Requirement: Templates Command
The system SHALL show resolved template paths for all artifacts in a schema.
#### Scenario: List template paths with default schema
- **WHEN** user runs `openspec templates`
- **THEN** the system displays each artifact with its resolved template path using the default schema
#### Scenario: List template paths with custom schema
- **WHEN** user runs `openspec templates --schema tdd`
- **THEN** the system displays template paths for the specified schema
#### Scenario: Templates JSON output
- **WHEN** user runs `openspec templates --json`
- **THEN** the system outputs JSON mapping artifact IDs to template paths
#### Scenario: Template resolution source
- **WHEN** displaying template paths
- **THEN** the system indicates whether each template is from user override or package built-in
### Requirement: New Change Command
The system SHALL create new change directories with validation.
#### Scenario: Create valid change
- **WHEN** user runs `openspec new change add-feature`
- **THEN** the system creates `openspec/changes/add-feature/` directory
#### Scenario: Invalid change name
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
- **THEN** the system displays validation error with guidance
#### Scenario: Duplicate change name
- **WHEN** user runs `openspec new change existing-change` for an existing change
- **THEN** the system displays an error indicating the change already exists
#### Scenario: Create with description
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
- **THEN** the system creates the change directory with description in README.md
### Requirement: Schema Selection
The system SHALL support custom schema selection for workflow commands.
#### Scenario: Default schema
- **WHEN** user runs workflow commands without `--schema`
- **THEN** the system uses the "spec-driven" schema
#### Scenario: Custom schema
- **WHEN** user runs `openspec status --change <id> --schema tdd`
- **THEN** the system uses the specified schema for artifact graph
#### Scenario: Unknown schema
- **WHEN** user specifies an unknown schema
- **THEN** the system displays an error listing available schemas
### Requirement: Output Formatting
The system SHALL provide consistent output formatting.
#### Scenario: Color output
- **WHEN** terminal supports colors
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
#### Scenario: No color output
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
- **THEN** output uses text-only indicators without ANSI colors
#### Scenario: Progress indication
- **WHEN** loading change state takes time
- **THEN** the system displays a spinner during loading
### Requirement: Experimental Isolation
The system SHALL implement artifact workflow commands in isolation for easy removal.
#### Scenario: Single file implementation
- **WHEN** artifact workflow feature is implemented
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
#### Scenario: Help text marking
- **WHEN** user runs `--help` on any artifact workflow command
- **THEN** help text indicates the command is experimental
+27 -3
View File
@@ -20,12 +20,13 @@ The system SHALL provide a `view` command that displays a dashboard overview of
### Requirement: Summary Section
The dashboard SHALL display a summary section with key project metrics.
The dashboard SHALL display a summary section with key project metrics, including draft change count.
#### Scenario: Complete summary display
- **WHEN** dashboard is rendered with specs and changes
- **THEN** system shows total number of specifications and requirements
- **AND** shows number of draft changes
- **AND** shows number of active changes in progress
- **AND** shows number of completed changes
- **AND** shows overall task progress percentage
@@ -46,11 +47,13 @@ The dashboard SHALL show active changes with visual progress indicators.
### Requirement: Completed Changes Display
The dashboard SHALL list completed changes in a separate section.
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
#### Scenario: Completed changes listing
- **WHEN** there are completed changes (all tasks done)
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
- **THEN** system shows them with checkmark indicators in a dedicated section
#### Scenario: Mixed completion states
@@ -58,6 +61,12 @@ The dashboard SHALL list completed changes in a separate section.
- **WHEN** some changes are complete and others active
- **THEN** system separates them into appropriate sections
#### Scenario: Empty changes not completed
- **WHEN** a change has no tasks.md or zero tasks defined
- **THEN** system does NOT show it in "Completed Changes" section
- **AND** shows it in "Draft Changes" section instead
### Requirement: Specifications Display
The dashboard SHALL display specifications sorted by requirement count.
@@ -103,3 +112,18 @@ The view command SHALL handle errors gracefully.
- **WHEN** specs or changes have invalid format
- **THEN** system skips invalid items and continues rendering
### Requirement: Draft Changes Display
The dashboard SHALL display changes without tasks in a separate "Draft" section.
#### Scenario: Draft changes listing
- **WHEN** there are changes with no tasks.md or zero tasks defined
- **THEN** system shows them in a "Draft Changes" section
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
#### Scenario: Draft section ordering
- **WHEN** multiple draft changes exist
- **THEN** system sorts them alphabetically by name
+70
View File
@@ -0,0 +1,70 @@
# instruction-loader Specification
## Purpose
The instruction-loader loads instruction templates from schema directories, validates and enriches them with metadata and parameters (such as change context and dependency status), and exposes them for use by downstream services including template retrieval, parameter substitution, and enrichment.
## Requirements
### Requirement: Template Loading
The system SHALL load templates from schema directories.
#### Scenario: Load template from schema directory
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
#### Scenario: Template file not found
- **WHEN** a template file does not exist in the schema's templates directory
- **THEN** the system throws an error with the template path
### Requirement: Change Context Loading
The system SHALL load change context combining graph and completion state.
#### Scenario: Load context for existing change
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
- **THEN** the system returns a context with graph, completed set, schema name, and change info
#### Scenario: Load context with custom schema
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
- **THEN** the system uses the specified schema instead of default
#### Scenario: Load context for non-existent change directory
- **WHEN** `loadChangeContext` is called for a non-existent change directory
- **THEN** the system returns context with empty completed set
### Requirement: Template Enrichment
The system SHALL enrich templates with change-specific context.
#### Scenario: Include artifact metadata
- **WHEN** instructions are generated for an artifact
- **THEN** the output includes change name, artifact ID, schema name, and output path
#### Scenario: Include dependency status
- **WHEN** an artifact has dependencies
- **THEN** the output shows each dependency with completion status (done/missing)
#### Scenario: Include unlocked artifacts
- **WHEN** instructions are generated
- **THEN** the output includes which artifacts become available after this one
#### Scenario: Root artifact indicator
- **WHEN** an artifact has no dependencies
- **THEN** the dependency section indicates this is a root artifact
### Requirement: Status Formatting
The system SHALL format change status as readable output.
#### Scenario: All artifacts completed
- **WHEN** all artifacts are completed
- **THEN** status shows all artifacts as "done"
#### Scenario: Mixed completion status
- **WHEN** some artifacts are completed
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
#### Scenario: Blocked artifact details
- **WHEN** an artifact is blocked
- **THEN** status shows which dependencies are missing
#### Scenario: Include output paths
- **WHEN** status is formatted
- **THEN** each artifact shows its output path pattern
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.17.1",
"version": "0.17.2",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -32,6 +32,7 @@
"files": [
"dist",
"bin",
"schemas",
"scripts/postinstall.js",
"!dist/**/*.test.js",
"!dist/**/__tests__",
@@ -73,7 +74,9 @@
"@inquirer/prompts": "^7.8.0",
"chalk": "^5.5.0",
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
}
+25 -11
View File
@@ -20,9 +20,15 @@ importers:
commander:
specifier: ^14.0.0
version: 14.0.0
fast-glob:
specifier: ^3.3.3
version: 3.3.3
ora:
specifier: ^8.2.0
version: 8.2.0
yaml:
specifier: ^2.8.2
version: 2.8.2
zod:
specifier: ^4.0.17
version: 4.0.17
@@ -47,7 +53,7 @@ importers:
version: 8.50.1(eslint@9.39.2)(typescript@5.9.3)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
packages:
@@ -1576,6 +1582,11 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2202,13 +2213,13 @@ snapshots:
chai: 5.2.1
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0))':
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 7.0.6(@types/node@24.2.0)
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -2239,7 +2250,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -2987,13 +2998,13 @@ snapshots:
dependencies:
punycode: 2.3.1
vite-node@3.2.4(@types/node@24.2.0):
vite-node@3.2.4(@types/node@24.2.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.0.6(@types/node@24.2.0)
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -3008,7 +3019,7 @@ snapshots:
- tsx
- yaml
vite@7.0.6(@types/node@24.2.0):
vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.8
fdir: 6.4.6(picomatch@4.0.3)
@@ -3019,12 +3030,13 @@ snapshots:
optionalDependencies:
'@types/node': 24.2.0
fsevents: 2.3.3
yaml: 2.8.2
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4):
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0))
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -3042,8 +3054,8 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.0.6(@types/node@24.2.0)
vite-node: 3.2.4(@types/node@24.2.0)
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
vite-node: 3.2.4(@types/node@24.2.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.2.0
@@ -3079,6 +3091,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
yaml@2.8.2: {}
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.2: {}
+28
View File
@@ -0,0 +1,28 @@
name: spec-driven
version: 1
description: Default OpenSpec workflow - proposal → specs → design → tasks
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal document outlining the change
template: proposal.md
requires: []
- id: specs
generates: "specs/**/*.md"
description: Detailed specifications for the change
template: spec.md
requires:
- proposal
- id: design
generates: design.md
description: Technical design document with implementation details
template: design.md
requires:
- proposal
- id: tasks
generates: tasks.md
description: Implementation tasks derived from specs and design
template: tasks.md
requires:
- specs
- design
+19
View File
@@ -0,0 +1,19 @@
## Context
<!-- Background and current state -->
## Goals / Non-Goals
**Goals:**
<!-- What this design aims to achieve -->
**Non-Goals:**
<!-- What is explicitly out of scope -->
## Decisions
<!-- Key design decisions and rationale -->
## Risks / Trade-offs
<!-- Known risks and trade-offs -->
+11
View File
@@ -0,0 +1,11 @@
## Why
<!-- Explain the motivation for this change -->
## What Changes
<!-- Describe what will change -->
## Impact
<!-- List affected areas -->
+8
View File
@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: <!-- requirement name -->
<!-- requirement text -->
#### Scenario: <!-- scenario name -->
- **WHEN** <!-- condition -->
- **THEN** <!-- expected outcome -->
+9
View File
@@ -0,0 +1,9 @@
## 1. <!-- Task Group Name -->
- [ ] 1.1 <!-- Task description -->
- [ ] 1.2 <!-- Task description -->
## 2. <!-- Task Group Name -->
- [ ] 2.1 <!-- Task description -->
- [ ] 2.2 <!-- Task description -->
+27
View File
@@ -0,0 +1,27 @@
name: tdd
version: 1
description: Test-driven development workflow - tests → implementation → docs
artifacts:
- id: spec
generates: spec.md
description: Feature specification defining requirements
template: spec.md
requires: []
- id: tests
generates: "tests/*.test.ts"
description: Test files written before implementation
template: test.md
requires:
- spec
- id: implementation
generates: "src/*.ts"
description: Implementation code to pass the tests
template: implementation.md
requires:
- tests
- id: docs
generates: "docs/*.md"
description: Documentation for the implemented feature
template: docs.md
requires:
- implementation
+15
View File
@@ -0,0 +1,15 @@
## Overview
<!-- Feature overview -->
## Getting Started
<!-- Quick start guide -->
## Examples
<!-- Code examples -->
## Reference
<!-- API reference or additional details -->
+11
View File
@@ -0,0 +1,11 @@
## Implementation Notes
<!-- Technical implementation details -->
## API
<!-- Public API documentation -->
## Usage
<!-- Usage examples -->
+11
View File
@@ -0,0 +1,11 @@
## Feature: <!-- feature name -->
<!-- Feature description -->
## Requirements
<!-- List of requirements -->
## Acceptance Criteria
<!-- List of acceptance criteria -->
+11
View File
@@ -0,0 +1,11 @@
## Test Plan
<!-- Describe the testing strategy -->
## Test Cases
### <!-- Test case name -->
- **Given:** <!-- preconditions -->
- **When:** <!-- action -->
- **Then:** <!-- expected result -->
+9 -2
View File
@@ -14,6 +14,7 @@ import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
const program = new Command();
const require = createRequire(import.meta.url);
@@ -95,11 +96,14 @@ program
.description('List items (changes by default). Use --specs to list specs.')
.option('--specs', 'List specs instead of changes')
.option('--changes', 'List changes explicitly (default)')
.action(async (options?: { specs?: boolean; changes?: boolean }) => {
.option('--sort <order>', '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 }) => {
try {
const listCommand = new ListCommand();
const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';
await listCommand.execute('.', mode);
const sort = options?.sort === 'name' ? 'name' : 'recent';
await listCommand.execute('.', mode, { sort, json: options?.json });
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
@@ -316,4 +320,7 @@ program
}
});
// Register artifact workflow commands (experimental)
registerArtifactWorkflowCommands(program);
program.parse();
+540
View File
@@ -0,0 +1,540 @@
/**
* Artifact Workflow CLI Commands (Experimental)
*
* This file contains all artifact workflow commands in isolation for easy removal.
* Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents.
*
* To remove this feature:
* 1. Delete this file
* 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts
*/
import type { Command } from 'commander';
import ora from 'ora';
import chalk from 'chalk';
import path from 'path';
import * as fs from 'fs';
import {
loadChangeContext,
formatChangeStatus,
generateInstructions,
listSchemas,
getSchemaDir,
resolveSchema,
ArtifactGraph,
type ChangeStatus,
type ArtifactInstructions,
} from '../core/artifact-graph/index.js';
import { createChange, validateChangeName } from '../utils/change-utils.js';
const DEFAULT_SCHEMA = 'spec-driven';
/**
* Checks if color output is disabled via NO_COLOR env or --no-color flag.
*/
function isColorDisabled(): boolean {
return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true';
}
/**
* Gets the color function based on status.
*/
function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string {
if (isColorDisabled()) {
return (text: string) => text;
}
switch (status) {
case 'done':
return chalk.green;
case 'ready':
return chalk.yellow;
case 'blocked':
return chalk.red;
}
}
/**
* Gets the status indicator for an artifact.
*/
function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string {
const color = getStatusColor(status);
switch (status) {
case 'done':
return color('[x]');
case 'ready':
return color('[ ]');
case 'blocked':
return color('[-]');
}
}
/**
* Validates that a change exists and returns available changes if not.
* Checks directory existence directly to support scaffolded changes (without proposal.md).
*/
async function validateChangeExists(
changeName: string | undefined,
projectRoot: string
): Promise<string> {
const changesPath = path.join(projectRoot, 'openspec', 'changes');
// Get all change directories (not just those with proposal.md)
const getAvailableChanges = async (): Promise<string[]> => {
try {
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
.map((e) => e.name);
} catch {
return [];
}
};
if (!changeName) {
const available = await getAvailableChanges();
if (available.length === 0) {
throw new Error('No changes found. Create one with: openspec new change <name>');
}
throw new Error(
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
);
}
// Validate change name format to prevent path traversal
const nameValidation = validateChangeName(changeName);
if (!nameValidation.valid) {
throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);
}
// Check directory existence directly
const changePath = path.join(changesPath, changeName);
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
if (!exists) {
const available = await getAvailableChanges();
if (available.length === 0) {
throw new Error(
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`
);
}
throw new Error(
`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`
);
}
return changeName;
}
/**
* Validates that a schema exists and returns available schemas if not.
*/
function validateSchemaExists(schemaName: string): string {
const schemaDir = getSchemaDir(schemaName);
if (!schemaDir) {
const availableSchemas = listSchemas();
throw new Error(
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
);
}
return schemaName;
}
// -----------------------------------------------------------------------------
// Status Command
// -----------------------------------------------------------------------------
interface StatusOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function statusCommand(options: StatusOptions): Promise<void> {
const spinner = ora('Loading change status...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const context = loadChangeContext(projectRoot, changeName, schemaName);
const status = formatChangeStatus(context);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(status, null, 2));
return;
}
printStatusText(status);
} catch (error) {
spinner.stop();
throw error;
}
}
function printStatusText(status: ChangeStatus): void {
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
const total = status.artifacts.length;
console.log(`Change: ${status.changeName}`);
console.log(`Schema: ${status.schemaName}`);
console.log(`Progress: ${doneCount}/${total} artifacts complete`);
console.log();
for (const artifact of status.artifacts) {
const indicator = getStatusIndicator(artifact.status);
const color = getStatusColor(artifact.status);
let line = `${indicator} ${artifact.id}`;
if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) {
line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`);
}
console.log(line);
}
if (status.isComplete) {
console.log();
console.log(chalk.green('All artifacts complete!'));
}
}
// -----------------------------------------------------------------------------
// Next Command
// -----------------------------------------------------------------------------
interface NextOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function nextCommand(options: NextOptions): Promise<void> {
const spinner = ora('Finding next artifacts...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const context = loadChangeContext(projectRoot, changeName, schemaName);
const ready = context.graph.getNextArtifacts(context.completed);
const isComplete = context.graph.isComplete(context.completed);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(ready, null, 2));
return;
}
if (isComplete) {
console.log(chalk.green('All artifacts are complete!'));
return;
}
if (ready.length === 0) {
console.log('No artifacts are ready. All remaining artifacts are blocked.');
console.log('Run `openspec status --change ' + changeName + '` to see blocked dependencies.');
return;
}
console.log('Artifacts ready to create:');
for (const artifactId of ready) {
const color = getStatusColor('ready');
console.log(color(` ${artifactId}`));
}
} catch (error) {
spinner.stop();
throw error;
}
}
// -----------------------------------------------------------------------------
// Instructions Command
// -----------------------------------------------------------------------------
interface InstructionsOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function instructionsCommand(
artifactId: string | undefined,
options: InstructionsOptions
): Promise<void> {
const spinner = ora('Generating instructions...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
if (!artifactId) {
spinner.stop();
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const validIds = graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const context = loadChangeContext(projectRoot, changeName, schemaName);
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const instructions = generateInstructions(context, artifactId);
const isBlocked = instructions.dependencies.some((d) => !d.done);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
return;
}
printInstructionsText(instructions, isBlocked);
} catch (error) {
spinner.stop();
throw error;
}
}
function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {
if (isBlocked) {
console.log(chalk.yellow('Warning: This artifact has unmet dependencies.'));
console.log();
}
console.log(`Artifact: ${instructions.artifactId}`);
console.log(`Output: ${instructions.outputPath}`);
console.log(`Description: ${instructions.description}`);
console.log();
console.log('Dependencies:');
if (instructions.dependencies.length === 0) {
console.log(' (none)');
} else {
for (const dep of instructions.dependencies) {
const status = dep.done ? chalk.green('[done]') : chalk.red('[missing]');
console.log(` ${status} ${dep.id}`);
}
}
console.log();
if (instructions.unlocks.length > 0) {
console.log('Unlocks:');
for (const unlocked of instructions.unlocks) {
console.log(` ${unlocked}`);
}
console.log();
}
console.log('Template:');
console.log('─'.repeat(40));
console.log(instructions.template);
}
// -----------------------------------------------------------------------------
// Templates Command
// -----------------------------------------------------------------------------
interface TemplatesOptions {
schema?: string;
json?: boolean;
}
interface TemplateInfo {
artifactId: string;
templatePath: string;
source: 'user' | 'package';
}
async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();
try {
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const schemaDir = getSchemaDir(schemaName)!;
// Determine if this is a user override or package built-in
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
const userSchemasDir = getUserSchemasDir();
const isUserOverride = schemaDir.startsWith(userSchemasDir);
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: path.join(schemaDir, 'templates', artifact.template),
source: isUserOverride ? 'user' : 'package',
}));
spinner.stop();
if (options.json) {
const output: Record<string, { path: string; source: string }> = {};
for (const t of templates) {
output[t.artifactId] = { path: t.templatePath, source: t.source };
}
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(`Schema: ${schemaName}`);
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
console.log();
for (const t of templates) {
console.log(`${t.artifactId}:`);
console.log(` ${t.templatePath}`);
}
} catch (error) {
spinner.stop();
throw error;
}
}
// -----------------------------------------------------------------------------
// New Change Command
// -----------------------------------------------------------------------------
interface NewChangeOptions {
description?: string;
}
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
if (!name) {
throw new Error('Missing required argument <name>');
}
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
const spinner = ora(`Creating change '${name}'...`).start();
try {
const projectRoot = process.cwd();
await createChange(projectRoot, name);
// If description provided, create README.md with description
if (options.description) {
const { promises: fs } = await import('fs');
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
const readmePath = path.join(changeDir, 'README.md');
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
}
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
}
}
// -----------------------------------------------------------------------------
// Command Registration
// -----------------------------------------------------------------------------
/**
* Registers all artifact workflow commands on the given program.
* All commands are marked as experimental in their help text.
*/
export function registerArtifactWorkflowCommands(program: Command): void {
// Status command
program
.command('status')
.description('[Experimental] Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON')
.action(async (options: StatusOptions) => {
try {
await statusCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Next command
program
.command('next')
.description('[Experimental] Show artifacts ready to be created')
.option('--change <id>', 'Change name to check')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON array of ready artifact IDs')
.action(async (options: NextOptions) => {
try {
await nextCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Instructions command
program
.command('instructions [artifact]')
.description('[Experimental] Output enriched instructions for creating an artifact')
.option('--change <id>', 'Change name')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON')
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
try {
await instructionsCommand(artifactId, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Templates command
program
.command('templates')
.description('[Experimental] Show resolved template paths for all artifacts in a schema')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
.action(async (options: TemplatesOptions) => {
try {
await templatesCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// New command group with change subcommand
const newCmd = program.command('new').description('[Experimental] Create new items');
newCmd
.command('change <name>')
.description('[Experimental] Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.action(async (name: string, options: NewChangeOptions) => {
try {
await newChangeCommand(name, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
}
+5 -4
View File
@@ -1,7 +1,7 @@
import ora from 'ora';
import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive } from '../utils/interactive.js';
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { nearestMatches } from '../utils/match.js';
@@ -15,6 +15,7 @@ interface ExecuteOptions {
strict?: boolean;
json?: boolean;
noInteractive?: boolean;
interactive?: boolean; // Commander sets this to false when --no-interactive is used
concurrency?: string;
}
@@ -35,7 +36,7 @@ export class ValidateCommand {
await this.runBulkValidation({
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
return;
}
@@ -180,8 +181,8 @@ export class ValidateCommand {
bullets.forEach(b => console.error(` ${b}`));
}
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const spinner = !opts.json ? ora('Validating...').start() : undefined;
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
+23 -7
View File
@@ -1,6 +1,5 @@
import { promises as fs } from 'fs';
import path from 'path';
import { FileSystemUtils } from '../utils/file-system.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
@@ -447,15 +446,26 @@ export class ArchiveCommand {
// Load or create base target content
let targetContent: string;
let isNewSpec = false;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; only ADDED operations are permitted
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
if (plan.modified.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
);
}
// Warn about REMOVED requirements being ignored for new specs
if (plan.removed.length > 0) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`
)
);
}
isNewSpec = true;
targetContent = this.buildSpecSkeleton(specName, changeName);
}
@@ -498,9 +508,15 @@ export class ArchiveCommand {
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
// For new specs, REMOVED requirements are already warned about and ignored
// For existing specs, missing requirements are an error
if (!isNewSpec) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
// Skip removal for new specs (already warned above)
continue;
}
nameToBlock.delete(key);
}
+167
View File
@@ -0,0 +1,167 @@
import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';
import { loadSchema, parseSchema } from './schema.js';
/**
* Represents an artifact dependency graph.
* Provides methods for querying build order, ready artifacts, and completion status.
*/
export class ArtifactGraph {
private artifacts: Map<string, Artifact>;
private schema: SchemaYaml;
private constructor(schema: SchemaYaml) {
this.schema = schema;
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
}
/**
* Creates an ArtifactGraph from a YAML file path.
*/
static fromYaml(filePath: string): ArtifactGraph {
const schema = loadSchema(filePath);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from YAML content string.
*/
static fromYamlContent(yamlContent: string): ArtifactGraph {
const schema = parseSchema(yamlContent);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from a pre-validated schema object.
*/
static fromSchema(schema: SchemaYaml): ArtifactGraph {
return new ArtifactGraph(schema);
}
/**
* Gets a single artifact by ID.
*/
getArtifact(id: string): Artifact | undefined {
return this.artifacts.get(id);
}
/**
* Gets all artifacts in the graph.
*/
getAllArtifacts(): Artifact[] {
return Array.from(this.artifacts.values());
}
/**
* Gets the schema name.
*/
getName(): string {
return this.schema.name;
}
/**
* Gets the schema version.
*/
getVersion(): number {
return this.schema.version;
}
/**
* Computes the topological build order using Kahn's algorithm.
* Returns artifact IDs in the order they should be built.
*/
getBuildOrder(): string[] {
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
// Initialize all artifacts
for (const artifact of this.artifacts.values()) {
inDegree.set(artifact.id, artifact.requires.length);
dependents.set(artifact.id, []);
}
// Build reverse adjacency (who depends on whom)
for (const artifact of this.artifacts.values()) {
for (const req of artifact.requires) {
dependents.get(req)!.push(artifact.id);
}
}
// Start with roots (in-degree 0), sorted for determinism
const queue = [...this.artifacts.keys()]
.filter(id => inDegree.get(id) === 0)
.sort();
const result: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
result.push(current);
// Collect newly ready artifacts, then sort before adding
const newlyReady: string[] = [];
for (const dep of dependents.get(current)!) {
const newDegree = inDegree.get(dep)! - 1;
inDegree.set(dep, newDegree);
if (newDegree === 0) {
newlyReady.push(dep);
}
}
queue.push(...newlyReady.sort());
}
return result;
}
/**
* Gets artifacts that are ready to be created (all dependencies completed).
*/
getNextArtifacts(completed: CompletedSet): string[] {
const ready: string[] = [];
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
if (allDepsCompleted) {
ready.push(artifact.id);
}
}
// Sort for deterministic ordering
return ready.sort();
}
/**
* Checks if all artifacts in the graph are completed.
*/
isComplete(completed: CompletedSet): boolean {
for (const artifact of this.artifacts.values()) {
if (!completed.has(artifact.id)) {
return false;
}
}
return true;
}
/**
* Gets blocked artifacts and their unmet dependencies.
*/
getBlocked(completed: CompletedSet): BlockedArtifacts {
const blocked: BlockedArtifacts = {};
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
if (unmetDeps.length > 0) {
blocked[artifact.id] = unmetDeps.sort();
}
}
return blocked;
}
}
+42
View File
@@ -0,0 +1,42 @@
// Types
export {
ArtifactSchema,
SchemaYamlSchema,
type Artifact,
type SchemaYaml,
type CompletedSet,
type BlockedArtifacts,
} from './types.js';
// Schema loading and validation
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
// Graph operations
export { ArtifactGraph } from './graph.js';
// State detection
export { detectCompleted } from './state.js';
// Schema resolution
export {
resolveSchema,
listSchemas,
getSchemaDir,
getPackageSchemasDir,
getUserSchemasDir,
SchemaLoadError,
} from './resolver.js';
// Instruction loading
export {
loadTemplate,
loadChangeContext,
generateInstructions,
formatChangeStatus,
TemplateLoadError,
type ChangeContext,
type ArtifactInstructions,
type DependencyStatus,
type ArtifactStatus,
type ChangeStatus,
} from './instruction-loader.js';
@@ -0,0 +1,269 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getSchemaDir, resolveSchema } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import type { Artifact, CompletedSet } from './types.js';
/**
* Error thrown when loading a template fails.
*/
export class TemplateLoadError extends Error {
constructor(
message: string,
public readonly templatePath: string
) {
super(message);
this.name = 'TemplateLoadError';
}
}
/**
* Change context containing graph, completion state, and metadata.
*/
export interface ChangeContext {
/** The artifact dependency graph */
graph: ArtifactGraph;
/** Set of completed artifact IDs */
completed: CompletedSet;
/** Schema name being used */
schemaName: string;
/** Change name */
changeName: string;
/** Path to the change directory */
changeDir: string;
}
/**
* Enriched instructions for creating an artifact.
*/
export interface ArtifactInstructions {
/** Change name */
changeName: string;
/** Artifact ID */
artifactId: string;
/** Schema name */
schemaName: string;
/** Output path pattern (e.g., "proposal.md") */
outputPath: string;
/** Artifact description */
description: string;
/** Template content */
template: string;
/** Dependencies with completion status */
dependencies: DependencyStatus[];
/** Artifacts that become available after completing this one */
unlocks: string[];
}
/**
* Dependency status information.
*/
export interface DependencyStatus {
/** Artifact ID */
id: string;
/** Whether the dependency is completed */
done: boolean;
}
/**
* Status of a single artifact in the workflow.
*/
export interface ArtifactStatus {
/** Artifact ID */
id: string;
/** Output path pattern */
outputPath: string;
/** Status: done, ready, or blocked */
status: 'done' | 'ready' | 'blocked';
/** Missing dependencies (only for blocked) */
missingDeps?: string[];
}
/**
* Formatted change status.
*/
export interface ChangeStatus {
/** Change name */
changeName: string;
/** Schema name */
schemaName: string;
/** Whether all artifacts are complete */
isComplete: boolean;
/** Status of each artifact */
artifacts: ArtifactStatus[];
}
/**
* Loads a template from a schema's templates directory.
*
* @param schemaName - Schema name (e.g., "spec-driven")
* @param templatePath - Relative path within the templates directory (e.g., "proposal.md")
* @returns The template content
* @throws TemplateLoadError if the template cannot be loaded
*/
export function loadTemplate(schemaName: string, templatePath: string): string {
const schemaDir = getSchemaDir(schemaName);
if (!schemaDir) {
throw new TemplateLoadError(
`Schema '${schemaName}' not found`,
templatePath
);
}
const fullPath = path.join(schemaDir, 'templates', templatePath);
if (!fs.existsSync(fullPath)) {
throw new TemplateLoadError(
`Template not found: ${fullPath}`,
fullPath
);
}
try {
return fs.readFileSync(fullPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new TemplateLoadError(
`Failed to read template: ${ioError.message}`,
fullPath
);
}
}
/**
* Loads change context combining graph and completion state.
*
* @param projectRoot - Project root directory
* @param changeName - Change name
* @param schemaName - Optional schema name (defaults to "spec-driven")
* @returns Change context with graph, completed set, and metadata
*/
export function loadChangeContext(
projectRoot: string,
changeName: string,
schemaName: string = 'spec-driven'
): ChangeContext {
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
const completed = detectCompleted(graph, changeDir);
return {
graph,
completed,
schemaName,
changeName,
changeDir,
};
}
/**
* Generates enriched instructions for creating an artifact.
*
* @param context - Change context
* @param artifactId - Artifact ID to generate instructions for
* @returns Enriched artifact instructions
* @throws Error if artifact not found
*/
export function generateInstructions(
context: ChangeContext,
artifactId: string
): ArtifactInstructions {
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
}
const template = loadTemplate(context.schemaName, artifact.template);
const dependencies = getDependencyStatus(artifact, context.completed);
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
return {
changeName: context.changeName,
artifactId: artifact.id,
schemaName: context.schemaName,
outputPath: artifact.generates,
description: artifact.description,
template,
dependencies,
unlocks,
};
}
/**
* Gets dependency status for an artifact.
*/
function getDependencyStatus(
artifact: Artifact,
completed: CompletedSet
): DependencyStatus[] {
return artifact.requires.map(id => ({
id,
done: completed.has(id),
}));
}
/**
* Gets artifacts that become available after completing the given artifact.
*/
function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] {
const unlocks: string[] = [];
for (const artifact of graph.getAllArtifacts()) {
if (artifact.requires.includes(artifactId)) {
unlocks.push(artifact.id);
}
}
return unlocks.sort();
}
/**
* Formats the status of all artifacts in a change.
*
* @param context - Change context
* @returns Formatted change status
*/
export function formatChangeStatus(context: ChangeContext): ChangeStatus {
const artifacts = context.graph.getAllArtifacts();
const ready = new Set(context.graph.getNextArtifacts(context.completed));
const blocked = context.graph.getBlocked(context.completed);
const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => {
if (context.completed.has(artifact.id)) {
return {
id: artifact.id,
outputPath: artifact.generates,
status: 'done' as const,
};
}
if (ready.has(artifact.id)) {
return {
id: artifact.id,
outputPath: artifact.generates,
status: 'ready' as const,
};
}
return {
id: artifact.id,
outputPath: artifact.generates,
status: 'blocked' as const,
missingDeps: blocked[artifact.id] ?? [],
};
});
// Sort by build order for consistent output
const buildOrder = context.graph.getBuildOrder();
const orderMap = new Map(buildOrder.map((id, idx) => [id, idx]));
artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
return {
changeName: context.changeName,
schemaName: context.schemaName,
isComplete: context.graph.isComplete(context.completed),
artifacts: artifactStatuses,
};
}
+158
View File
@@ -0,0 +1,158 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getGlobalDataDir } from '../global-config.js';
import { parseSchema, SchemaValidationError } from './schema.js';
import type { SchemaYaml } from './types.js';
/**
* Error thrown when loading a schema fails.
*/
export class SchemaLoadError extends Error {
constructor(
message: string,
public readonly schemaPath: string,
public readonly cause?: Error
) {
super(message);
this.name = 'SchemaLoadError';
}
}
/**
* Gets the package's built-in schemas directory path.
* Uses import.meta.url to resolve relative to the current module.
*/
export function getPackageSchemasDir(): string {
const currentFile = fileURLToPath(import.meta.url);
// Navigate from dist/core/artifact-graph/ to package root's schemas/
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
}
/**
* Gets the user's schema override directory path.
*/
export function getUserSchemasDir(): string {
return path.join(getGlobalDataDir(), 'schemas');
}
/**
* Resolves a schema name to its directory path.
*
* Resolution order:
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
*
* @param name - Schema name (e.g., "spec-driven")
* @returns The path to the schema directory, or null if not found
*/
export function getSchemaDir(name: string): string | null {
// 1. Check user override directory
const userDir = path.join(getUserSchemasDir(), name);
const userSchemaPath = path.join(userDir, 'schema.yaml');
if (fs.existsSync(userSchemaPath)) {
return userDir;
}
// 2. Check package built-in directory
const packageDir = path.join(getPackageSchemasDir(), name);
const packageSchemaPath = path.join(packageDir, 'schema.yaml');
if (fs.existsSync(packageSchemaPath)) {
return packageDir;
}
return null;
}
/**
* Resolves a schema name to a SchemaYaml object.
*
* Resolution order:
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
*
* @param name - Schema name (e.g., "spec-driven")
* @returns The resolved schema object
* @throws Error if schema is not found in any location
*/
export function resolveSchema(name: string): SchemaYaml {
// Normalize name (remove .yaml extension if provided)
const normalizedName = name.replace(/\.ya?ml$/, '');
const schemaDir = getSchemaDir(normalizedName);
if (!schemaDir) {
const availableSchemas = listSchemas();
throw new Error(
`Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}`
);
}
const schemaPath = path.join(schemaDir, 'schema.yaml');
// Load and parse the schema
let content: string;
try {
content = fs.readFileSync(schemaPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to read schema at '${schemaPath}': ${ioError.message}`,
schemaPath,
ioError
);
}
try {
return parseSchema(content);
} catch (err) {
if (err instanceof SchemaValidationError) {
throw new SchemaLoadError(
`Invalid schema at '${schemaPath}': ${err.message}`,
schemaPath,
err
);
}
const parseError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to parse schema at '${schemaPath}': ${parseError.message}`,
schemaPath,
parseError
);
}
}
/**
* Lists all available schema names.
* Combines user override and package built-in schemas.
*/
export function listSchemas(): string[] {
const schemas = new Set<string>();
// Add package built-in schemas
const packageDir = getPackageSchemasDir();
if (fs.existsSync(packageDir)) {
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
schemas.add(entry.name);
}
}
}
}
// Add user override schemas (may override package schemas)
const userDir = getUserSchemasDir();
if (fs.existsSync(userDir)) {
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
schemas.add(entry.name);
}
}
}
}
return Array.from(schemas).sort();
}
+124
View File
@@ -0,0 +1,124 @@
import * as fs from 'node:fs';
import { parse as parseYaml } from 'yaml';
import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';
export class SchemaValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SchemaValidationError';
}
}
/**
* Loads and validates an artifact schema from a YAML file.
*/
export function loadSchema(filePath: string): SchemaYaml {
const content = fs.readFileSync(filePath, 'utf-8');
return parseSchema(content);
}
/**
* Parses and validates an artifact schema from YAML content.
*/
export function parseSchema(yamlContent: string): SchemaYaml {
const parsed = parseYaml(yamlContent);
// Validate with Zod
const result = SchemaYamlSchema.safeParse(parsed);
if (!result.success) {
const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
throw new SchemaValidationError(`Invalid schema: ${errors}`);
}
const schema = result.data;
// Check for duplicate artifact IDs
validateNoDuplicateIds(schema.artifacts);
// Check that all requires references are valid
validateRequiresReferences(schema.artifacts);
// Check for cycles
validateNoCycles(schema.artifacts);
return schema;
}
/**
* Validates that there are no duplicate artifact IDs.
*/
function validateNoDuplicateIds(artifacts: Artifact[]): void {
const seen = new Set<string>();
for (const artifact of artifacts) {
if (seen.has(artifact.id)) {
throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);
}
seen.add(artifact.id);
}
}
/**
* Validates that all `requires` references point to valid artifact IDs.
*/
function validateRequiresReferences(artifacts: Artifact[]): void {
const validIds = new Set(artifacts.map(a => a.id));
for (const artifact of artifacts) {
for (const req of artifact.requires) {
if (!validIds.has(req)) {
throw new SchemaValidationError(
`Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`
);
}
}
}
}
/**
* Validates that there are no cyclic dependencies.
* Uses DFS to detect cycles and reports the full cycle path.
*/
function validateNoCycles(artifacts: Artifact[]): void {
const artifactMap = new Map(artifacts.map(a => [a.id, a]));
const visited = new Set<string>();
const inStack = new Set<string>();
const parent = new Map<string, string>();
function dfs(id: string): string | null {
visited.add(id);
inStack.add(id);
const artifact = artifactMap.get(id);
if (!artifact) return null;
for (const dep of artifact.requires) {
if (!visited.has(dep)) {
parent.set(dep, id);
const cycle = dfs(dep);
if (cycle) return cycle;
} else if (inStack.has(dep)) {
// Found a cycle - reconstruct the path
const cyclePath = [dep];
let current = id;
while (current !== dep) {
cyclePath.unshift(current);
current = parent.get(current)!;
}
cyclePath.unshift(dep);
return cyclePath.join(' → ');
}
}
inStack.delete(id);
return null;
}
for (const artifact of artifacts) {
if (!visited.has(artifact.id)) {
const cycle = dfs(artifact.id);
if (cycle) {
throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import fg from 'fast-glob';
import type { CompletedSet } from './types.js';
import type { ArtifactGraph } from './graph.js';
import { FileSystemUtils } from '../../utils/file-system.js';
/**
* Detects which artifacts are completed by checking file existence in the change directory.
* Returns a Set of completed artifact IDs.
*
* @param graph - The artifact graph to check
* @param changeDir - The change directory to scan for files
* @returns Set of artifact IDs whose generated files exist
*/
export function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {
const completed = new Set<string>();
// Handle missing change directory gracefully
if (!fs.existsSync(changeDir)) {
return completed;
}
for (const artifact of graph.getAllArtifacts()) {
if (isArtifactComplete(artifact.generates, changeDir)) {
completed.add(artifact.id);
}
}
return completed;
}
/**
* Checks if an artifact is complete by checking if its generated file(s) exist.
* Supports both simple paths and glob patterns.
*/
function isArtifactComplete(generates: string, changeDir: string): boolean {
const fullPattern = path.join(changeDir, generates);
// Check if it's a glob pattern
if (isGlobPattern(generates)) {
return hasGlobMatches(fullPattern);
}
// Simple file path - check if file exists
return fs.existsSync(fullPattern);
}
/**
* Checks if a path contains glob pattern characters.
*/
function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}
/**
* Checks if a glob pattern has any matches.
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
*/
function hasGlobMatches(pattern: string): boolean {
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
return matches.length > 0;
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, { error: 'Artifact ID is required' }),
generates: z.string().min(1, { error: 'generates field is required' }),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, { error: 'Schema name is required' }),
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
// Runtime state types (not Zod - internal only)
// Slice 1: Simple completion tracking via filesystem
export type CompletedSet = Set<string>;
// Return type for blocked query
export interface BlockedArtifacts {
[artifactId: string]: string[];
}
+2 -1
View File
@@ -3,6 +3,7 @@ import path from 'path';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { Spec, Change } from '../schemas/index.js';
import { FileSystemUtils } from '../../utils/file-system.js';
export class JsonConverter {
convertSpecToJson(filePath: string): string {
@@ -43,7 +44,7 @@ export class JsonConverter {
}
private extractNameFromPath(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/');
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
const parts = normalizedPath.split('/');
for (let i = parts.length - 1; i >= 0; i--) {
+32
View File
@@ -5,6 +5,7 @@ import * as os from 'node:os';
// Constants
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
export const GLOBAL_DATA_DIR_NAME = 'openspec';
// TypeScript interfaces
export interface GlobalConfig {
@@ -45,6 +46,37 @@ export function getGlobalConfigDir(): string {
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
}
/**
* Gets the global data directory path following XDG Base Directory Specification.
* Used for user data like schema overrides.
*
* - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
* - Unix/macOS fallback: ~/.local/share/openspec/
* - Windows fallback: %LOCALAPPDATA%/openspec/
*/
export function getGlobalDataDir(): string {
// XDG_DATA_HOME takes precedence on all platforms when explicitly set
const xdgDataHome = process.env.XDG_DATA_HOME;
if (xdgDataHome) {
return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);
}
const platform = os.platform();
if (platform === 'win32') {
// Windows: use %LOCALAPPDATA%
const localAppData = process.env.LOCALAPPDATA;
if (localAppData) {
return path.join(localAppData, GLOBAL_DATA_DIR_NAME);
}
// Fallback for Windows if LOCALAPPDATA is not set
return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
}
// Unix/macOS fallback: ~/.local/share
return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);
}
/**
* Gets the path to the global config file.
*/
+3 -1
View File
@@ -2,9 +2,11 @@
export {
GLOBAL_CONFIG_DIR_NAME,
GLOBAL_CONFIG_FILE_NAME,
GLOBAL_DATA_DIR_NAME,
type GlobalConfig,
getGlobalConfigDir,
getGlobalConfigPath,
getGlobalConfig,
saveGlobalConfig
saveGlobalConfig,
getGlobalDataDir
} from './global-config.js';
+98 -8
View File
@@ -9,13 +9,78 @@ interface ChangeInfo {
name: string;
completedTasks: number;
totalTasks: number;
lastModified: Date;
}
interface ListOptions {
sort?: 'recent' | 'name';
json?: boolean;
}
/**
* Get the most recent modification time of any file in a directory (recursive).
* Falls back to the directory's own mtime if no files are found.
*/
async function getLastModified(dirPath: string): Promise<Date> {
let latest: Date | null = null;
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const stat = await fs.stat(fullPath);
if (latest === null || stat.mtime > latest) {
latest = stat.mtime;
}
}
}
}
await walk(dirPath);
// If no files found, use the directory's own modification time
if (latest === null) {
const dirStat = await fs.stat(dirPath);
return dirStat.mtime;
}
return latest;
}
/**
* Format a date as relative time (e.g., "2 hours ago", "3 days ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 30) {
return date.toLocaleDateString();
} else if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMins > 0) {
return `${diffMins}m ago`;
} else {
return 'just now';
}
}
export class ListCommand {
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes'): Promise<void> {
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> {
const { sort = 'recent', json = false } = options;
if (mode === 'changes') {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
@@ -30,24 +95,48 @@ export class ListCommand {
.map(entry => entry.name);
if (changeDirs.length === 0) {
console.log('No active changes found.');
if (json) {
console.log(JSON.stringify({ changes: [] }));
} else {
console.log('No active changes found.');
}
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, changeDir);
const changePath = path.join(changesDir, changeDir);
const lastModified = await getLastModified(changePath);
changes.push({
name: changeDir,
completedTasks: progress.completed,
totalTasks: progress.total
totalTasks: progress.total,
lastModified
});
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Sort by preference (default: recent first)
if (sort === 'recent') {
changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
} else {
changes.sort((a, b) => a.name.localeCompare(b.name));
}
// JSON output for programmatic use
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({ changes: jsonOutput }, null, 2));
return;
}
// Display results
console.log('Changes:');
@@ -56,7 +145,8 @@ export class ListCommand {
for (const change of changes) {
const paddedName = change.name.padEnd(nameWidth);
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
console.log(`${padding}${paddedName} ${status}`);
const timeAgo = formatRelativeTime(change.lastModified);
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
}
return;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export class UpdateCommand {
if (updatedSlashFiles.length > 0) {
// Normalize to forward slashes for cross-platform log consistency
const normalized = updatedSlashFiles.map((p) => p.replace(/\\/g, '/'));
const normalized = updatedSlashFiles.map((p) => FileSystemUtils.toPosixPath(p));
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
}
+4 -3
View File
@@ -5,12 +5,13 @@ import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
import {
import {
MIN_PURPOSE_LENGTH,
MAX_REQUIREMENT_TEXT_LENGTH,
VALIDATION_MESSAGES
VALIDATION_MESSAGES
} from './constants.js';
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
import { FileSystemUtils } from '../../utils/file-system.js';
export class Validator {
private strictMode: boolean;
@@ -359,7 +360,7 @@ export class Validator {
}
private extractNameFromPath(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/');
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
const parts = normalizedPath.split('/');
// Look for the directory name after 'specs' or 'changes'
+53 -23
View File
@@ -23,16 +23,26 @@ export class ViewCommand {
// Display summary metrics
this.displaySummary(changesData, specsData);
// Display draft changes
if (changesData.draft.length > 0) {
console.log(chalk.bold.gray('\nDraft Changes'));
console.log('─'.repeat(60));
changesData.draft.forEach((change) => {
console.log(` ${chalk.gray('○')} ${change.name}`);
});
}
// Display active changes
if (changesData.active.length > 0) {
console.log(chalk.bold.cyan('\nActive Changes'));
console.log('─'.repeat(60));
changesData.active.forEach(change => {
changesData.active.forEach((change) => {
const progressBar = this.createProgressBar(change.progress.completed, change.progress.total);
const percentage = change.progress.total > 0
? Math.round((change.progress.completed / change.progress.total) * 100)
: 0;
const percentage =
change.progress.total > 0
? Math.round((change.progress.completed / change.progress.total) * 100)
: 0;
console.log(
` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`
);
@@ -43,7 +53,7 @@ export class ViewCommand {
if (changesData.completed.length > 0) {
console.log(chalk.bold.green('\nCompleted Changes'));
console.log('─'.repeat(60));
changesData.completed.forEach(change => {
changesData.completed.forEach((change) => {
console.log(` ${chalk.green('✓')} ${change.name}`);
});
}
@@ -69,33 +79,43 @@ export class ViewCommand {
}
private async getChangesData(openspecDir: string): Promise<{
draft: Array<{ name: string }>;
active: Array<{ name: string; progress: { total: number; completed: number } }>;
completed: Array<{ name: string }>;
}> {
const changesDir = path.join(openspecDir, 'changes');
if (!fs.existsSync(changesDir)) {
return { active: [], completed: [] };
return { draft: [], active: [], completed: [] };
}
const draft: Array<{ name: string }> = [];
const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];
const completed: Array<{ name: string }> = [];
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'archive') {
const progress = await getTaskProgressForChange(changesDir, entry.name);
if (progress.total === 0 || progress.completed === progress.total) {
if (progress.total === 0) {
// No tasks defined yet - still in planning/draft phase
draft.push({ name: entry.name });
} else if (progress.completed === progress.total) {
// All tasks complete
completed.push({ name: entry.name });
} else {
// Has tasks but not all complete
active.push({ name: entry.name, progress });
}
}
}
// Sort active changes by completion percentage (ascending) and then by name for deterministic ordering
// Sort all categories by name for deterministic ordering
draft.sort((a, b) => a.name.localeCompare(b.name));
// Sort active changes by completion percentage (ascending) and then by name
active.sort((a, b) => {
const percentageA = a.progress.total > 0 ? a.progress.completed / a.progress.total : 0;
const percentageB = b.progress.total > 0 ? b.progress.completed / b.progress.total : 0;
@@ -106,7 +126,7 @@ export class ViewCommand {
});
completed.sort((a, b) => a.name.localeCompare(b.name));
return { active, completed };
return { draft, active, completed };
}
private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
@@ -142,35 +162,45 @@ export class ViewCommand {
}
private displaySummary(
changesData: { active: any[]; completed: any[] },
changesData: { draft: any[]; active: any[]; completed: any[] },
specsData: any[]
): void {
const totalChanges = changesData.active.length + changesData.completed.length;
const totalChanges =
changesData.draft.length + changesData.active.length + changesData.completed.length;
const totalSpecs = specsData.length;
const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);
// Calculate total task progress
let totalTasks = 0;
let completedTasks = 0;
changesData.active.forEach(change => {
changesData.active.forEach((change) => {
totalTasks += change.progress.total;
completedTasks += change.progress.completed;
});
changesData.completed.forEach(() => {
// Completed changes count as 100% done (we don't know exact task count)
// This is a simplification
});
console.log(chalk.bold('Summary:'));
console.log(` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`);
console.log(` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`);
console.log(
` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`
);
if (changesData.draft.length > 0) {
console.log(` ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`);
}
console.log(
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
);
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
if (totalTasks > 0) {
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
console.log(` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`);
console.log(
` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
/**
* Result of validating a change name.
*/
export interface ValidationResult {
valid: boolean;
error?: string;
}
/**
* Validates that a change name follows kebab-case conventions.
*
* Valid names:
* - Start with a lowercase letter
* - Contain only lowercase letters, numbers, and hyphens
* - Do not start or end with a hyphen
* - Do not contain consecutive hyphens
*
* @param name - The change name to validate
* @returns Validation result with `valid: true` or `valid: false` with an error message
*
* @example
* validateChangeName('add-auth') // { valid: true }
* validateChangeName('Add-Auth') // { valid: false, error: '...' }
*/
export function validateChangeName(name: string): ValidationResult {
// Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
// optionally followed by hyphen + lowercase letters/numbers (repeatable)
const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
if (!name) {
return { valid: false, error: 'Change name cannot be empty' };
}
if (!kebabCasePattern.test(name)) {
// Provide specific error messages for common mistakes
if (/[A-Z]/.test(name)) {
return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
}
if (/\s/.test(name)) {
return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };
}
if (/_/.test(name)) {
return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };
}
if (name.startsWith('-')) {
return { valid: false, error: 'Change name cannot start with a hyphen' };
}
if (name.endsWith('-')) {
return { valid: false, error: 'Change name cannot end with a hyphen' };
}
if (/--/.test(name)) {
return { valid: false, error: 'Change name cannot contain consecutive hyphens' };
}
if (/[^a-z0-9-]/.test(name)) {
return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
}
if (/^[0-9]/.test(name)) {
return { valid: false, error: 'Change name must start with a letter' };
}
return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
}
return { valid: true };
}
/**
* Creates a new change directory.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @param name - The change name (must be valid kebab-case)
* @throws Error if the change name is invalid
* @throws Error if the change directory already exists
*
* @example
* // Creates openspec/changes/add-auth/
* await createChange('/path/to/project', 'add-auth')
*/
export async function createChange(
projectRoot: string,
name: string
): Promise<void> {
// Validate the name first
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
// Build the change directory path
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
// Check if change already exists
if (await FileSystemUtils.directoryExists(changeDir)) {
throw new Error(`Change '${name}' already exists at ${changeDir}`);
}
// Create the directory (including parent directories if needed)
await FileSystemUtils.createDirectory(changeDir);
}
+8
View File
@@ -42,6 +42,14 @@ function findMarkerIndex(
}
export class FileSystemUtils {
/**
* Converts a path to use forward slashes (POSIX style).
* Essential for cross-platform compatibility with glob libraries like fast-glob.
*/
static toPosixPath(p: string): string {
return p.replace(/\\/g, '/');
}
private static isWindowsBasePath(basePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
}
+3 -2
View File
@@ -1,2 +1,3 @@
// Shared utilities will be implemented here
export {};
// Shared utilities
export { validateChangeName, createChange } from './change-utils.js';
export type { ValidationResult } from './change-utils.js';
+9 -2
View File
@@ -1,4 +1,4 @@
type InteractiveOptions = {
export type InteractiveOptions = {
/**
* Explicit "disable prompts" flag passed by internal callers.
*/
@@ -9,7 +9,12 @@ type InteractiveOptions = {
interactive?: boolean;
};
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
/**
* Resolves whether non-interactive mode is requested.
* Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
* Use this helper instead of manually checking options.noInteractive to avoid bugs.
*/
export function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
if (typeof value === 'boolean') return value;
return value?.noInteractive === true || value?.interactive === false;
}
@@ -17,6 +22,8 @@ function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
if (resolveNoInteractive(value)) return false;
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
// Respect the standard CI environment variable (set by GitHub Actions, GitLab CI, Travis, etc.)
if ('CI' in process.env) return false;
return !!process.stdin.isTTY;
}
+444
View File
@@ -0,0 +1,444 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { runCLI } from '../helpers/run-cli.js';
describe('artifact-workflow CLI commands', () => {
let tempDir: string;
let changesDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
});
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
/**
* Gets combined output from CLI result (ora outputs to stdout).
*/
function getOutput(result: { stdout: string; stderr: string }): string {
return result.stdout + result.stderr;
}
/**
* Creates a test change with the specified artifacts completed.
* Note: An "active" change requires at least a proposal.md file to be detected.
* If no artifacts are specified, we create an empty proposal to make it detectable.
*/
async function createTestChange(
changeName: string,
artifacts: ('proposal' | 'design' | 'specs' | 'tasks')[] = []
): Promise<string> {
const changeDir = path.join(changesDir, changeName);
await fs.mkdir(changeDir, { recursive: true });
// Always create proposal.md for the change to be detected as active
// Content varies based on whether 'proposal' is in artifacts list
const proposalContent = artifacts.includes('proposal')
? '## Why\nTest proposal content that is long enough.\n\n## What Changes\n- **test:** Something'
: '## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder';
await fs.writeFile(path.join(changeDir, 'proposal.md'), proposalContent);
if (artifacts.includes('design')) {
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\n\nTechnical design.');
}
if (artifacts.includes('specs')) {
// specs artifact uses glob pattern "specs/*.md" - files directly in specs/ directory
const specsDir = path.join(changeDir, 'specs');
await fs.mkdir(specsDir, { recursive: true });
await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.');
}
if (artifacts.includes('tasks')) {
await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\n- [ ] Task 1');
}
return changeDir;
}
describe('status command', () => {
it('shows status for scaffolded change without proposal.md', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['status', '--change', 'scaffolded-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('scaffolded-change');
expect(result.stdout).toContain('0/4 artifacts complete');
});
it('shows status for a change with proposal only', async () => {
// createTestChange always creates proposal.md, so this has 1 artifact complete
await createTestChange('minimal-change');
const result = await runCLI(['status', '--change', 'minimal-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('minimal-change');
expect(result.stdout).toContain('spec-driven');
expect(result.stdout).toContain('1/4 artifacts complete');
});
it('shows status for a change with proposal and design', async () => {
await createTestChange('partial-change', ['proposal', 'design']);
const result = await runCLI(['status', '--change', 'partial-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('2/4 artifacts complete');
expect(result.stdout).toContain('[x]');
});
it('outputs JSON when --json flag is used', async () => {
await createTestChange('json-change', ['proposal', 'design']);
const result = await runCLI(['status', '--change', 'json-change', '--json'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.changeName).toBe('json-change');
expect(json.schemaName).toBe('spec-driven');
expect(json.isComplete).toBe(false);
expect(Array.isArray(json.artifacts)).toBe(true);
expect(json.artifacts).toHaveLength(4);
const proposalArtifact = json.artifacts.find((a: any) => a.id === 'proposal');
expect(proposalArtifact.status).toBe('done');
});
it('shows complete status when all artifacts are done', async () => {
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['status', '--change', 'complete-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('4/4 artifacts complete');
expect(result.stdout).toContain('All artifacts complete!');
});
it('errors when --change is missing and lists available changes', async () => {
await createTestChange('some-change');
const result = await runCLI(['status'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required option --change');
expect(output).toContain('some-change');
});
it('errors for unknown change name and lists available changes', async () => {
await createTestChange('existing-change');
const result = await runCLI(['status', '--change', 'nonexistent'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Change 'nonexistent' not found");
expect(output).toContain('existing-change');
});
it('supports --schema option', async () => {
await createTestChange('tdd-change');
const result = await runCLI(['status', '--change', 'tdd-change', '--schema', 'tdd'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tdd');
});
it('errors for unknown schema', async () => {
await createTestChange('test-change');
const result = await runCLI(['status', '--change', 'test-change', '--schema', 'unknown'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Schema 'unknown' not found");
});
it('rejects path traversal in change name', async () => {
const result = await runCLI(['status', '--change', '../foo'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Invalid change name');
});
it('rejects absolute path in change name', async () => {
const result = await runCLI(['status', '--change', '/etc/passwd'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Invalid change name');
});
it('rejects slashes in change name', async () => {
const result = await runCLI(['status', '--change', 'foo/bar'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Invalid change name');
});
});
describe('next command', () => {
it('shows proposal as next for scaffolded change', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['next', '--change', 'scaffolded-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifacts ready to create');
expect(result.stdout).toContain('proposal');
});
it('shows design and specs as next when proposal exists', async () => {
// createTestChange always creates proposal.md, so design and specs are ready
await createTestChange('minimal-change');
const result = await runCLI(['next', '--change', 'minimal-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifacts ready to create');
expect(result.stdout).toContain('design');
expect(result.stdout).toContain('specs');
});
it('shows tasks as next after proposal, design, and specs', async () => {
await createTestChange('after-specs', ['proposal', 'design', 'specs']);
const result = await runCLI(['next', '--change', 'after-specs'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tasks');
});
it('shows complete message when all done', async () => {
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['next', '--change', 'complete-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('All artifacts are complete!');
});
it('outputs JSON array of ready artifacts', async () => {
await createTestChange('json-next', ['proposal']);
const result = await runCLI(['next', '--change', 'json-next', '--json'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(Array.isArray(json)).toBe(true);
expect(json).toContain('design');
expect(json).toContain('specs');
});
it('errors when --change is missing and lists available changes', async () => {
await createTestChange('some-change');
const result = await runCLI(['next'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required option --change');
expect(output).toContain('some-change');
});
});
describe('instructions command', () => {
it('shows instructions for proposal on scaffolded change', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['instructions', 'proposal', '--change', 'scaffolded-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifact: proposal');
expect(result.stdout).toContain('proposal.md');
expect(result.stdout).toContain('Template:');
});
it('shows instructions for design artifact', async () => {
await createTestChange('instr-change');
const result = await runCLI(['instructions', 'design', '--change', 'instr-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifact: design');
expect(result.stdout).toContain('design.md');
expect(result.stdout).toContain('Template:');
});
it('shows blocked warning for artifact with unmet dependencies', async () => {
// tasks depends on design and specs, which are not done yet
await createTestChange('blocked-change');
const result = await runCLI(['instructions', 'tasks', '--change', 'blocked-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Warning: This artifact has unmet dependencies');
expect(result.stdout).toContain('[missing]');
});
it('outputs JSON for instructions', async () => {
await createTestChange('json-instr', ['proposal']);
const result = await runCLI(['instructions', 'design', '--change', 'json-instr', '--json'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.artifactId).toBe('design');
expect(json.outputPath).toContain('design.md');
expect(typeof json.template).toBe('string');
expect(Array.isArray(json.dependencies)).toBe(true);
});
it('errors when artifact argument is missing', async () => {
await createTestChange('test-change');
const result = await runCLI(['instructions', '--change', 'test-change'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required argument <artifact>');
expect(output).toContain('Valid artifacts');
});
it('errors for unknown artifact', async () => {
await createTestChange('test-change');
const result = await runCLI(['instructions', 'unknown-artifact', '--change', 'test-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Artifact 'unknown-artifact' not found");
expect(output).toContain('Valid artifacts');
});
});
describe('templates command', () => {
it('shows template paths for default schema', async () => {
const result = await runCLI(['templates'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: spec-driven');
expect(result.stdout).toContain('proposal:');
expect(result.stdout).toContain('design:');
expect(result.stdout).toContain('specs:');
expect(result.stdout).toContain('tasks:');
});
it('shows template paths for custom schema', async () => {
const result = await runCLI(['templates', '--schema', 'tdd'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: tdd');
expect(result.stdout).toContain('spec:');
expect(result.stdout).toContain('tests:');
});
it('outputs JSON mapping of templates', async () => {
const result = await runCLI(['templates', '--json'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.proposal).toBeDefined();
expect(json.proposal.path).toContain('proposal.md');
expect(json.proposal.source).toBe('package');
});
it('errors for unknown schema', async () => {
const result = await runCLI(['templates', '--schema', 'nonexistent'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Schema 'nonexistent' not found");
});
});
describe('new change command', () => {
it('creates a new change directory', async () => {
const result = await runCLI(['new', 'change', 'my-new-feature'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const output = getOutput(result);
expect(output).toContain("Created change 'my-new-feature'");
const changeDir = path.join(changesDir, 'my-new-feature');
const stat = await fs.stat(changeDir);
expect(stat.isDirectory()).toBe(true);
});
it('creates README.md when --description is provided', async () => {
const result = await runCLI(
['new', 'change', 'described-feature', '--description', 'This is a test feature'],
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
const readmePath = path.join(changesDir, 'described-feature', 'README.md');
const content = await fs.readFile(readmePath, 'utf-8');
expect(content).toContain('described-feature');
expect(content).toContain('This is a test feature');
});
it('errors for invalid change name with spaces', async () => {
const result = await runCLI(['new', 'change', 'invalid name'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Error');
});
it('errors for duplicate change name', async () => {
await createTestChange('existing-change');
const result = await runCLI(['new', 'change', 'existing-change'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('exists');
});
it('errors when name argument is missing', async () => {
const result = await runCLI(['new', 'change'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
});
});
describe('help text', () => {
it('marks status command as experimental in help', async () => {
const result = await runCLI(['status', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks next command as experimental in help', async () => {
const result = await runCLI(['next', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks instructions command as experimental in help', async () => {
const result = await runCLI(['instructions', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks templates command as experimental in help', async () => {
const result = await runCLI(['templates', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks new command as experimental in help', async () => {
const result = await runCLI(['new', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
});
});
+14
View File
@@ -130,4 +130,18 @@ describe('top-level validate command', () => {
const result = await runCLI(['validate', changeId], { cwd: testDir });
expect(result.exitCode).toBe(0);
});
it('respects --no-interactive flag passed via CLI', async () => {
// This test ensures Commander.js --no-interactive flag is correctly parsed
// and passed to the validate command. The flag sets options.interactive = false
// (not options.noInteractive = true) due to Commander.js convention.
const result = await runCLI(['validate', '--specs', '--no-interactive'], {
cwd: testDir,
// Don't set OPEN_SPEC_INTERACTIVE to ensure we're testing the flag itself
env: { ...process.env, OPEN_SPEC_INTERACTIVE: undefined },
});
expect(result.exitCode).toBe(0);
// Should complete without hanging and without prompts
expect(result.stderr).not.toContain('What would you like to validate?');
});
});
+127
View File
@@ -127,6 +127,133 @@ Then expected result happens`;
expect(updatedContent).toContain('#### Scenario: Basic test');
});
it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => {
const changeName = 'new-spec-with-removed';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'gift-card');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create delta spec with both ADDED and REMOVED requirements
// This simulates refactoring where old fields are removed and new ones are added
const specContent = `# Gift Card - Changes
## ADDED Requirements
### Requirement: Logo and Background Color
The system SHALL support logo and backgroundColor fields for gift cards.
#### Scenario: Display gift card with logo
- **WHEN** a gift card is displayed
- **THEN** it shows the logo and backgroundColor
## REMOVED Requirements
### Requirement: Image Field
### Requirement: Thumbnail Field`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive - should succeed with warning about REMOVED requirements
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify warning was logged about REMOVED requirements being ignored
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).')
);
// Verify spec was created with only ADDED requirements
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md');
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain('# gift-card Specification');
expect(updatedContent).toContain('### Requirement: Logo and Background Color');
expect(updatedContent).toContain('#### Scenario: Display gift card with logo');
// REMOVED requirements should not be in the final spec
expect(updatedContent).not.toContain('### Requirement: Image Field');
expect(updatedContent).not.toContain('### Requirement: Thumbnail Field');
// Verify change was archived successfully
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBeGreaterThan(0);
expect(archives.some(a => a.includes(changeName))).toBe(true);
});
it('should still error on MODIFIED when creating new spec file', async () => {
const changeName = 'new-spec-with-modified';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'new-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create delta spec with MODIFIED requirement (should fail for new spec)
const specContent = `# New Capability - Changes
## ADDED Requirements
### Requirement: New Feature
New feature description.
## MODIFIED Requirements
### Requirement: Existing Feature
Modified content.`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive - should abort with error message (not throw, but log and return)
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify error message mentions MODIFIED not allowed for new specs
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
);
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
// Verify spec was NOT created
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md');
await expect(fs.access(mainSpecPath)).rejects.toThrow();
// Verify change was NOT archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.some(a => a.includes(changeName))).toBe(false);
});
it('should still error on RENAMED when creating new spec file', async () => {
const changeName = 'new-spec-with-renamed';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'another-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create delta spec with RENAMED requirement (should fail for new spec)
const specContent = `# Another Capability - Changes
## ADDED Requirements
### Requirement: New Feature
New feature description.
## RENAMED Requirements
- FROM: \`### Requirement: Old Name\`
- TO: \`### Requirement: New Name\``;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive - should abort with error message (not throw, but log and return)
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify error message mentions RENAMED not allowed for new specs
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
);
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
// Verify spec was NOT created
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md');
await expect(fs.access(mainSpecPath)).rejects.toThrow();
// Verify change was NOT archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.some(a => a.includes(changeName))).toBe(false);
});
it('should throw error if change does not exist', async () => {
await expect(
archiveCommand.execute('non-existent-change', { yes: true })
+268
View File
@@ -0,0 +1,268 @@
import { describe, it, expect } from 'vitest';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
describe('artifact-graph/graph', () => {
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
name: 'test',
version: 1,
artifacts,
});
describe('fromSchema', () => {
it('should create graph from schema object', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('test');
expect(graph.getVersion()).toBe(1);
});
});
describe('fromYamlContent', () => {
it('should create graph from YAML string', () => {
const yaml = `
name: my-workflow
version: 2
artifacts:
- id: doc
generates: doc.md
description: Documentation
template: templates/doc.md
`;
const graph = ArtifactGraph.fromYamlContent(yaml);
expect(graph.getName()).toBe('my-workflow');
expect(graph.getVersion()).toBe(2);
expect(graph.getArtifact('doc')).toBeDefined();
});
});
describe('getArtifact', () => {
it('should return artifact by ID', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const artifact = graph.getArtifact('proposal');
expect(artifact).toBeDefined();
expect(artifact?.id).toBe('proposal');
expect(artifact?.generates).toBe('proposal.md');
});
it('should return undefined for non-existent ID', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getArtifact('nonexistent')).toBeUndefined();
});
});
describe('getAllArtifacts', () => {
it('should return all artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const artifacts = graph.getAllArtifacts();
expect(artifacts).toHaveLength(3);
expect(artifacts.map(a => a.id).sort()).toEqual(['A', 'B', 'C']);
});
});
describe('getBuildOrder', () => {
it('should return correct order for linear chain A → B → C', () => {
const schema = createSchema([
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['B'] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
expect(order).toEqual(['A', 'B', 'C']);
});
it('should handle diamond dependency correctly', () => {
// A → B, A → C, B → D, C → D
const schema = createSchema([
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
// A must come before B and C; D must come last
expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));
expect(order.indexOf('A')).toBeLessThan(order.indexOf('C'));
expect(order.indexOf('B')).toBeLessThan(order.indexOf('D'));
expect(order.indexOf('C')).toBeLessThan(order.indexOf('D'));
});
it('should return independent artifacts in stable sorted order', () => {
const schema = createSchema([
{ id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'M', generates: 'm.md', description: 'M', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
// All independent, should be sorted alphabetically for stability
expect(order).toEqual(['A', 'M', 'Z']);
});
});
describe('getNextArtifacts', () => {
it('should return root artifacts when nothing completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set());
expect(ready.sort()).toEqual(['A', 'C']);
});
it('should include artifact when all deps completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set(['A']));
expect(ready).toEqual(['B']);
});
it('should not include completed artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set(['A', 'B']));
expect(ready).toEqual([]);
});
it('should handle diamond dependency correctly', () => {
// D requires B and C
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Only A completed - B and C ready, D not
expect(graph.getNextArtifacts(new Set(['A'])).sort()).toEqual(['B', 'C']);
// Only B completed (from deps) - C still needed for D
expect(graph.getNextArtifacts(new Set(['A', 'B']))).toEqual(['C']);
// Both B and C completed - D ready
expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']);
});
});
describe('isComplete', () => {
it('should return true when all artifacts completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.isComplete(new Set(['A', 'B']))).toBe(true);
});
it('should return false when some artifacts incomplete', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.isComplete(new Set(['A']))).toBe(false);
expect(graph.isComplete(new Set())).toBe(false);
});
});
describe('getBlocked', () => {
it('should return empty object when nothing is blocked', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set())).toEqual({});
});
it('should return artifact blocked by single dependency', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set())).toEqual({ B: ['A'] });
});
it('should return artifact blocked by multiple dependencies', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Neither A nor B completed
expect(graph.getBlocked(new Set())).toEqual({ C: ['A', 'B'] });
});
it('should only list unmet dependencies', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// A completed, B not
expect(graph.getBlocked(new Set(['A']))).toEqual({ C: ['B'] });
});
it('should not include completed artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({});
});
});
});
@@ -0,0 +1,264 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
loadTemplate,
loadChangeContext,
generateInstructions,
formatChangeStatus,
TemplateLoadError,
} from '../../../src/core/artifact-graph/instruction-loader.js';
describe('instruction-loader', () => {
describe('loadTemplate', () => {
it('should load template from schema directory', () => {
// Uses built-in spec-driven schema
const template = loadTemplate('spec-driven', 'proposal.md');
expect(template).toContain('## Why');
expect(template).toContain('## What Changes');
});
it('should throw TemplateLoadError for non-existent template', () => {
expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow(
TemplateLoadError
);
});
it('should throw TemplateLoadError for non-existent schema', () => {
expect(() => loadTemplate('nonexistent-schema', 'proposal.md')).toThrow(
TemplateLoadError
);
});
it('should include template path in error', () => {
try {
loadTemplate('spec-driven', 'nonexistent.md');
expect.fail('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TemplateLoadError);
expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md');
}
});
});
describe('loadChangeContext', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should load context with default schema', () => {
const context = loadChangeContext(tempDir, 'my-change');
expect(context.schemaName).toBe('spec-driven');
expect(context.changeName).toBe('my-change');
expect(context.graph.getName()).toBe('spec-driven');
expect(context.completed.size).toBe(0);
});
it('should load context with custom schema', () => {
const context = loadChangeContext(tempDir, 'my-change', 'tdd');
expect(context.schemaName).toBe('tdd');
expect(context.graph.getName()).toBe('tdd');
});
it('should detect completed artifacts', () => {
// Create change directory with proposal.md
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
const context = loadChangeContext(tempDir, 'my-change');
expect(context.completed.has('proposal')).toBe(true);
});
it('should return empty completed set for non-existent change directory', () => {
const context = loadChangeContext(tempDir, 'nonexistent-change');
expect(context.completed.size).toBe(0);
});
});
describe('generateInstructions', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should include artifact metadata', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal');
expect(instructions.changeName).toBe('my-change');
expect(instructions.artifactId).toBe('proposal');
expect(instructions.schemaName).toBe('spec-driven');
expect(instructions.outputPath).toBe('proposal.md');
});
it('should include template content', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal');
expect(instructions.template).toContain('## Why');
});
it('should show dependencies with completion status', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'specs');
expect(instructions.dependencies).toHaveLength(1);
expect(instructions.dependencies[0].id).toBe('proposal');
expect(instructions.dependencies[0].done).toBe(false);
});
it('should mark completed dependencies as done', () => {
// Create proposal
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'specs');
expect(instructions.dependencies[0].done).toBe(true);
});
it('should list artifacts unlocked by this one', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal');
// proposal unlocks specs and design
expect(instructions.unlocks).toContain('specs');
expect(instructions.unlocks).toContain('design');
});
it('should have empty dependencies for root artifact', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal');
expect(instructions.dependencies).toHaveLength(0);
});
it('should throw for non-existent artifact', () => {
const context = loadChangeContext(tempDir, 'my-change');
expect(() => generateInstructions(context, 'nonexistent')).toThrow(
"Artifact 'nonexistent' not found"
);
});
});
describe('formatChangeStatus', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should show all artifacts as ready/blocked when nothing completed', () => {
const context = loadChangeContext(tempDir, 'my-change');
const status = formatChangeStatus(context);
expect(status.changeName).toBe('my-change');
expect(status.schemaName).toBe('spec-driven');
expect(status.isComplete).toBe(false);
// proposal has no deps, should be ready
const proposal = status.artifacts.find(a => a.id === 'proposal');
expect(proposal?.status).toBe('ready');
// specs depends on proposal, should be blocked
const specs = status.artifacts.find(a => a.id === 'specs');
expect(specs?.status).toBe('blocked');
expect(specs?.missingDeps).toContain('proposal');
});
it('should show completed artifacts as done', () => {
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
const context = loadChangeContext(tempDir, 'my-change');
const status = formatChangeStatus(context);
const proposal = status.artifacts.find(a => a.id === 'proposal');
expect(proposal?.status).toBe('done');
// specs should now be ready
const specs = status.artifacts.find(a => a.id === 'specs');
expect(specs?.status).toBe('ready');
});
it('should include output paths for each artifact', () => {
const context = loadChangeContext(tempDir, 'my-change');
const status = formatChangeStatus(context);
const proposal = status.artifacts.find(a => a.id === 'proposal');
expect(proposal?.outputPath).toBe('proposal.md');
const specs = status.artifacts.find(a => a.id === 'specs');
expect(specs?.outputPath).toBe('specs/**/*.md');
});
it('should report isComplete true when all done', () => {
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true });
// Create all required files for spec-driven schema
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
fs.writeFileSync(path.join(changeDir, 'specs', 'test.md'), '# Spec');
fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design');
fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks');
const context = loadChangeContext(tempDir, 'my-change');
const status = formatChangeStatus(context);
expect(status.isComplete).toBe(true);
expect(status.artifacts.every(a => a.status === 'done')).toBe(true);
});
it('should show blocked artifacts with missing dependencies', () => {
const context = loadChangeContext(tempDir, 'my-change');
const status = formatChangeStatus(context);
// tasks requires specs and design
const tasks = status.artifacts.find(a => a.id === 'tasks');
expect(tasks?.status).toBe('blocked');
expect(tasks?.missingDeps).toContain('specs');
expect(tasks?.missingDeps).toContain('design');
});
it('should sort artifacts in build order', () => {
const context = loadChangeContext(tempDir, 'my-change');
const status = formatChangeStatus(context);
const ids = status.artifacts.map(a => a.id);
const proposalIdx = ids.indexOf('proposal');
const specsIdx = ids.indexOf('specs');
const tasksIdx = ids.indexOf('tasks');
// proposal must come before specs, specs before tasks
expect(proposalIdx).toBeLessThan(specsIdx);
expect(specsIdx).toBeLessThan(tasksIdx);
});
});
});
+327
View File
@@ -0,0 +1,327 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
resolveSchema,
listSchemas,
SchemaLoadError,
getSchemaDir,
getPackageSchemasDir,
getUserSchemasDir,
} from '../../../src/core/artifact-graph/resolver.js';
describe('artifact-graph/resolver', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
originalEnv = { ...process.env };
});
afterEach(() => {
process.env = originalEnv;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getPackageSchemasDir', () => {
it('should return a valid path', () => {
const schemasDir = getPackageSchemasDir();
expect(typeof schemasDir).toBe('string');
expect(schemasDir.length).toBeGreaterThan(0);
});
});
describe('getUserSchemasDir', () => {
it('should use XDG_DATA_HOME when set', () => {
process.env.XDG_DATA_HOME = tempDir;
const userDir = getUserSchemasDir();
expect(userDir).toBe(path.join(tempDir, 'openspec', 'schemas'));
});
});
describe('getSchemaDir', () => {
it('should return null for non-existent schema', () => {
const dir = getSchemaDir('nonexistent-schema');
expect(dir).toBeNull();
});
it('should return package dir for built-in schema', () => {
const dir = getSchemaDir('spec-driven');
expect(dir).not.toBeNull();
expect(dir).toContain('schemas');
expect(dir).toContain('spec-driven');
});
it('should prefer user override directory', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
'name: custom\nversion: 1\nartifacts: []'
);
const dir = getSchemaDir('spec-driven');
expect(dir).toBe(userSchemaDir);
});
});
describe('resolveSchema', () => {
it('should return built-in spec-driven schema', () => {
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('spec-driven');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should return built-in tdd schema', () => {
const schema = resolveSchema('tdd');
expect(schema.name).toBe('tdd');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should strip .yaml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yaml');
expect(schema1).toEqual(schema2);
});
it('should strip .yml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yml');
expect(schema1).toEqual(schema2);
});
it('should prefer user override over built-in', () => {
// Set up global data dir
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
// Create a custom schema with same name as built-in
const customSchema = `
name: custom-override
version: 99
artifacts:
- id: custom
generates: custom.md
description: Custom artifact
template: custom.md
`;
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), customSchema);
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('custom-override');
expect(schema.version).toBe(99);
});
it('should validate user override and throw on invalid schema', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
// Create an invalid schema (missing required fields)
const invalidSchema = `
name: invalid
version: 1
artifacts:
- id: broken
# missing generates, description, template
`;
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidSchema);
expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);
});
it('should include file path in validation error message', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
const invalidSchema = `
name: invalid
version: 1
artifacts:
- id: broken
`;
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
fs.writeFileSync(schemaPath, invalidSchema);
try {
resolveSchema('spec-driven');
expect.fail('Should have thrown');
} catch (e) {
const error = e as SchemaLoadError;
expect(error.message).toContain(schemaPath);
expect(error.schemaPath).toBe(schemaPath);
expect(error.cause).toBeDefined();
}
});
it('should detect cycles in user override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
// Create a schema with cyclic dependencies
const cyclicSchema = `
name: cyclic
version: 1
artifacts:
- id: a
generates: a.md
description: A
template: a.md
requires: [b]
- id: b
generates: b.md
description: B
template: b.md
requires: [a]
`;
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), cyclicSchema);
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
});
it('should detect invalid requires references in user override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
// Create a schema with invalid requires reference
const invalidRefSchema = `
name: invalid-ref
version: 1
artifacts:
- id: a
generates: a.md
description: A
template: a.md
requires: [nonexistent]
`;
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidRefSchema);
expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/);
});
it('should throw SchemaLoadError on YAML syntax errors', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
// Create malformed YAML
const malformedYaml = `
name: bad
version: [[[invalid yaml
`;
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
fs.writeFileSync(schemaPath, malformedYaml);
try {
resolveSchema('spec-driven');
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(SchemaLoadError);
const error = e as SchemaLoadError;
expect(error.message).toContain('Failed to parse');
expect(error.message).toContain(schemaPath);
}
});
it('should fall back to built-in when user override not found', () => {
process.env.XDG_DATA_HOME = tempDir;
// Don't create any user schemas
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('spec-driven');
expect(schema.version).toBe(1);
});
it('should throw when schema not found', () => {
expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);
});
it('should list available schemas in error message', () => {
try {
resolveSchema('nonexistent');
expect.fail('Should have thrown');
} catch (e) {
const error = e as Error;
expect(error.message).toContain('spec-driven');
expect(error.message).toContain('tdd');
}
});
});
describe('listSchemas', () => {
it('should list built-in schemas', () => {
const schemas = listSchemas();
expect(schemas).toContain('spec-driven');
expect(schemas).toContain('tdd');
});
it('should include user override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom-workflow');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
const schemas = listSchemas();
expect(schemas).toContain('custom-workflow');
expect(schemas).toContain('spec-driven');
});
it('should deduplicate schemas with same name', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(userSchemaDir, { recursive: true });
// Override spec-driven
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
const schemas = listSchemas();
// Should only appear once
const count = schemas.filter(s => s === 'spec-driven').length;
expect(count).toBe(1);
});
it('should return sorted list', () => {
const schemas = listSchemas();
const sorted = [...schemas].sort();
expect(schemas).toEqual(sorted);
});
it('should only include directories with schema.yaml', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemasBase = path.join(tempDir, 'openspec', 'schemas');
// Create a directory without schema.yaml
const emptyDir = path.join(userSchemasBase, 'empty-dir');
fs.mkdirSync(emptyDir, { recursive: true });
// Create a valid schema directory
const validDir = path.join(userSchemasBase, 'valid-schema');
fs.mkdirSync(validDir, { recursive: true });
fs.writeFileSync(path.join(validDir, 'schema.yaml'), 'name: valid\nversion: 1\nartifacts: []');
const schemas = listSchemas();
expect(schemas).toContain('valid-schema');
expect(schemas).not.toContain('empty-dir');
});
});
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, it, expect } from 'vitest';
import { parseSchema, SchemaValidationError } from '../../../src/core/artifact-graph/schema.js';
describe('artifact-graph/schema', () => {
describe('parseSchema', () => {
it('should parse valid schema YAML', () => {
const yaml = `
name: test-schema
version: 1
description: A test schema
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal
template: templates/proposal.md
requires: []
- id: design
generates: design.md
description: Design document
template: templates/design.md
requires:
- proposal
`;
const schema = parseSchema(yaml);
expect(schema.name).toBe('test-schema');
expect(schema.version).toBe(1);
expect(schema.description).toBe('A test schema');
expect(schema.artifacts).toHaveLength(2);
expect(schema.artifacts[0].id).toBe('proposal');
expect(schema.artifacts[1].requires).toEqual(['proposal']);
});
it('should throw on missing required fields', () => {
const yaml = `
name: test-schema
version: 1
artifacts:
- id: proposal
description: Missing generates and template
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/generates/);
});
it('should throw on missing schema name', () => {
const yaml = `
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: Test
template: templates/proposal.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/name/);
});
it('should throw on invalid version (non-positive)', () => {
const yaml = `
name: test
version: 0
artifacts:
- id: proposal
generates: proposal.md
description: Test
template: templates/proposal.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/positive/);
});
it('should throw on empty artifacts array', () => {
const yaml = `
name: test
version: 1
artifacts: []
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/artifact/i);
});
it('should throw on duplicate artifact IDs', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: First
template: templates/proposal.md
- id: proposal
generates: other.md
description: Duplicate
template: templates/other.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Duplicate artifact ID: proposal/);
});
it('should throw on invalid requires reference', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: design
generates: design.md
description: Design doc
template: templates/design.md
requires:
- nonexistent
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Invalid dependency reference.*nonexistent/);
});
it('should detect self-referencing cycle', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: Self reference
template: templates/a.md
requires:
- A
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
});
it('should detect simple A → B → A cycle', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: A
template: templates/a.md
requires:
- B
- id: B
generates: b.md
description: B
template: templates/b.md
requires:
- A
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
expect(() => parseSchema(yaml)).toThrow(/→/);
});
it('should detect longer A → B → C → A cycle and list all IDs', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: A
template: templates/a.md
requires:
- C
- id: B
generates: b.md
description: B
template: templates/b.md
requires:
- A
- id: C
generates: c.md
description: C
template: templates/c.md
requires:
- B
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
// Should contain all three in the cycle path
const error = (() => {
try {
parseSchema(yaml);
} catch (e) {
return e;
}
})() as Error;
expect(error.message).toMatch(/A.*→.*B|B.*→.*C|C.*→.*A/);
});
it('should allow default empty requires array', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: root
generates: root.md
description: Root artifact
template: templates/root.md
`;
const schema = parseSchema(yaml);
expect(schema.artifacts[0].requires).toEqual([]);
});
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
describe('artifact-graph/state', () => {
let tempDir: string;
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
name: 'test',
version: 1,
artifacts,
});
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('detectCompleted', () => {
it('should return empty set when changeDir does not exist', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, '/nonexistent/path');
expect(completed.size).toBe(0);
});
it('should return empty set when changeDir is empty', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
});
it('should mark artifact complete when file exists', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create the file
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
});
it('should not mark artifact complete when file does not exist', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Only create proposal.md
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
expect(completed.has('design')).toBe(false);
});
it('should handle nested paths', () => {
const schema = createSchema([
{ id: 'nested', generates: 'docs/design.md', description: 'Nested', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create nested directory and file
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'docs', 'design.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('nested')).toBe(true);
});
it('should detect glob pattern as complete when files exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with files
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-a.md'), 'content');
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-b.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(true);
});
it('should not mark glob pattern complete when directory is empty', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create empty specs directory
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should not mark glob pattern complete when directory does not exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should not mark glob pattern complete when only non-matching files exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with non-matching files
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'readme.txt'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should handle multiple artifacts with mixed completion', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: ['proposal'] },
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: ['proposal'] },
{ id: 'tasks', generates: 'tasks.md', description: 'Tasks', template: 't.md', requires: ['specs', 'design'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create some files
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'auth.md'), 'content');
// design.md and tasks.md do not exist
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
expect(completed.has('specs')).toBe(true);
expect(completed.has('design')).toBe(false);
expect(completed.has('tasks')).toBe(false);
});
});
});
@@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveSchema } from '../../../src/core/artifact-graph/resolver.js';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
import type { BlockedArtifacts } from '../../../src/core/artifact-graph/types.js';
/**
* Normalize BlockedArtifacts for comparison by sorting dependency arrays.
* The order of unmet dependencies is not guaranteed, so we sort for stable assertions.
*/
function normalizeBlocked(blocked: BlockedArtifacts): BlockedArtifacts {
const normalized: BlockedArtifacts = {};
for (const [key, deps] of Object.entries(blocked)) {
normalized[key] = [...deps].sort();
}
return normalized;
}
describe('artifact-graph workflow integration', () => {
let tempDir: string;
beforeEach(() => {
// Use a unique temp directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workflow-test-'));
});
afterEach(() => {
// Clean up temp directory after each test
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('spec-driven workflow', () => {
it('should progress through complete workflow', () => {
// 1. Resolve the real built-in schema
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Verify schema structure
expect(graph.getName()).toBe('spec-driven');
expect(graph.getAllArtifacts()).toHaveLength(4);
// 2. Initial state - nothing complete, only proposal is ready
let completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
expect(graph.isComplete(completed)).toBe(false);
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
specs: ['proposal'],
design: ['proposal'],
tasks: ['design', 'specs'],
});
// 3. Create proposal.md - now specs and design become ready
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal\n\nInitial proposal content.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal']));
expect(graph.getNextArtifacts(completed).sort()).toEqual(['design', 'specs']);
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
tasks: ['design', 'specs'],
});
// 4. Create design.md - specs still needed for tasks
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design\n\nTechnical design content.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design']));
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
expect(graph.getBlocked(completed)).toEqual({
tasks: ['specs'],
});
// 5. Create specs directory with a spec file - tasks becomes ready
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'feature-auth.md'), '# Auth Spec\n\nAuthentication specification.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design', 'specs']));
expect(graph.getNextArtifacts(completed)).toEqual(['tasks']);
expect(graph.getBlocked(completed)).toEqual({});
// 6. Create tasks.md - workflow complete
fs.writeFileSync(path.join(tempDir, 'tasks.md'), '# Tasks\n\n- [ ] Implement feature');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design', 'specs', 'tasks']));
expect(graph.getNextArtifacts(completed)).toEqual([]);
expect(graph.isComplete(completed)).toBe(true);
expect(graph.getBlocked(completed)).toEqual({});
});
it('should handle out-of-order file creation', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Create files in wrong order - design before proposal
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design');
let completed = detectCompleted(graph, tempDir);
// design file exists but it's still marked complete (filesystem-based)
expect(completed).toEqual(new Set(['design']));
// proposal is still the only "ready" artifact since it has no deps
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
// Now create proposal
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design']));
// specs is the only thing ready now (design already done)
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
});
it('should handle multiple spec files in glob pattern', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Complete prerequisites
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
// Create specs directory with multiple files
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'auth.md'), '# Auth');
fs.writeFileSync(path.join(specsDir, 'api.md'), '# API');
fs.writeFileSync(path.join(specsDir, 'database.md'), '# Database');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(true);
});
});
describe('tdd workflow', () => {
it('should progress through complete workflow', () => {
const schema = resolveSchema('tdd');
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('tdd');
expect(graph.getBuildOrder()).toEqual(['spec', 'tests', 'implementation', 'docs']);
// Initial state
let completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['spec']);
// Create spec
fs.writeFileSync(path.join(tempDir, 'spec.md'), '# Feature Spec');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['tests']);
// Create tests directory with test file
const testsDir = path.join(tempDir, 'tests');
fs.mkdirSync(testsDir, { recursive: true });
fs.writeFileSync(path.join(testsDir, 'feature.test.ts'), 'describe("feature", () => {});');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['implementation']);
// Create src directory with implementation
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'feature.ts'), 'export function feature() {}');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['docs']);
// Create docs
const docsDir = path.join(tempDir, 'docs');
fs.mkdirSync(docsDir, { recursive: true });
fs.writeFileSync(path.join(docsDir, 'feature.md'), '# Feature Documentation');
completed = detectCompleted(graph, tempDir);
expect(graph.isComplete(completed)).toBe(true);
});
});
describe('build order consistency', () => {
it('should return consistent build order across multiple calls', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
const order1 = graph.getBuildOrder();
const order2 = graph.getBuildOrder();
const order3 = graph.getBuildOrder();
expect(order1).toEqual(order2);
expect(order2).toEqual(order3);
});
});
describe('empty and edge cases', () => {
it('should handle empty change directory gracefully', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Directory exists but is empty
const completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
});
it('should handle non-existent change directory', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
const nonExistentDir = path.join(tempDir, 'does-not-exist');
const completed = detectCompleted(graph, nonExistentDir);
expect(completed.size).toBe(0);
});
it('should not count non-matching files in glob directories', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with wrong file types
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'notes.txt'), 'not a markdown file');
fs.writeFileSync(path.join(specsDir, 'data.json'), '{}');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
});
});
+50
View File
@@ -28,6 +28,56 @@ describe('ViewCommand', () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('shows changes with no tasks in Draft section, not Completed', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
// Empty change (no tasks.md) - should show in Draft
await fs.mkdir(path.join(changesDir, 'empty-change'), { recursive: true });
// Change with tasks.md but no tasks - should show in Draft
await fs.mkdir(path.join(changesDir, 'no-tasks-change'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'no-tasks-change', 'tasks.md'), '# Tasks\n\nNo tasks yet.');
// Change with all tasks complete - should show in Completed
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Done task\n'
);
const viewCommand = new ViewCommand();
await viewCommand.execute(tempDir);
const output = logOutput.map(stripAnsi).join('\n');
// Draft section should contain empty and no-tasks changes
expect(output).toContain('Draft Changes');
expect(output).toContain('empty-change');
expect(output).toContain('no-tasks-change');
// Completed section should only contain changes with all tasks done
expect(output).toContain('Completed Changes');
expect(output).toContain('completed-change');
// Verify empty-change and no-tasks-change are in Draft section (marked with ○)
const draftLines = logOutput
.map(stripAnsi)
.filter((line) => line.includes('○'));
const draftNames = draftLines.map((line) => line.trim().replace('○ ', ''));
expect(draftNames).toContain('empty-change');
expect(draftNames).toContain('no-tasks-change');
// Verify completed-change is in Completed section (marked with ✓)
const completedLines = logOutput
.map(stripAnsi)
.filter((line) => line.includes('✓'));
const completedNames = completedLines.map((line) => line.trim().replace('✓ ', ''));
expect(completedNames).toContain('completed-change');
expect(completedNames).not.toContain('empty-change');
expect(completedNames).not.toContain('no-tasks-change');
});
it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
+11
View File
@@ -90,6 +90,9 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
windowsHide: true,
});
// Prevent child process from keeping the event loop alive
child.unref();
let stdout = '';
let stderr = '';
let timedOut = false;
@@ -113,11 +116,19 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
// Explicitly destroy streams to prevent hanging handles
child.stdout?.destroy();
child.stderr?.destroy();
child.stdin?.destroy();
reject(error);
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
// Explicitly destroy streams to prevent hanging handles
child.stdout?.destroy();
child.stderr?.destroy();
child.stdin?.destroy();
resolve({
exitCode: code,
signal,
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
import { validateChangeName, createChange } from '../../src/utils/change-utils.js';
describe('validateChangeName', () => {
describe('valid names', () => {
it('should accept simple kebab-case name', () => {
const result = validateChangeName('add-auth');
expect(result).toEqual({ valid: true });
});
it('should accept name with multiple segments', () => {
const result = validateChangeName('add-user-auth');
expect(result).toEqual({ valid: true });
});
it('should accept name with numeric suffix', () => {
const result = validateChangeName('add-feature-2');
expect(result).toEqual({ valid: true });
});
it('should accept single word name', () => {
const result = validateChangeName('refactor');
expect(result).toEqual({ valid: true });
});
it('should accept name with numbers in segments', () => {
const result = validateChangeName('upgrade-to-v2');
expect(result).toEqual({ valid: true });
});
});
describe('invalid names - uppercase rejected', () => {
it('should reject name with uppercase letters', () => {
const result = validateChangeName('Add-Auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase');
});
it('should reject fully uppercase name', () => {
const result = validateChangeName('ADD-AUTH');
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase');
});
});
describe('invalid names - spaces rejected', () => {
it('should reject name with spaces', () => {
const result = validateChangeName('add auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('spaces');
});
});
describe('invalid names - underscores rejected', () => {
it('should reject name with underscores', () => {
const result = validateChangeName('add_auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('underscores');
});
});
describe('invalid names - special characters rejected', () => {
it('should reject name with exclamation mark', () => {
const result = validateChangeName('add-auth!');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject name with @ symbol', () => {
const result = validateChangeName('add@auth');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('invalid names - leading/trailing hyphens rejected', () => {
it('should reject name with leading hyphen', () => {
const result = validateChangeName('-add-auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('start with a hyphen');
});
it('should reject name with trailing hyphen', () => {
const result = validateChangeName('add-auth-');
expect(result.valid).toBe(false);
expect(result.error).toContain('end with a hyphen');
});
});
describe('invalid names - consecutive hyphens rejected', () => {
it('should reject name with double hyphens', () => {
const result = validateChangeName('add--auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('consecutive hyphens');
});
});
describe('invalid names - empty name rejected', () => {
it('should reject empty string', () => {
const result = validateChangeName('');
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
});
});
describe('createChange', () => {
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('creates directory', () => {
it('should create change directory', async () => {
await createChange(testDir, 'add-auth');
const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth');
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
});
describe('duplicate change throws error', () => {
it('should throw error if change already exists', async () => {
await createChange(testDir, 'add-auth');
await expect(createChange(testDir, 'add-auth')).rejects.toThrow(
/already exists/
);
});
});
describe('invalid name throws validation error', () => {
it('should throw error for uppercase name', async () => {
await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow(
/lowercase/
);
});
it('should throw error for name with spaces', async () => {
await expect(createChange(testDir, 'add auth')).rejects.toThrow(
/spaces/
);
});
it('should throw error for empty name', async () => {
await expect(createChange(testDir, '')).rejects.toThrow(
/empty/
);
});
});
describe('creates parent directories if needed', () => {
it('should create openspec/changes/ directories if they do not exist', async () => {
const newProjectDir = path.join(testDir, 'new-project');
await fs.mkdir(newProjectDir);
// openspec/changes/ does not exist yet
await createChange(newProjectDir, 'add-auth');
const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth');
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js';
describe('interactive utilities', () => {
let originalOpenSpecInteractive: string | undefined;
let originalCI: string | undefined;
let originalStdinIsTTY: boolean | undefined;
beforeEach(() => {
// Save original environment
originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;
originalCI = process.env.CI;
originalStdinIsTTY = process.stdin.isTTY;
// Clear environment for clean testing
delete process.env.OPEN_SPEC_INTERACTIVE;
delete process.env.CI;
});
afterEach(() => {
// Restore original environment
if (originalOpenSpecInteractive !== undefined) {
process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;
} else {
delete process.env.OPEN_SPEC_INTERACTIVE;
}
if (originalCI !== undefined) {
process.env.CI = originalCI;
} else {
delete process.env.CI;
}
// Restore stdin.isTTY
Object.defineProperty(process.stdin, 'isTTY', {
value: originalStdinIsTTY,
writable: true,
configurable: true,
});
});
describe('resolveNoInteractive', () => {
it('should return true when noInteractive is true', () => {
expect(resolveNoInteractive({ noInteractive: true })).toBe(true);
});
it('should return true when interactive is false (Commander.js style)', () => {
// This is how Commander.js handles --no-interactive flag
expect(resolveNoInteractive({ interactive: false })).toBe(true);
});
it('should return false when noInteractive is false', () => {
expect(resolveNoInteractive({ noInteractive: false })).toBe(false);
});
it('should return false when interactive is true', () => {
expect(resolveNoInteractive({ interactive: true })).toBe(false);
});
it('should return false for empty options object', () => {
expect(resolveNoInteractive({})).toBe(false);
});
it('should return false for undefined', () => {
expect(resolveNoInteractive(undefined)).toBe(false);
});
it('should handle boolean value true', () => {
expect(resolveNoInteractive(true)).toBe(true);
});
it('should handle boolean value false', () => {
expect(resolveNoInteractive(false)).toBe(false);
});
it('should prioritize noInteractive over interactive when both set', () => {
// noInteractive: true should win
expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);
// If noInteractive is false, check interactive
expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);
});
});
describe('isInteractive', () => {
it('should return false when noInteractive is true', () => {
expect(isInteractive({ noInteractive: true })).toBe(false);
});
it('should return false when interactive is false (Commander.js --no-interactive)', () => {
expect(isInteractive({ interactive: false })).toBe(false);
});
it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {
process.env.OPEN_SPEC_INTERACTIVE = '0';
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return false when CI env var is set', () => {
process.env.CI = 'true';
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return false when CI env var is set to any value', () => {
// CI can be set to any value, not just "true"
process.env.CI = '1';
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return false when stdin is not a TTY', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return true when stdin is TTY and no flags disable it', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(true);
});
it('should return true when stdin is TTY and options are undefined', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive(undefined)).toBe(true);
});
});
});
+2 -1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
]
},
testTimeout: 10000,
hookTimeout: 10000
hookTimeout: 10000,
teardownTimeout: 3000
}
});
+6
View File
@@ -4,3 +4,9 @@ import { ensureCliBuilt } from './test/helpers/run-cli.js';
export async function setup() {
await ensureCliBuilt();
}
// Global teardown to ensure clean exit
export async function teardown() {
// Clear any remaining timers
// This helps prevent hanging handles from keeping the process alive
}