mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-18 05:59:56 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acdce5b2ba | ||
|
|
4f47bd2f37 | ||
|
|
9eb22410a9 | ||
|
|
f8747203a7 | ||
|
|
17016d5eaa | ||
|
|
a91adbdbd7 | ||
|
|
ac8f6d281c | ||
|
|
44ca42fbe1 | ||
|
|
39645d9284 | ||
|
|
11e195575f | ||
|
|
ab47cc6b00 | ||
|
|
8dcd1707ee | ||
|
|
4f4af5708d |
+208
-141
@@ -22,7 +22,7 @@ This system is **not** a workflow engine. It's an **artifact tracker with depend
|
||||
| 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) | `schemas/spec-driven.yaml` |
|
||||
| **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` |
|
||||
|
||||
@@ -47,33 +47,45 @@ Schemas can vary across multiple dimensions:
|
||||
| Language | `en`, `zh`, `es` |
|
||||
| Custom | `team-alpha`, `experimental` |
|
||||
|
||||
### Template Inheritance (2 Levels Max)
|
||||
### Schema Resolution (XDG Standard)
|
||||
|
||||
Schemas follow the XDG Base Directory Specification with a 2-level resolution:
|
||||
|
||||
```
|
||||
.openspec/
|
||||
├── templates/ # Shared (Level 1)
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ └── specs.md
|
||||
│
|
||||
└── schemas/
|
||||
└── tdd/
|
||||
├── schema.yaml
|
||||
└── templates/ # Overrides (Level 2)
|
||||
└── tests.md # TDD-specific
|
||||
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:**
|
||||
- Shared templates are the default
|
||||
- Schema-specific templates override OR add new
|
||||
- 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)
|
||||
- Max 2 levels - no deeper inheritance chains
|
||||
- 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
|
||||
- Clear boundaries between shared and specific
|
||||
- Schema + templates form a cohesive unit
|
||||
|
||||
---
|
||||
|
||||
@@ -90,83 +102,82 @@ The system answers:
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. ArtifactGraph
|
||||
### 1. ArtifactGraph (Slice 1 - COMPLETE)
|
||||
|
||||
The dependency graph engine.
|
||||
The dependency graph engine with XDG-compliant schema resolution.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Model artifacts as a DAG | Artifact with `requires: string[]` |
|
||||
| Track completion state | Sets for `completed`, `in_progress`, `failed` |
|
||||
| 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:**
|
||||
**Key Data Structures (Zod-validated):**
|
||||
|
||||
```
|
||||
Artifact {
|
||||
id: string
|
||||
generates: string // e.g., "proposal.md" or "specs/*.md"
|
||||
description: string
|
||||
instruction: string // path to template
|
||||
requires: string[] // artifact IDs this depends on
|
||||
}
|
||||
```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([]),
|
||||
});
|
||||
|
||||
ArtifactState {
|
||||
completed: Set<string>
|
||||
inProgress: Set<string>
|
||||
failed: Set<string>
|
||||
}
|
||||
const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.number().int().positive(),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1),
|
||||
});
|
||||
|
||||
ArtifactGraph {
|
||||
artifacts: Map<string, Artifact>
|
||||
}
|
||||
// Derived types
|
||||
type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `fromYaml(path)` - Load artifact definitions from YAML
|
||||
- `getNextArtifacts(state)` - Find artifacts ready to create
|
||||
- `getBuildOrder()` - Topological sort of all artifacts
|
||||
- `isComplete(state)` - Check if all artifacts done
|
||||
- `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. ChangeManager
|
||||
### 2. Change Utilities (Slice 2)
|
||||
|
||||
Multi-change orchestration layer. **CLI is fully deterministic** - no "active change" tracking.
|
||||
Simple utility functions for programmatic change creation. No class, no abstraction layer.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| CRUD changes | Create dirs under `openspec/changes/<name>/` |
|
||||
| Template fallback | Schema-specific → Shared (2 levels max) |
|
||||
| Create changes | Create dirs under `openspec/changes/<name>/` with README |
|
||||
| Name validation | Enforce kebab-case naming |
|
||||
|
||||
**Key Paths:**
|
||||
|
||||
```
|
||||
.openspec/schemas/ → Schema definitions (artifact graphs)
|
||||
.openspec/templates/ → Shared instruction templates
|
||||
openspec/changes/<name>/ → Change instances with artifacts
|
||||
openspec/changes/<name>/ → Change instances with artifacts (project-level)
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `isInitialized()` - Check for `.openspec/` existence
|
||||
- `listChanges()` - List all changes in `openspec/changes/`
|
||||
- `createChange(name, description)` - Create new change directory
|
||||
- `getChangePath(name)` - Get path to a change directory
|
||||
- `getSchemaPath(schemaName?)` - Find schema with fallback
|
||||
- `getTemplatePath(artifactId, schemaName?)` - Find template (schema → shared)
|
||||
**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:** No `getActiveChange()`, `setActiveChange()`, or `resolveChange()` - the agent infers which change from conversation context and passes it explicitly to CLI commands.
|
||||
**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
|
||||
### 3. InstructionLoader (Slice 3)
|
||||
|
||||
State detection and instruction enrichment.
|
||||
Template resolution and instruction enrichment.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Detect artifact completion | Scan filesystem, support glob patterns |
|
||||
| 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 |
|
||||
@@ -178,7 +189,7 @@ ChangeState {
|
||||
changeName: string
|
||||
changeDir: string
|
||||
graph: ArtifactGraph
|
||||
state: ArtifactState
|
||||
completed: Set<string>
|
||||
|
||||
// Methods
|
||||
getNextSteps(): string[]
|
||||
@@ -188,28 +199,34 @@ ChangeState {
|
||||
```
|
||||
|
||||
**Key Functions:**
|
||||
- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback
|
||||
- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point
|
||||
- `getChangeStatus(projectRoot, changeName?)` - Formatted status report
|
||||
- `resolveTemplatePath(artifactId, schemaName?)` - 2-level fallback
|
||||
|
||||
---
|
||||
|
||||
### 4. CLI
|
||||
### 4. CLI (Slice 4)
|
||||
|
||||
User interface layer. **All commands are deterministic** - require explicit `--change` parameter.
|
||||
|
||||
| Command | Function |
|
||||
|---------|----------|
|
||||
| `status --change <id>` | Show change progress |
|
||||
| `next --change <id>` | Show artifacts ready to create |
|
||||
| `instructions <artifact> --change <id>` | Get enriched instructions for artifact |
|
||||
| `list` | List all changes |
|
||||
| `new <name>` | Create change |
|
||||
| `init` | Initialize structure |
|
||||
| `templates --change <id>` | Show resolved template paths |
|
||||
| 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
|
||||
@@ -251,19 +268,19 @@ This works for ANY artifact in ANY schema - no new slash commands needed when sc
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ORCHESTRATION LAYER │
|
||||
│ ┌────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ InstructionLoader │───────▶│ ChangeManager │ │
|
||||
│ │ │ uses │ │ │
|
||||
│ └─────────┬──────────┘ └──────────────────────────┘ │
|
||||
│ │ InstructionLoader │ │ change-utils (Slice 2) │ │
|
||||
│ │ (Slice 3) │ │ createChange() │ │
|
||||
│ └─────────┬──────────┘ │ validateChangeName() │ │
|
||||
│ │ └──────────────────────────┘ │
|
||||
└────────────┼────────────────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CORE LAYER │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ ArtifactGraph │ │
|
||||
│ │ ArtifactGraph (Slice 1) │ │
|
||||
│ │ │ │
|
||||
│ │ Artifact ←────── ArtifactState │ │
|
||||
│ │ (data) (runtime state) │ │
|
||||
│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
@@ -272,9 +289,10 @@ This works for ANY artifact in ANY schema - no new slash commands needed when sc
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE LAYER │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────────┐ │
|
||||
│ │ YAML Config │ │ Filesystem Artifacts │ │
|
||||
│ │ - config.yaml │ │ - proposal.md, design.md │ │
|
||||
│ │ - schema.yaml │ │ - specs/*.md, tasks.md │ │
|
||||
│ │ XDG Schemas │ │ Project Artifacts │ │
|
||||
│ │ ~/.local/share/ │ │ openspec/changes/<name>/ │ │
|
||||
│ │ openspec/ │ │ - proposal.md, design.md │ │
|
||||
│ │ schemas/ │ │ - specs/*.md, tasks.md │ │
|
||||
│ └──────────────────┘ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -310,17 +328,27 @@ This separation means:
|
||||
- Agent handles all "smartness"
|
||||
- No config.yaml tracking of "active change"
|
||||
|
||||
### 3. Two-Level Template Fallback
|
||||
### 3. XDG-Compliant Schema Resolution
|
||||
|
||||
```
|
||||
schema-specific/templates/proposal.md
|
||||
${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
|
||||
↓ (not found)
|
||||
.openspec/templates/proposal.md (shared)
|
||||
<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)
|
||||
```
|
||||
|
||||
### 4. Glob Pattern Support
|
||||
### 5. Glob Pattern Support
|
||||
|
||||
`specs/*.md` allows multiple files to satisfy a single artifact:
|
||||
|
||||
@@ -333,7 +361,7 @@ if (artifact.generates.includes("*")) {
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Stateless State Detection
|
||||
### 6. Stateless State Detection
|
||||
|
||||
Every command re-scans the filesystem. No cached state to corrupt.
|
||||
|
||||
@@ -376,39 +404,41 @@ Structured as **vertical slices** - each slice is independently testable.
|
||||
|
||||
---
|
||||
|
||||
### Slice 1: "What's Ready?" (Core Query)
|
||||
### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE
|
||||
|
||||
**Combines:** Types + Graph + State Detection
|
||||
**Delivers:** Types + Graph + State Detection + Schema Resolution
|
||||
|
||||
```
|
||||
Input: schema YAML path + change directory
|
||||
Output: {
|
||||
completed: ['proposal'],
|
||||
ready: ['specs'],
|
||||
blocked: ['design', 'tasks'],
|
||||
buildOrder: ['proposal', 'specs', 'design', 'tasks']
|
||||
}
|
||||
```
|
||||
**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
|
||||
|
||||
**Testable behaviors:**
|
||||
- Parse schema YAML → returns correct artifact graph
|
||||
- Compute build order (topological sort) → correct ordering
|
||||
- Empty directory → only root artifacts (no dependencies) are ready
|
||||
- Directory with `proposal.md` → `specs` becomes ready
|
||||
- Directory with `specs/foo.md` → glob pattern detected as complete
|
||||
- All artifacts present → `isComplete()` returns true
|
||||
**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: "Multi-Change Management"
|
||||
### Slice 2: "Change Creation Utilities"
|
||||
|
||||
**Delivers:** CRUD for changes, path resolution
|
||||
**Delivers:** Utility functions for programmatic change creation
|
||||
|
||||
**Testable behaviors:**
|
||||
- `createChange('add-auth')` → creates directory + README
|
||||
- `listChanges()` → returns directory names
|
||||
- `getChangePath('add-auth')` → returns correct path
|
||||
- Missing change → clear error message
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -417,7 +447,7 @@ Output: {
|
||||
**Delivers:** Template resolution + context injection
|
||||
|
||||
**Testable behaviors:**
|
||||
- Template fallback: schema-specific → shared → error
|
||||
- Template fallback: schema-specific → shared → built-in → error
|
||||
- Context injection: completed deps show ✓, missing show ✗
|
||||
- Output path shown correctly based on change directory
|
||||
|
||||
@@ -425,10 +455,23 @@ Output: {
|
||||
|
||||
### Slice 4: "CLI + Integration"
|
||||
|
||||
**Delivers:** Full command interface
|
||||
**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 command produces expected output
|
||||
- Each new command produces expected output
|
||||
- Commands compose correctly (status → next → instructions flow)
|
||||
- Error handling for missing changes, invalid artifacts, etc.
|
||||
|
||||
@@ -437,32 +480,46 @@ Output: {
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.openspec/
|
||||
├── schemas/ # Schema definitions
|
||||
│ ├── spec-driven.yaml # Default: proposal → specs → design → tasks
|
||||
│ ├── spec-driven-v2.yaml # Version 2
|
||||
│ ├── tdd.yaml # TDD: tests → implementation → docs
|
||||
│ └── tdd/
|
||||
│ └── templates/ # TDD-specific template overrides
|
||||
│ └── tests.md
|
||||
│
|
||||
└── templates/ # Shared instruction templates
|
||||
├── proposal.md
|
||||
├── design.md
|
||||
├── specs.md
|
||||
└── tasks.md
|
||||
# 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
|
||||
│ ├── README.md # Auto-generated on creation
|
||||
│ ├── proposal.md # Created artifacts
|
||||
│ ├── design.md
|
||||
│ └── specs/
|
||||
│ └── *.md
|
||||
│
|
||||
└── refactor-db/
|
||||
└── ...
|
||||
├── refactor-db/
|
||||
│ └── ...
|
||||
└── archive/ # Completed changes
|
||||
└── 2025-01-01-add-auth/
|
||||
|
||||
.claude/
|
||||
├── settings.local.json # Permissions
|
||||
@@ -475,7 +532,8 @@ openspec/
|
||||
## Schema YAML Format
|
||||
|
||||
```yaml
|
||||
# .openspec/schemas/spec-driven.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
|
||||
@@ -484,7 +542,7 @@ artifacts:
|
||||
- id: proposal
|
||||
generates: "proposal.md"
|
||||
description: "Create project proposal document"
|
||||
template: "proposal.md" # resolves via 2-level fallback
|
||||
template: "proposal.md" # resolves from co-located templates/ directory
|
||||
requires: []
|
||||
|
||||
- id: specs
|
||||
@@ -514,17 +572,26 @@ artifacts:
|
||||
|
||||
## Summary
|
||||
|
||||
| Layer | Component | Responsibility |
|
||||
|-------|-----------|----------------|
|
||||
| Core | ArtifactGraph | Pure dependency logic (no I/O) |
|
||||
| Core | ChangeManager | Multi-change orchestration |
|
||||
| Core | InstructionLoader | State detection + enrichment |
|
||||
| Presentation | CLI | Thin command wrapper |
|
||||
| Integration | Claude Commands | AI assistant glue |
|
||||
| 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
|
||||
- **2-level template inheritance** - shared + override, no deeper
|
||||
- **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,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
|
||||
+153
@@ -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`
|
||||
+70
@@ -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`)
|
||||
+49
@@ -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
|
||||
+109
@@ -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`
|
||||
@@ -4,10 +4,10 @@
|
||||
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.
|
||||
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a valid schema YAML file is provided
|
||||
- **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
|
||||
@@ -26,6 +26,10 @@ The system SHALL load artifact graph definitions from YAML schema files.
|
||||
- **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.
|
||||
|
||||
@@ -105,3 +109,22 @@ The system SHALL identify which artifacts are blocked and return all their unmet
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"schemas",
|
||||
"scripts/postinstall.js",
|
||||
"!dist/**/*.test.js",
|
||||
"!dist/**/__tests__",
|
||||
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change -->
|
||||
|
||||
## What Changes
|
||||
|
||||
<!-- Describe what will change -->
|
||||
|
||||
## Impact
|
||||
|
||||
<!-- List affected areas -->
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <!-- requirement name -->
|
||||
<!-- requirement text -->
|
||||
|
||||
#### Scenario: <!-- scenario name -->
|
||||
- **WHEN** <!-- condition -->
|
||||
- **THEN** <!-- expected outcome -->
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
## Overview
|
||||
|
||||
<!-- Feature overview -->
|
||||
|
||||
## Getting Started
|
||||
|
||||
<!-- Quick start guide -->
|
||||
|
||||
## Examples
|
||||
|
||||
<!-- Code examples -->
|
||||
|
||||
## Reference
|
||||
|
||||
<!-- API reference or additional details -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Implementation Notes
|
||||
|
||||
<!-- Technical implementation details -->
|
||||
|
||||
## API
|
||||
|
||||
<!-- Public API documentation -->
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- Usage examples -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Feature: <!-- feature name -->
|
||||
|
||||
<!-- Feature description -->
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- List of requirements -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- List of acceptance criteria -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Test Plan
|
||||
|
||||
<!-- Describe the testing strategy -->
|
||||
|
||||
## Test Cases
|
||||
|
||||
### <!-- Test case name -->
|
||||
|
||||
- **Given:** <!-- preconditions -->
|
||||
- **When:** <!-- action -->
|
||||
- **Then:** <!-- expected result -->
|
||||
@@ -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);
|
||||
@@ -316,4 +317,7 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Register artifact workflow commands (experimental)
|
||||
registerArtifactWorkflowCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { SchemaYaml } from './types.js';
|
||||
|
||||
/**
|
||||
* Built-in schema definitions.
|
||||
* These are compiled into the package, avoiding runtime file resolution issues.
|
||||
*/
|
||||
|
||||
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
|
||||
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: 'templates/proposal.md',
|
||||
requires: [],
|
||||
},
|
||||
{
|
||||
id: 'specs',
|
||||
generates: 'specs/*.md',
|
||||
description: 'Detailed specifications for the change',
|
||||
template: 'templates/spec.md',
|
||||
requires: ['proposal'],
|
||||
},
|
||||
{
|
||||
id: 'design',
|
||||
generates: 'design.md',
|
||||
description: 'Technical design document with implementation details',
|
||||
template: 'templates/design.md',
|
||||
requires: ['proposal'],
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
generates: 'tasks.md',
|
||||
description: 'Implementation tasks derived from specs and design',
|
||||
template: 'templates/tasks.md',
|
||||
requires: ['specs', 'design'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const TDD_SCHEMA: SchemaYaml = {
|
||||
name: 'tdd',
|
||||
version: 1,
|
||||
description: 'Test-driven development workflow - tests → implementation → docs',
|
||||
artifacts: [
|
||||
{
|
||||
id: 'spec',
|
||||
generates: 'spec.md',
|
||||
description: 'Feature specification defining requirements',
|
||||
template: 'templates/spec.md',
|
||||
requires: [],
|
||||
},
|
||||
{
|
||||
id: 'tests',
|
||||
generates: 'tests/*.test.ts',
|
||||
description: 'Test files written before implementation',
|
||||
template: 'templates/test.md',
|
||||
requires: ['spec'],
|
||||
},
|
||||
{
|
||||
id: 'implementation',
|
||||
generates: 'src/*.ts',
|
||||
description: 'Implementation code to pass the tests',
|
||||
template: 'templates/implementation.md',
|
||||
requires: ['tests'],
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
generates: 'docs/*.md',
|
||||
description: 'Documentation for the implemented feature',
|
||||
template: 'templates/docs.md',
|
||||
requires: ['implementation'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Map of built-in schema names to their definitions */
|
||||
export const BUILTIN_SCHEMAS: Record<string, SchemaYaml> = {
|
||||
'spec-driven': SPEC_DRIVEN_SCHEMA,
|
||||
'tdd': TDD_SCHEMA,
|
||||
};
|
||||
@@ -18,7 +18,25 @@ export { ArtifactGraph } from './graph.js';
|
||||
export { detectCompleted } from './state.js';
|
||||
|
||||
// Schema resolution
|
||||
export { resolveSchema, listSchemas } from './resolver.js';
|
||||
export {
|
||||
resolveSchema,
|
||||
listSchemas,
|
||||
getSchemaDir,
|
||||
getPackageSchemasDir,
|
||||
getUserSchemasDir,
|
||||
SchemaLoadError,
|
||||
} from './resolver.js';
|
||||
|
||||
// Built-in schemas
|
||||
export { BUILTIN_SCHEMAS, SPEC_DRIVEN_SCHEMA, TDD_SCHEMA } from './builtin-schemas.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,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getGlobalDataDir } from '../global-config.js';
|
||||
import { BUILTIN_SCHEMAS } from './builtin-schemas.js';
|
||||
import { parseSchema, SchemaValidationError } from './schema.js';
|
||||
import type { SchemaYaml } from './types.js';
|
||||
|
||||
/**
|
||||
* Error thrown when loading a global schema override fails.
|
||||
* Error thrown when loading a schema fails.
|
||||
*/
|
||||
export class SchemaLoadError extends Error {
|
||||
constructor(
|
||||
@@ -19,12 +19,57 @@ export class SchemaLoadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Global user override: ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml
|
||||
* 2. Built-in schema
|
||||
* 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
|
||||
@@ -33,86 +78,78 @@ export class SchemaLoadError extends Error {
|
||||
export function resolveSchema(name: string): SchemaYaml {
|
||||
// Normalize name (remove .yaml extension if provided)
|
||||
const normalizedName = name.replace(/\.ya?ml$/, '');
|
||||
const builtinNames = Object.keys(BUILTIN_SCHEMAS).join(', ');
|
||||
|
||||
// 1. Check global user override (returns path if found)
|
||||
const globalPath = getGlobalSchemaPath(normalizedName);
|
||||
if (globalPath) {
|
||||
// User override found - load and validate through the same pipeline as other schemas
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(globalPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const ioError = err instanceof Error ? err : new Error(String(err));
|
||||
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(
|
||||
`Failed to read global schema override at '${globalPath}': ${ioError.message}`,
|
||||
globalPath,
|
||||
ioError
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return parseSchema(content);
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaValidationError) {
|
||||
// Re-wrap validation errors to include the file path for context
|
||||
throw new SchemaLoadError(
|
||||
`Invalid global schema override at '${globalPath}': ${err.message}`,
|
||||
globalPath,
|
||||
err
|
||||
);
|
||||
}
|
||||
// Handle unexpected parse errors (e.g., YAML syntax errors)
|
||||
const parseError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to parse global schema override at '${globalPath}': ${parseError.message}`,
|
||||
globalPath,
|
||||
parseError
|
||||
`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
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check built-in schemas
|
||||
const builtin = BUILTIN_SCHEMAS[normalizedName];
|
||||
if (builtin) {
|
||||
return builtin;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Schema '${normalizedName}' not found. Checked global overrides and built-in schemas. Available built-ins: ${builtinNames}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to a global user override schema, if it exists.
|
||||
*/
|
||||
function getGlobalSchemaPath(name: string): string | null {
|
||||
const globalDir = path.join(getGlobalDataDir(), 'schemas');
|
||||
|
||||
// Check both .yaml and .yml extensions
|
||||
for (const ext of ['.yaml', '.yml']) {
|
||||
const schemaPath = path.join(globalDir, `${name}${ext}`);
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
return schemaPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available schema names.
|
||||
* Combines built-in and user override schemas.
|
||||
* Combines user override and package built-in schemas.
|
||||
*/
|
||||
export function listSchemas(): string[] {
|
||||
const schemas = new Set<string>(Object.keys(BUILTIN_SCHEMAS));
|
||||
const schemas = new Set<string>();
|
||||
|
||||
// Add user override schemas
|
||||
const globalDir = path.join(getGlobalDataDir(), 'schemas');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
for (const file of fs.readdirSync(globalDir)) {
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
||||
schemas.add(file.replace(/\.ya?ml$/, ''));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-23
@@ -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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+3
-2
@@ -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';
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,14 @@ 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 } from '../../../src/core/artifact-graph/resolver.js';
|
||||
import { BUILTIN_SCHEMAS } from '../../../src/core/artifact-graph/builtin-schemas.js';
|
||||
import {
|
||||
resolveSchema,
|
||||
listSchemas,
|
||||
SchemaLoadError,
|
||||
getSchemaDir,
|
||||
getPackageSchemasDir,
|
||||
getUserSchemasDir,
|
||||
} from '../../../src/core/artifact-graph/resolver.js';
|
||||
|
||||
describe('artifact-graph/resolver', () => {
|
||||
let tempDir: string;
|
||||
@@ -20,6 +26,49 @@ describe('artifact-graph/resolver', () => {
|
||||
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');
|
||||
@@ -51,11 +100,11 @@ describe('artifact-graph/resolver', () => {
|
||||
expect(schema1).toEqual(schema2);
|
||||
});
|
||||
|
||||
it('should prefer global override over built-in', () => {
|
||||
it('should prefer user override over built-in', () => {
|
||||
// Set up global data dir
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
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 = `
|
||||
@@ -65,9 +114,9 @@ artifacts:
|
||||
- id: custom
|
||||
generates: custom.md
|
||||
description: Custom artifact
|
||||
template: templates/custom.md
|
||||
template: custom.md
|
||||
`;
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), customSchema);
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), customSchema);
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
@@ -75,10 +124,10 @@ artifacts:
|
||||
expect(schema.version).toBe(99);
|
||||
});
|
||||
|
||||
it('should validate global override and throw on invalid schema', () => {
|
||||
it('should validate user override and throw on invalid schema', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create an invalid schema (missing required fields)
|
||||
const invalidSchema = `
|
||||
@@ -88,16 +137,15 @@ artifacts:
|
||||
- id: broken
|
||||
# missing generates, description, template
|
||||
`;
|
||||
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
|
||||
fs.writeFileSync(schemaPath, invalidSchema);
|
||||
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 globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
@@ -105,7 +153,7 @@ version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
`;
|
||||
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
|
||||
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
|
||||
fs.writeFileSync(schemaPath, invalidSchema);
|
||||
|
||||
try {
|
||||
@@ -119,10 +167,10 @@ artifacts:
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect cycles in global override schemas', () => {
|
||||
it('should detect cycles in user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with cyclic dependencies
|
||||
const cyclicSchema = `
|
||||
@@ -132,23 +180,23 @@ artifacts:
|
||||
- id: a
|
||||
generates: a.md
|
||||
description: A
|
||||
template: templates/a.md
|
||||
template: a.md
|
||||
requires: [b]
|
||||
- id: b
|
||||
generates: b.md
|
||||
description: B
|
||||
template: templates/b.md
|
||||
template: b.md
|
||||
requires: [a]
|
||||
`;
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), cyclicSchema);
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), cyclicSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
|
||||
});
|
||||
|
||||
it('should detect invalid requires references in global override schemas', () => {
|
||||
it('should detect invalid requires references in user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with invalid requires reference
|
||||
const invalidRefSchema = `
|
||||
@@ -158,25 +206,25 @@ artifacts:
|
||||
- id: a
|
||||
generates: a.md
|
||||
description: A
|
||||
template: templates/a.md
|
||||
template: a.md
|
||||
requires: [nonexistent]
|
||||
`;
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), invalidRefSchema);
|
||||
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 globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
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(globalSchemaDir, 'spec-driven.yaml');
|
||||
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
|
||||
fs.writeFileSync(schemaPath, malformedYaml);
|
||||
|
||||
try {
|
||||
@@ -190,21 +238,21 @@ version: [[[invalid yaml
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to built-in when global not found', () => {
|
||||
it('should fall back to built-in when user override not found', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
// Don't create any global schemas
|
||||
// Don't create any user schemas
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('spec-driven');
|
||||
expect(schema).toEqual(BUILTIN_SCHEMAS['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 built-in schemas in error message', () => {
|
||||
it('should list available schemas in error message', () => {
|
||||
try {
|
||||
resolveSchema('nonexistent');
|
||||
expect.fail('Should have thrown');
|
||||
@@ -214,17 +262,6 @@ version: [[[invalid yaml
|
||||
expect(error.message).toContain('tdd');
|
||||
}
|
||||
});
|
||||
|
||||
it('should mention both global and built-in schemas were checked in not found error', () => {
|
||||
try {
|
||||
resolveSchema('nonexistent');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
expect(error.message).toContain('global overrides');
|
||||
expect(error.message).toContain('built-in');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSchemas', () => {
|
||||
@@ -235,11 +272,11 @@ version: [[[invalid yaml
|
||||
expect(schemas).toContain('tdd');
|
||||
});
|
||||
|
||||
it('should include global override schemas', () => {
|
||||
it('should include user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'custom-workflow.yaml'), 'name: custom');
|
||||
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();
|
||||
|
||||
@@ -249,10 +286,10 @@ version: [[[invalid yaml
|
||||
|
||||
it('should deduplicate schemas with same name', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
// Override spec-driven
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), 'name: custom');
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
@@ -267,5 +304,24 @@ version: [[[invalid yaml
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user