Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 5f448b0436 Merge branch 'main' into add-change-manager 2025-12-26 00:42:59 +11:00
Tabish Bidiwale 694c124c8e docs: update change-creation spec purpose 2025-12-26 00:29:12 +11:00
Tabish Bidiwale cc6c358e73 chore: archive add-change-manager change
Move change to archive and create change-creation spec with
requirements for createChange() and validateChangeName().
2025-12-26 00:14:14 +11:00
Tabish Bidiwale 334b23d022 feat: implement change creation utilities
Add createChange() and validateChangeName() functions for programmatic
change directory creation with kebab-case validation.

- createChange(projectRoot, name) creates openspec/changes/<name>/
- validateChangeName() enforces kebab-case naming conventions
- Comprehensive test coverage (21 tests)
2025-12-26 00:11:34 +11:00
Tabish Bidiwale 698c5c37a0 rename: change-manager -> change-creation capability
The capability name "change-manager" implied a manager class abstraction,
but the simplified proposal uses only utility functions. Renamed to
"change-creation" to accurately reflect what the capability provides.
2025-12-25 23:58:15 +11:00
Tabish Bidiwale f04d494ed6 docs: clarify existing vs new functionality in artifact_poc.md
Audit artifact_poc.md against existing codebase to mark what already
exists vs what's genuinely new:

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

Key finding: The document was already well-simplified. Only truly new
functionality (createChange, validateChangeName, InstructionLoader,
artifact graph CLI commands) is proposed.
2025-12-25 23:45:43 +11:00
Tabish Bidiwale f59d9a9499 docs: update artifact_poc.md for simplified Slice 2
- Rename ChangeManager to change-utils (simple utility functions)
- Remove extracted methods (listChanges, getChangePath, etc.)
- Keep only new functionality: createChange(), validateChangeName()
- Update component diagram and summary table
2025-12-25 23:35:42 +11:00
Tabish Bidiwale 1c924730dc proposal: simplify to utility functions only
Remove extraction/refactor scope. Just add:
- createChange(projectRoot, name, description?)
- validateChangeName(name)

Simple utility functions in src/utils/change-utils.ts.
No class, no abstraction layer.
2025-12-25 23:31:29 +11:00
Tabish Bidiwale 653f2c9aab proposal: add change manager - extract + new functionality
Slice 2 of the artifact tracker POC. Creates ChangeManager module that:

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

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

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

Also updates docs/artifact_poc.md to reflect XDG decisions from Slice 1.
2025-12-25 23:22:47 +11:00
9 changed files with 757 additions and 136 deletions
+197 -134
View File
@@ -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,25 +47,39 @@ 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>.yaml # Global user override
2. <package>/schemas/<name>.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 also use 2-level resolution (to be implemented in Slice 3):
```
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
2. ${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
3. <package>/templates/<artifact>.md # Built-in fallback
```
**Rules:**
- Shared templates are the default
- Schema-specific templates override OR add new
- Schema-specific templates override shared templates
- Shared templates override 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
@@ -90,83 +104,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 +191,7 @@ ChangeState {
changeName: string
changeDir: string
graph: ArtifactGraph
state: ArtifactState
completed: Set<string>
// Methods
getNextSteps(): string[]
@@ -188,28 +201,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 +270,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 +291,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 +330,29 @@ 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>.yaml # User override
↓ (not found)
.openspec/templates/proposal.md (shared)
<package>/schemas/<name>.yaml # Built-in
↓ (not found)
Error (schema not found)
```
### 4. Two-Level Template Fallback (Slice 3)
```
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
↓ (not found)
${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
↓ (not found)
<package>/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 +365,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 +408,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 +451,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 +459,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 +484,38 @@ Output: {
## Directory Structure
```
.openspec/
├── schemas/ # Schema definitions
# Global (XDG paths - user overrides)
~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/)
%LOCALAPPDATA%/openspec/ # Windows
├── schemas/ # Schema overrides
│ └── custom-workflow.yaml # User-defined schema
└── templates/ # Template overrides (Slice 3)
└── proposal.md # Custom proposal template
# Package (built-in defaults)
<package>/
├── schemas/ # Built-in 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
│ └── tdd.yaml # TDD: tests → implementation → docs
└── templates/ # Built-in templates (Slice 3)
├── proposal.md
├── design.md
├── specs.md
└── tasks.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 +528,8 @@ openspec/
## Schema YAML Format
```yaml
# .openspec/schemas/spec-driven.yaml
# Built-in: <package>/schemas/spec-driven.yaml
# Or user override: ~/.local/share/openspec/schemas/spec-driven.yaml
name: spec-driven
version: 1
description: Specification-driven development
@@ -484,7 +538,7 @@ artifacts:
- id: proposal
generates: "proposal.md"
description: "Create project proposal document"
template: "proposal.md" # resolves via 2-level fallback
template: "proposal.md" # resolves via 2-level fallback (Slice 3)
requires: []
- id: specs
@@ -514,17 +568,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
+67
View File
@@ -0,0 +1,67 @@
# change-creation Specification
## Purpose
Provide programmatic utilities for creating and validating OpenSpec change directories.
## Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
+102
View File
@@ -0,0 +1,102 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
/**
* Result of validating a change name.
*/
export interface ValidationResult {
valid: boolean;
error?: string;
}
/**
* Validates that a change name follows kebab-case conventions.
*
* Valid names:
* - Start with a lowercase letter
* - Contain only lowercase letters, numbers, and hyphens
* - Do not start or end with a hyphen
* - Do not contain consecutive hyphens
*
* @param name - The change name to validate
* @returns Validation result with `valid: true` or `valid: false` with an error message
*
* @example
* validateChangeName('add-auth') // { valid: true }
* validateChangeName('Add-Auth') // { valid: false, error: '...' }
*/
export function validateChangeName(name: string): ValidationResult {
// Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
// optionally followed by hyphen + lowercase letters/numbers (repeatable)
const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
if (!name) {
return { valid: false, error: 'Change name cannot be empty' };
}
if (!kebabCasePattern.test(name)) {
// Provide specific error messages for common mistakes
if (/[A-Z]/.test(name)) {
return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
}
if (/\s/.test(name)) {
return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };
}
if (/_/.test(name)) {
return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };
}
if (name.startsWith('-')) {
return { valid: false, error: 'Change name cannot start with a hyphen' };
}
if (name.endsWith('-')) {
return { valid: false, error: 'Change name cannot end with a hyphen' };
}
if (/--/.test(name)) {
return { valid: false, error: 'Change name cannot contain consecutive hyphens' };
}
if (/[^a-z0-9-]/.test(name)) {
return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
}
if (/^[0-9]/.test(name)) {
return { valid: false, error: 'Change name must start with a letter' };
}
return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
}
return { valid: true };
}
/**
* Creates a new change directory.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @param name - The change name (must be valid kebab-case)
* @throws Error if the change name is invalid
* @throws Error if the change directory already exists
*
* @example
* // Creates openspec/changes/add-auth/
* await createChange('/path/to/project', 'add-auth')
*/
export async function createChange(
projectRoot: string,
name: string
): Promise<void> {
// Validate the name first
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
// Build the change directory path
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
// Check if change already exists
if (await FileSystemUtils.directoryExists(changeDir)) {
throw new Error(`Change '${name}' already exists at ${changeDir}`);
}
// Create the directory (including parent directories if needed)
await FileSystemUtils.createDirectory(changeDir);
}
+3 -2
View File
@@ -1,2 +1,3 @@
// Shared utilities will be implemented here
export {};
// Shared utilities
export { validateChangeName, createChange } from './change-utils.js';
export type { ValidationResult } from './change-utils.js';
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
import { validateChangeName, createChange } from '../../src/utils/change-utils.js';
describe('validateChangeName', () => {
describe('valid names', () => {
it('should accept simple kebab-case name', () => {
const result = validateChangeName('add-auth');
expect(result).toEqual({ valid: true });
});
it('should accept name with multiple segments', () => {
const result = validateChangeName('add-user-auth');
expect(result).toEqual({ valid: true });
});
it('should accept name with numeric suffix', () => {
const result = validateChangeName('add-feature-2');
expect(result).toEqual({ valid: true });
});
it('should accept single word name', () => {
const result = validateChangeName('refactor');
expect(result).toEqual({ valid: true });
});
it('should accept name with numbers in segments', () => {
const result = validateChangeName('upgrade-to-v2');
expect(result).toEqual({ valid: true });
});
});
describe('invalid names - uppercase rejected', () => {
it('should reject name with uppercase letters', () => {
const result = validateChangeName('Add-Auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase');
});
it('should reject fully uppercase name', () => {
const result = validateChangeName('ADD-AUTH');
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase');
});
});
describe('invalid names - spaces rejected', () => {
it('should reject name with spaces', () => {
const result = validateChangeName('add auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('spaces');
});
});
describe('invalid names - underscores rejected', () => {
it('should reject name with underscores', () => {
const result = validateChangeName('add_auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('underscores');
});
});
describe('invalid names - special characters rejected', () => {
it('should reject name with exclamation mark', () => {
const result = validateChangeName('add-auth!');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject name with @ symbol', () => {
const result = validateChangeName('add@auth');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('invalid names - leading/trailing hyphens rejected', () => {
it('should reject name with leading hyphen', () => {
const result = validateChangeName('-add-auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('start with a hyphen');
});
it('should reject name with trailing hyphen', () => {
const result = validateChangeName('add-auth-');
expect(result.valid).toBe(false);
expect(result.error).toContain('end with a hyphen');
});
});
describe('invalid names - consecutive hyphens rejected', () => {
it('should reject name with double hyphens', () => {
const result = validateChangeName('add--auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('consecutive hyphens');
});
});
describe('invalid names - empty name rejected', () => {
it('should reject empty string', () => {
const result = validateChangeName('');
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
});
});
describe('createChange', () => {
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('creates directory', () => {
it('should create change directory', async () => {
await createChange(testDir, 'add-auth');
const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth');
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
});
describe('duplicate change throws error', () => {
it('should throw error if change already exists', async () => {
await createChange(testDir, 'add-auth');
await expect(createChange(testDir, 'add-auth')).rejects.toThrow(
/already exists/
);
});
});
describe('invalid name throws validation error', () => {
it('should throw error for uppercase name', async () => {
await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow(
/lowercase/
);
});
it('should throw error for name with spaces', async () => {
await expect(createChange(testDir, 'add auth')).rejects.toThrow(
/spaces/
);
});
it('should throw error for empty name', async () => {
await expect(createChange(testDir, '')).rejects.toThrow(
/empty/
);
});
});
describe('creates parent directories if needed', () => {
it('should create openspec/changes/ directories if they do not exist', async () => {
const newProjectDir = path.join(testDir, 'new-project');
await fs.mkdir(newProjectDir);
// openspec/changes/ does not exist yet
await createChange(newProjectDir, 'add-auth');
const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth');
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
});
});