Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 103838f371 fix: address PR review comments for validation improvements
- Use word boundaries in delta operation detection to avoid false matches
- Standardize name extraction to use directory name after specs/changes
- Combine archive validation warning into single prompt for better UX
- Add change.md validation to archive command before archiving
2025-08-15 23:32:51 +10:00
Tabish Bidiwale 0a26c686f9 refactor: simplify scenario parsing to store raw text
- Replace structured {given, when, then} with {rawText} field
- Remove complex Given/When/Then parsing logic
- Preserve original formatting and whitespace
- Update all tests to use rawText field
- Remove unnecessary validation checks

This change reduces complexity while preserving all content exactly
as written, making the system more maintainable and flexible.
2025-08-15 23:25:24 +10:00
Tabish Bidiwale efcf766193 docs: add validation and migration documentation to openspec
- Add VALIDATION.md with comprehensive schema and rules documentation
- Add MIGRATION.md with guide for future command integration
- Update task references to correct documentation locations
2025-08-15 23:07:00 +10:00
Tabish Bidiwale 151eddb759 fix: address minor code review issues
- Extract magic numbers to named constants in validation/constants.ts
- Update task 3.2 to reflect actual implementation (constants.ts)
- Add comprehensive documentation for validation system
- Add migration guide for future command integration
- Update CLI help text for diff command
2025-08-15 23:05:47 +10:00
Tabish Bidiwale cb0d6f3189 feat: add zod runtime validation for specs and changes
- Add Zod schemas for specs, changes, requirements, and scenarios
- Implement markdown parser for extracting structured data
- Create validation infrastructure with error/warning/info levels
- Enhance archive command with pre-archive validation
- Add --no-validate flag with confirmation prompt for emergencies
- Enhance diff command with non-blocking validation warnings
- Add JSON converters for spec and change formats
- Add comprehensive test coverage for all validation components
2025-08-15 22:50:05 +10:00
Tabish Bidiwale a897c697a5 Merge pull request #34 from Fission-AI/json-zod-implementation-plan
Add JSON output and Zod validation change proposals
2025-08-15 22:01:58 +10:00
Tabish Bidiwale 3bedf6b23e fix: move cli-change and cli-spec specs to their respective changes 2025-08-15 21:28:57 +10:00
Tabish Bidiwale 9ff0e85693 refactor: reorder implementation phases to zod -> change -> spec 2025-08-15 21:09:46 +10:00
Tabish Bidiwale 1ca407fa2f fix: rename --deltas to --requirements-only for clarity
The --deltas flag was ambiguous. Since it shows only the requirement
changes (ADDED/MODIFIED/REMOVED/RENAMED sections), rename it to
--requirements-only to be explicit about what it displays.
2025-08-15 19:14:29 +10:00
Tabish Bidiwale 46c927af06 fix: use explicit --json flag instead of ambiguous -j
Following the principle that explicit is better than implicit,
replace all occurrences of `-j` with `--json` for clarity.
2025-08-15 19:12:12 +10:00
Tabish Bidiwale 87cb206e88 feat: add JSON output and Zod validation change proposals
Add three OpenSpec change proposals for enhancing the CLI:
- add-spec-commands: Resource-based spec commands with JSON output
- add-change-commands: Resource-based change commands with JSON output
- add-zod-validation: Runtime validation with detailed error reporting

These proposals enable programmatic access to specs and changes,
improving integration with CI/CD pipelines and external tooling.
2025-08-15 17:40:06 +10:00
Tabish Bidiwale 6806a2fc5a Merge pull request #32 from Fission-AI/update-delta-conventions
feat: update conventions to support delta-based changes
2025-08-14 18:06:43 +10:00
Tabish Bidiwale 4ab65d75dd feat: update conventions to support delta-based changes
- Update openspec-conventions spec with delta-based approach
- Add Header-Based Requirement Identification for programmatic matching
- Define ADDED/MODIFIED/REMOVED/RENAMED sections format
- Document standard output symbols (+ ~ - →)
- Update openspec/README.md with delta conventions and examples
- Update init command template to use delta format
- Mark completed tasks in adopt-delta-based-changes/tasks.md

This implements the first part of the delta-based changes proposal,
updating all documentation and conventions to support the new format.
2025-08-14 18:01:09 +10:00
Tabish Bidiwale 2a3294dbfb Delete abandoned changes 2025-08-14 17:44:21 +10:00
Tabish Bidiwale 8334006f2b Merge pull request #31 from Fission-AI/adopt-delta-based-changes
feat: adopt delta-based change storage for better reviews
2025-08-14 17:33:50 +10:00
Tabish Bidiwale 8a559e0d00 fix: clarify openspec/README.md in tasks (AI instructions file) 2025-08-14 17:31:37 +10:00
Tabish Bidiwale a3924f17b2 fix: remove specific rendering examples from diff spec 2025-08-14 17:25:12 +10:00
Tabish Bidiwale b11e862b0f chore: reorganize tasks into clearer command-based groups 2025-08-14 17:20:18 +10:00
Tabish Bidiwale 099585afcb fix: remove unnecessary backward compatibility for full-state format 2025-08-14 17:16:18 +10:00
Tabish Bidiwale f023fc317e fix: simplify diff command to show only changes by default 2025-08-14 16:59:47 +10:00
Tabish Bidiwale 38a1463af0 fix: redesign diff command for requirement-level comparison
The diff command now applies deltas and shows side-by-side
requirement comparison rather than just displaying delta instructions.
2025-08-14 16:49:39 +10:00
Tabish Bidiwale f2399d3280 fix: restore implementation tasks that update actual specs
- Added back tasks to update the actual specs (not just proposals)
- Included validation implementation tasks
- Kept implementation-focused structure
- Clarified that specs in changes folder are proposals, not current truth
2025-08-14 12:51:01 +10:00
Tabish Bidiwale f699e10778 fix: remove duplication and simplify spec organization
- CLI specs now reference openspec-conventions for shared concepts
- Added standard output symbols definition to conventions
- Simplified tasks.md to focus on implementation only
- Fixed terminology to consistently use 'normalized header'
2025-08-14 12:38:27 +10:00
Tabish Bidiwale c824d8927f fix: address review feedback for consistency and clarity
- Unify header matching: normalize(header) = trim(header), case-sensitive
- Clarify RENAMED+MODIFIED: MODIFIED must use new header after rename
- Add RENAMED display to cli-diff with → symbol
- Define delta format detection via level-2 heading presence
- Remove RESTRUCTURED marker completely (unnecessary complexity)
- Standardize output symbols: + (added), ~ (modified), - (removed), → (renamed)
2025-08-14 12:14:26 +10:00
Tabish Bidiwale 5821b24ab3 fix: simplify proposal to reduce complexity
- Condense 'What Changes' section to core concepts only
- Simplify Impact section to essentials
- Make Conflict Resolution one concise paragraph
- Remove inline comment from example
- Focus on the key benefit: readable GitHub diffs
2025-08-14 00:02:30 +10:00
Tabish Bidiwale e812eb9e78 fix: remove migration timeline and deprecation notices
- Remove phased migration timeline (project not in use yet)
- Remove deprecation notices from CLI commands
- Keep simple backward compatibility for both formats
2025-08-13 23:59:52 +10:00
Tabish Bidiwale abfe13c5a7 fix: address review feedback on delta-based storage proposal
- Add whitespace normalization for header matching
- Add migration timeline with 3-phase approach over 6 months
- Clarify conflict resolution (handled by Git naturally)
- Replace 'self-contained' with 'complete content' for clarity
2025-08-13 23:57:24 +10:00
Tabish Bidiwale 0d5a75d3a0 feat: add cli-archive and cli-diff spec changes for delta-based storage 2025-08-13 23:49:36 +10:00
Tabish Bidiwale b30c0ad27e chore: remove overly detailed header-matching example 2025-08-13 23:43:14 +10:00
Tabish Bidiwale 1cada18186 feat: propose delta-based change storage for better reviews
- Replace full future state storage with delta-based approach
- Store only ADDED, MODIFIED, RENAMED, and REMOVED requirements
- Use headers as unique identifiers for programmatic matching
- Enable cleaner GitHub reviews showing only actual changes
- Add comprehensive examples and implementation tasks
2025-08-13 23:40:01 +10:00
Tabish Bidiwale fa50b07938 Merge pull request #29 from Fission-AI/fix-update-respects-tool-selection
fix: update command respects existing AI tool files
2025-08-13 23:36:50 +10:00
Tabish Bidiwale b6cad1631c feat: improve error handling and console output clarity
- Added try-catch error handling for configurator failures
- Improved console output to be more specific about what was updated
- Added TODO comment for future multi-configurator test enhancement
- Added test for error handling when configurator fails
- Console now shows 'Updated OpenSpec instructions (README.md)' for clarity
2025-08-13 23:33:23 +10:00
Tabish Bidiwale 2497e81e4d Merge pull request #28 from Fission-AI/add-skip-specs-archive-option
feat: add --skip-specs flag to archive command
2025-08-13 23:31:15 +10:00
Tabish Bidiwale d8cba03840 docs: enhance --skip-specs help text and add implementation notes 2025-08-13 23:27:41 +10:00
Tabish Bidiwale 0b1be19302 fix: update command respects existing AI tool files
The update command now only updates existing AI tool configuration
files instead of forcing CLAUDE.md creation. This allows team members
to use different AI tools without conflicts.

- Modified update.ts to check for existing files before updating
- Added comprehensive tests for the new behavior
- Updated spec and documentation to reflect team-friendly approach
- Created project README documenting the behavior
2025-08-13 23:25:53 +10:00
Tabish Bidiwale d7ebee4555 feat: add --skip-specs flag to archive command and fix confirmation behavior
- Add --skip-specs flag to archive command to skip spec update operations
- Fix confirmation behavior: declining spec updates now continues with archiving instead of cancelling
- Add comprehensive tests for new functionality
- Update task documentation to reflect completed implementation
2025-08-13 23:21:57 +10:00
Tabish Bidiwale 8f45a6f6ee Merge pull request #27 from Fission-AI/fix/update-respects-tool-selection
Fix: Update command respects AI tool selection
2025-08-13 23:11:36 +10:00
Tabish Bidiwale 6da77f01ce Merge pull request #26 from Fission-AI/feat/skip-spec-update-archive-proposal
feat: add skip-specs option for archive command
2025-08-13 23:10:45 +10:00
Tabish Bidiwale d90eccf959 fix: update command respects AI tool selection
The update command now only updates existing AI tool configuration
files instead of forcing CLAUDE.md creation. This allows team members
to use different AI tools without conflicts.
2025-08-13 23:06:05 +10:00
Tabish Bidiwale f192a97aeb feat: add proposal for skip-specs option in archive command
Add change proposal to enable skipping spec updates during archive operation.
This allows archiving changes that don't modify specs (tooling, docs, etc.)
and fixes the confirmation behavior to continue archiving even when users
decline spec updates.
2025-08-13 23:00:16 +10:00
Tabish Bidiwale 7781bbadd3 Merge pull request #25 from Fission-AI/feat/apply-structured-spec-format
feat: apply structured spec format to all specifications
2025-08-13 22:31:43 +10:00
Tabish Bidiwale aeaa1d50cc fix: remove Format Flexibility requirement from conventions
Remove the Format Flexibility section as it's not needed for the structured spec format. Keep focus on behavioral specifications only.
2025-08-13 22:29:10 +10:00
Tabish Bidiwale fa5df9a329 feat: apply structured spec format to all specifications
- Add Specification Format section to openspec-conventions with:
  - Requirement headers for consistent structure
  - Scenario headers with bold WHEN/THEN/AND keywords
  - Format flexibility for different content types

- Update all CLI command specs to use structured format:
  - cli-init: Convert all behavioral sections
  - cli-list: Apply structured format throughout
  - cli-update: Restructure requirements and edge cases
  - cli-diff: Update all behavior sections
  - cli-archive: Convert complex behaviors to scenarios

- Update openspec-conventions spec itself to follow its own format
- Mark all tasks as completed
2025-08-13 22:26:03 +10:00
Tabish Bidiwale f94f396c99 Merge pull request #24 from Fission-AI/cleanup/remove-add-requirement-markers
chore: remove abandoned add-requirement-markers change
2025-08-13 22:10:24 +10:00
Tabish Bidiwale 1f670f71d4 chore: remove abandoned add-requirement-markers change 2025-08-13 22:02:51 +10:00
Tabish Bidiwale 32b2901d13 Merge pull request #23 from Fission-AI/feat/structured-spec-format
feat(openspec): add structured format specification
2025-08-13 21:52:05 +10:00
Tabish Bidiwale 2ad0b1d306 feat: add tasks to update existing specs to new format
Add section 3 with tasks to update all existing CLI command specs to use the new structured format in their Behavior sections
2025-08-13 21:49:26 +10:00
Tabish Bidiwale 279d327899 fix: remove Format Flexibility task for non-behavioral specs
Non-behavioral specs not planned for now, keeping focus on behavioral specifications only
2025-08-13 21:48:36 +10:00
Tabish Bidiwale 5d848cf005 fix: remove unnecessary migration documentation
- Remove migration.md as project has no existing users to migrate
- Remove Migration Support section from tasks.md
- Structured format only applies to behavioral specs, not convention definitions
2025-08-13 21:36:52 +10:00
Tabish Bidiwale 5607fd3ccb fix: remove migration section from spec, keep only in change proposal 2025-08-13 21:21:28 +10:00
Tabish Bidiwale 6a0d862258 refactor: enhance openspec-conventions with structured format
- Merge format rules into openspec-conventions instead of separate spec
- Add Format Flexibility requirement for non-behavioral content
- Address review feedback on gradual migration and alternative formats
2025-08-13 21:17:46 +10:00
Tabish Bidiwale 1fe5f84fbc feat(openspec): add structured format specification for consistency 2025-08-13 21:05:36 +10:00
Tabish Bidiwale 80e78ecd1e chore: archive add-archive-command change after deployment 2025-08-13 18:49:17 +10:00
Tabish Bidiwale 564135a530 archive diff command 2025-08-13 18:47:31 +10:00
Tabish Bidiwale b9e80641a0 Merge pull request #21 from Fission-AI/feat/implement-archive-command
feat: implement archive command for OpenSpec changes
2025-08-13 18:41:54 +10:00
Tabish Bidiwale 0755994eaa refactor: use @inquirer/prompts for consistent UX in archive command
- Replace readline with @inquirer/prompts for all user interactions
- Add arrow key navigation for change selection (consistent with diff command)
- Use confirm() for yes/no prompts with better UX
- Remove manual readline interface management (no longer needed)
- Update tests to mock @inquirer/prompts instead of readline
- Add new test cases for interactive mode behavior

This change provides a consistent user experience across all OpenSpec commands,
where users can navigate options with arrow keys rather than typing numbers.
2025-08-13 18:37:18 +10:00
Tabish Bidiwale dcabd6de31 fix: address PR review feedback for archive command
- Add try-finally block to ensure readline interface always closes
- Extract date formatting to dedicated getArchiveDate() method
- Add comprehensive unit tests for ArchiveCommand covering:
  - Successful archiving flow
  - Incomplete tasks warning
  - Spec updates during archiving
  - Edge cases (missing tasks.md, no specs)
  - Error scenarios (missing change, duplicate archive)
  - No OpenSpec directory error
2025-08-13 18:27:24 +10:00
Tabish Bidiwale aef6ce01ff feat: implement archive command for OpenSpec changes
Adds a new `openspec archive` command that moves completed changes to an archive
directory with date-based naming. The command includes:
- Interactive change selection when no name provided
- Incomplete task warnings before archiving
- Automatic spec updates to main specs directory
- Confirmation prompts (skippable with --yes flag)
- Duplicate archive prevention

Also fixes TypeScript compilation errors in diff.ts and init.ts.
2025-08-13 18:19:26 +10:00
Tabish Bidiwale 441f9f444b Merge pull request #20 from Fission-AI/fix-archive-spec-updates
Fix: Add spec update functionality to archive command
2025-08-13 18:03:09 +10:00
Tabish Bidiwale b322829091 fix: add spec update functionality to archive command
The archive command was missing critical functionality to update main specs
from the change's future state specs when archiving. This fix adds:
- Spec update process that copies future state specs to main specs directory
- Confirmation prompt showing which specs will be created vs updated
- --yes flag for automation scenarios to skip confirmations
- Safety by default with clear visibility into spec changes
2025-08-13 18:00:00 +10:00
Tabish Bidiwale 5167e65a5c chore: archive add-list-command change after deployment 2025-08-13 17:36:12 +10:00
Tabish Bidiwale 5c6b4113a7 Merge pull request #19 from Fission-AI/feat/add-archive-command
feat: add archive command for completed changes
2025-08-13 17:25:34 +10:00
Tabish Bidiwale a8b76c3e69 Merge pull request #18 from Fission-AI/feat/implement-list-command
feat: add list command to show active changes with task status
2025-08-13 17:23:21 +10:00
Tabish Bidiwale d3237cac7b feat: add OpenSpec change proposal for archive command 2025-08-13 17:23:17 +10:00
Tabish Bidiwale e395eb4eeb feat: add list command to show active changes with task status 2025-08-13 17:17:40 +10:00
Tabish Bidiwale 76e1ec2a1f Merge pull request #12 from Fission-AI/feat/add-diff-command
feat: add diff command to view spec changes
2025-08-13 17:00:48 +10:00
Tabish Bidiwale 27eaccc024 Merge pull request #16 from Fission-AI/add-requirement-markers
feat: add @requirement markers convention
2025-08-13 15:28:56 +10:00
Tabish Bidiwale b288f2fc88 fix: update spec to contain complete future state per OpenSpec conventions 2025-08-13 15:21:14 +10:00
Tabish Bidiwale 22134a603b feat: add @requirement markers convention for requirement identification
- Define @requirement marker syntax for identifying requirements in specs
- Each marker includes a kebab-case identifier before WHEN/THEN blocks
- Document convention in openspec-conventions spec
- Enables reliable extraction without brittle regex parsing
2025-08-13 14:50:24 +10:00
Tabish Bidiwale 3b5fd11cb9 Merge pull request #13 from Fission-AI/feat/add-list-command
feat(openspec): add list command to display active changes
2025-08-12 16:57:43 +10:00
Tabish Bidiwale e9417fc147 Merge pull request #11 from Fission-AI/TabishB/abandon-status-command-change
chore: abandon add-status-command change
2025-08-12 01:03:39 +10:00
Tabish Bidiwale 8bcf2c6905 feat(openspec): add list command change proposal 2025-08-12 00:59:45 +10:00
Tabish Bidiwale 9a03ba1853 chore: abandon add-status-command change 2025-08-12 00:59:28 +10:00
75 changed files with 5681 additions and 235 deletions
+87
View File
@@ -0,0 +1,87 @@
# OpenSpec
A specification-driven development system for maintaining living documentation alongside your code.
## Installation
```bash
npm install -g openspec
```
## Quick Start
```bash
# Initialize OpenSpec in your project
openspec init
# Update existing OpenSpec instructions (team-friendly)
openspec update
# List all specifications
openspec list
# Show differences between specs and proposed changes
openspec diff [change-name]
# Archive completed changes
openspec archive [change-name]
```
## Commands
### `openspec init`
Initializes OpenSpec in your project by creating:
- `openspec/` directory structure
- `openspec/README.md` with OpenSpec instructions
- AI tool configuration files (based on your selection)
### `openspec update`
Updates OpenSpec instructions to the latest version. This command is **team-friendly** and only updates files that already exist:
- Always updates `openspec/README.md` with the latest OpenSpec instructions
- **Only updates existing AI tool configuration files** (e.g., CLAUDE.md, CURSOR.md)
- **Never creates new AI tool configuration files**
- Preserves content outside of OpenSpec markers in AI tool files
This allows team members to use different AI tools without conflicts. Each developer can maintain their preferred AI tool configuration file, and `openspec update` will respect their choice.
### `openspec list`
Lists all specifications and pending changes in your project:
- Shows current specifications in `openspec/specs/`
- Shows pending changes in `openspec/changes/`
- Shows archived changes in `openspec/changes/archive/`
### `openspec diff [change-name]`
Shows the differences between current specs and proposed changes:
- Displays a unified diff format
- Helps review what will change before implementation
- Useful for pull request reviews
### `openspec archive [change-name]`
Archives a completed change:
- Moves change from `openspec/changes/` to `openspec/changes/archive/`
- Adds a date prefix to the archived change
- Updates specs to reflect the new state
- Use `--skip-specs` to archive without updating specs (for abandoned changes)
## Team Collaboration
OpenSpec is designed for team collaboration:
1. **AI Tool Flexibility**: Each team member can use their preferred AI assistant (Claude, Cursor, etc.)
2. **Non-Invasive Updates**: The `update` command only modifies existing files, never forcing tools on team members
3. **Specification Sharing**: The `openspec/` directory contains shared specifications that all team members work from
4. **Change Tracking**: Proposed changes are visible to all team members for review before implementation
## Contributing
See `openspec/specs/` for the current system specifications and `openspec/changes/` for pending improvements.
## License
MIT
+263
View File
@@ -0,0 +1,263 @@
# Migration Guide: Integrating Validation into Future Commands
## Overview
This guide explains how to integrate the OpenSpec validation framework into new commands and features.
## Adding Validation to New Commands
### 1. Import Required Components
```typescript
import { Validator } from '../core/validation/validator.js';
import { ValidationReport } from '../core/validation/types.js';
import chalk from 'chalk';
```
### 2. Basic Validation Pattern
```typescript
export class NewCommand {
async execute(options: CommandOptions): Promise<void> {
// Create validator instance
const validator = new Validator(options.strict);
// Validate the spec or change
const report = await validator.validateSpec(filePath);
// or
const report = await validator.validateChange(filePath);
// Handle validation results
if (!report.valid && !options.skipValidation) {
this.displayValidationErrors(report);
throw new Error('Validation failed');
}
// Continue with command logic...
}
private displayValidationErrors(report: ValidationReport): void {
console.log(chalk.red('\nValidation errors:'));
for (const issue of report.issues) {
if (issue.level === 'ERROR') {
console.log(chalk.red(` ✗ ${issue.message}`));
} else if (issue.level === 'WARNING') {
console.log(chalk.yellow(` ⚠ ${issue.message}`));
} else {
console.log(chalk.blue(` ℹ ${issue.message}`));
}
}
}
}
```
### 3. Adding Skip Validation Option
```typescript
// In CLI definition
.option('--no-validate', 'Skip validation (not recommended)')
// In command execution
if (!options.noValidate) {
// Perform validation
const report = await validator.validateSpec(filePath);
if (!report.valid) {
// Handle validation failure
}
} else {
// Log warning about skipping validation
const timestamp = new Date().toISOString();
console.log(chalk.yellow(`⚠️ WARNING: Skipping validation`));
console.log(chalk.yellow(`[${timestamp}] Validation skipped for: ${filePath}`));
// Optionally require confirmation
if (!options.yes) {
const proceed = await confirm({
message: 'Continue without validation? (y/N)',
default: false
});
if (!proceed) {
console.log('Operation cancelled.');
return;
}
}
}
```
### 4. Non-Blocking Validation (Warnings Only)
```typescript
// For commands that should show warnings but continue
const report = await validator.validateSpec(filePath);
if (report.issues.length > 0) {
const warnings = report.issues.filter(i => i.level === 'WARNING');
const errors = report.issues.filter(i => i.level === 'ERROR');
if (errors.length > 0) {
// Block on errors
this.displayValidationErrors(report);
throw new Error('Validation failed with errors');
} else if (warnings.length > 0) {
// Show warnings but continue
console.log(chalk.yellow('\n⚠️ Validation warnings:'));
this.displayValidationErrors(report);
console.log(chalk.yellow('\nConsider fixing these issues.\n'));
}
}
// Continue with command...
```
## Extending Validation Rules
### 1. Add New Constants
```typescript
// In src/core/validation/constants.ts
export const NEW_THRESHOLD = 100;
export const VALIDATION_MESSAGES = {
// ... existing messages
NEW_RULE_MESSAGE: 'New validation rule message',
};
```
### 2. Update Schema with New Rules
```typescript
// In appropriate schema file
import { NEW_THRESHOLD, VALIDATION_MESSAGES } from '../validation/constants.js';
export const ExtendedSchema = z.object({
// ... existing fields
newField: z.string()
.min(NEW_THRESHOLD, VALIDATION_MESSAGES.NEW_RULE_MESSAGE)
});
```
### 3. Add Custom Validation Logic
```typescript
// In src/core/validation/validator.ts
private applyCustomRules(data: CustomType): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// Add custom validation logic
if (data.someField.length > NEW_THRESHOLD) {
issues.push({
level: 'WARNING',
path: 'someField',
message: VALIDATION_MESSAGES.NEW_RULE_MESSAGE,
});
}
return issues;
}
```
## Testing Validation
### 1. Unit Test Schema Validation
```typescript
import { describe, it, expect } from 'vitest';
import { NewSchema } from '../../src/core/schemas/new.schema.js';
describe('NewSchema', () => {
it('should validate valid data', () => {
const data = { /* valid data */ };
const result = NewSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should reject invalid data', () => {
const data = { /* invalid data */ };
const result = NewSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Expected error message');
}
});
});
```
### 2. Integration Test Command Validation
```typescript
it('should validate before executing', async () => {
const command = new NewCommand();
// Create invalid data
await fs.writeFile(testPath, invalidContent);
// Execute should fail due to validation
await expect(command.execute({ filePath: testPath }))
.rejects.toThrow('Validation failed');
});
it('should skip validation with flag', async () => {
const command = new NewCommand();
const mockConfirm = vi.fn().mockResolvedValue(true);
// Execute with skip validation
await command.execute({
filePath: testPath,
noValidate: true,
yes: true
});
// Should succeed despite invalid data
expect(mockConfirm).not.toHaveBeenCalled();
});
```
## Integration Checklist
When adding validation to a new command:
- [ ] Import Validator and related types
- [ ] Add validation before critical operations
- [ ] Handle validation errors appropriately
- [ ] Add --no-validate flag if skipping should be allowed
- [ ] Require confirmation for validation skips
- [ ] Log validation skips with timestamp
- [ ] Add tests for validation scenarios
- [ ] Update command documentation
- [ ] Document any new validation rules
## Common Patterns
### Pre-Operation Validation
Use for destructive operations (archive, delete, modify):
```typescript
if (!options.noValidate) {
const report = await validator.validateSpec(filePath);
if (!report.valid) {
throw new Error('Cannot proceed with invalid spec');
}
}
```
### Informational Validation
Use for read-only operations (diff, list, show):
```typescript
const report = await validator.validateSpec(filePath);
if (report.issues.length > 0) {
console.log(chalk.yellow('Note: This spec has validation issues'));
}
// Continue regardless
```
### Progressive Enhancement
Start with warnings, gradually enforce:
```typescript
const report = await validator.validateSpec(filePath);
const hasErrors = report.issues.some(i => i.level === 'ERROR');
if (hasErrors && process.env.STRICT_VALIDATION) {
throw new Error('Strict validation enabled, errors found');
} else if (hasErrors) {
console.log(chalk.yellow('Validation errors found (will be enforced in future)'));
}
```
+60 -15
View File
@@ -43,9 +43,9 @@ openspec/
│ │ ├── proposal.md # Why, what, impact (consolidated)
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional, for complex changes)
│ │ └── specs/ # Future state of affected specs
│ │ └── specs/ # Delta changes to specs
│ │ └── [capability]/
│ │ └── spec.md # Clean markdown (no diff syntax)
│ │ └── spec.md # Delta format (ADDED/MODIFIED/REMOVED/RENAMED)
│ └── archive/ # Completed changes (dated)
```
@@ -94,7 +94,35 @@ Before any task:
- If adding dependencies, frameworks, or architectural patterns, document why simpler alternatives won't work
- Default to single-file implementations until proven insufficient
### 3. Creating a Change Proposal
### 3. Delta-Based Change Format
Changes use a delta format with clear sections:
```markdown
## ADDED Requirements
### Requirement: New Feature
[Complete requirement content in structured format]
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement (header must match current spec)]
## REMOVED Requirements
### Requirement: Old Feature
**Reason for removal**: [Why removing]
**Migration path**: [How to handle existing usage]
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
Key rules:
- Headers are matched using `normalize(header) = trim(header)`
- Include complete requirements (not diffs)
- Use standard symbols in CLI output: + (added), ~ (modified), - (removed), → (renamed)
### 4. Creating a Change Proposal
When a user requests a significant change:
@@ -113,13 +141,21 @@ openspec/changes/[descriptive-name]/
- Affected specs: [list capabilities that will change]
- Affected code: [list key files/systems]
# 3. Create future state specs for ALL affected capabilities
# - Store complete spec files as they will exist after the change
# - Use clean markdown without diff syntax (+/- prefixes)
# - Include all formatting and structure of the final intended state
# 3. Create delta specs for ALL affected capabilities
# - Store only the changes (not complete future state)
# - Use sections: ## ADDED, ## MODIFIED, ## REMOVED, ## RENAMED
# - Include complete requirements in their final form
# Example spec.md content:
# ## ADDED Requirements
# ### Requirement: Password Reset
# Users SHALL be able to reset passwords via email...
#
# ## MODIFIED Requirements
# ### Requirement: User Authentication
# [Complete modified requirement with new password reset hook]
specs/
└── [capability]/
└── spec.md
└── spec.md # Contains delta sections
# 4. Create tasks.md with implementation steps
## 1. [Task Group]
@@ -130,16 +166,16 @@ specs/
[Technical decisions and trade-offs]
```
### 4. The Change Lifecycle
### 5. The Change Lifecycle
1. **Propose** → Create change directory with all documentation
1. **Propose** → Create change directory with delta-based documentation
2. **Review** → User reviews and approves the proposal
3. **Implement** → Follow the approved tasks.md (can be multiple PRs)
4. **Deploy** → User confirms deployment
5. **Update Specs** → Sync specs/ with new reality (IF the change affects system capabilities)
5. **Update Specs** → Apply deltas to sync specs/ with new reality (IF the change affects system capabilities)
6. **Archive** → Move to `changes/archive/YYYY-MM-DD-[name]/`
### 5. Implementing Changes
### 6. Implementing Changes
When implementing an approved change:
1. Follow the tasks.md checklist exactly
@@ -154,7 +190,7 @@ When implementing an approved change:
- Different developers can work on different task groups
- Example: PR #1 completes tasks 1.1-1.3, PR #2 completes tasks 2.1-2.4
### 6. Updating Specs and Archiving After Deployment
### 7. Updating Specs and Archiving After Deployment
**Create a separate PR after deployment** that:
1. Moves change to `changes/archive/YYYY-MM-DD-[name]/`
@@ -163,7 +199,7 @@ When implementing an approved change:
This ensures changes are only archived when truly complete and deployed.
### 7. Types of Changes That Don't Require Specs
### 8. Types of Changes That Don't Require Specs
Some changes only affect development infrastructure and don't need specs:
- Initial project setup (package.json, tsconfig.json, etc.)
@@ -216,7 +252,16 @@ User: "Add password reset functionality"
You should:
1. Read specs/user-auth/spec.md
2. Check changes/ for pending auth changes
3. Create changes/add-password-reset/ with proposal
3. Create changes/add-password-reset/ with:
- proposal.md describing the change
- specs/user-auth/spec.md with:
## ADDED Requirements
### Requirement: Password Reset
[Complete requirement for password reset]
## MODIFIED Requirements
### Requirement: User Authentication
[Updated to integrate with password reset]
4. Wait for approval before implementing
```
+157
View File
@@ -0,0 +1,157 @@
# OpenSpec Validation Guide
## Overview
OpenSpec uses Zod for runtime validation of specifications and changes. The validation system ensures that all specs and changes conform to the required structure and quality standards.
## Schema Structure
### Spec Schema
```typescript
{
name: string, // Spec identifier
overview: string, // Description of the spec
requirements: [ // Array of requirements
{
text: string, // Requirement text (must contain SHALL/MUST)
scenarios: [ // Array of test scenarios
{
given: string, // Initial condition
when: string, // Action taken
then: string // Expected result
}
]
}
],
metadata?: { // Optional metadata
version: string,
format: 'openspec',
sourcePath?: string
}
}
```
### Change Schema
```typescript
{
name: string, // Change identifier
why: string, // Justification (50-1000 chars)
whatChanges: string, // Description of changes
deltas: [ // Array of spec changes
{
spec: string, // Affected spec name
operation: 'ADDED' | 'MODIFIED' | 'REMOVED',
description: string, // Delta description
requirements?: [] // New/modified requirements
}
],
metadata?: { // Optional metadata
version: string,
format: 'openspec-change',
sourcePath?: string
}
}
```
## Validation Rules
### Three-Tier Validation System
#### ERROR Level (Blocking)
- Missing required sections (Overview, Requirements, Why, What Changes)
- Invalid heading hierarchy
- Malformed requirement/scenario structure
- Empty required fields
#### WARNING Level (Should Fix)
- Requirements without scenarios
- Requirements missing SHALL/MUST keywords
- Brief overview sections (<50 characters)
- Brief why sections (<50 characters)
- Missing requirements in ADDED/MODIFIED deltas
- Delta descriptions too brief (<10 characters)
#### INFO Level (Suggestions)
- Very long requirement text (>500 characters)
- Very long why sections (>1000 characters)
- Scenarios without Given/When/Then structure
- Too many deltas in single change (>10)
## Validation Thresholds
| Constant | Value | Description |
|----------|-------|-------------|
| MIN_WHY_SECTION_LENGTH | 50 | Minimum characters for change why section |
| MIN_OVERVIEW_LENGTH | 50 | Minimum characters for spec overview |
| MAX_WHY_SECTION_LENGTH | 1000 | Maximum characters for change why section |
| MAX_REQUIREMENT_TEXT_LENGTH | 500 | Maximum characters per requirement |
| MAX_DELTAS_PER_CHANGE | 10 | Maximum deltas in a single change |
## Using Validation
### Archive Command
```bash
# Validation runs by default
openspec archive my-change
# Skip validation (not recommended, requires confirmation)
openspec archive my-change --no-validate
```
### Diff Command
```bash
# Shows validation warnings (non-blocking)
openspec diff my-change
```
### Programmatic Usage
```typescript
import { Validator } from './src/core/validation/validator.js';
const validator = new Validator();
const report = await validator.validateSpec('path/to/spec.md');
if (!report.valid) {
console.log('Validation failed:');
report.issues.forEach(issue => {
console.log(`[${issue.level}] ${issue.path}: ${issue.message}`);
});
}
```
### Strict Mode
```typescript
// Fail on both errors and warnings
const validator = new Validator(true);
const report = await validator.validateSpec('path/to/spec.md');
```
## Validation Report Format
```json
{
"valid": false,
"issues": [
{
"level": "ERROR",
"path": "requirements[0].scenarios",
"message": "Requirement must have at least one scenario",
"line": 15,
"column": 0
}
],
"summary": {
"errors": 1,
"warnings": 2,
"info": 1
}
}
```
## Best Practices
1. **Always validate before archiving** - The archive command validates by default to protect archive integrity
2. **Fix errors immediately** - Errors indicate structural issues that prevent proper parsing
3. **Address warnings before merging** - Warnings indicate quality issues that should be fixed
4. **Consider info messages** - Info messages provide suggestions for improvement
5. **Use strict mode in CI/CD** - Enforce both errors and warnings in automated pipelines
+68
View File
@@ -0,0 +1,68 @@
# Implementation Order and Dependencies
## Required Implementation Sequence
The following changes must be implemented in this specific order due to dependencies:
### Phase 1: Foundation
**1. add-zod-validation** (No dependencies)
- Creates all core schemas (RequirementSchema, ScenarioSchema, SpecSchema, ChangeSchema, DeltaSchema)
- Implements markdown parser utilities
- Implements validation infrastructure and rules
- Establishes validation patterns used by all commands
- Must be completed first
### Phase 2: Change Commands
**2. add-change-commands** (Depends on: add-zod-validation)
- Imports ChangeSchema and DeltaSchema from zod validation
- Reuses markdown parsing utilities
- Implements change command with built-in validation
- Uses validation infrastructure for change validate subcommand
- Cannot start until schemas and validation exist
### Phase 3: Spec Commands
**3. add-spec-commands** (Depends on: add-zod-validation, add-change-commands)
- Imports RequirementSchema, ScenarioSchema, SpecSchema from zod validation
- Reuses markdown parsing utilities
- Implements spec command with built-in validation
- Uses validation infrastructure for spec validate subcommand
- Builds on patterns established by change commands
## Dependency Graph
```
add-zod-validation
↓
add-change-commands
↓
add-spec-commands
```
## Key Dependencies
### Shared Code Dependencies
1. **Schemas**: All schemas created in add-zod-validation, used by both command implementations
2. **Validation**: Infrastructure created in add-zod-validation, integrated into both commands
3. **Parsers**: Markdown parsing utilities created in add-zod-validation, used by both commands
### File Dependencies
- `src/core/schemas/*.schema.ts` (created by add-zod-validation) → imported by both commands
- `src/core/validation/validator.ts` (created by add-zod-validation) → used by both commands
- `src/core/parsers/markdown-parser.ts` (created by add-zod-validation) → used by both commands
## Implementation Notes
### For Developers
1. Complete each phase fully before moving to the next
2. Run tests after each phase to ensure stability
3. The legacy `list` command remains functional throughout
### For CI/CD
1. Each change can be validated independently
2. Integration tests should run after each phase
3. Full system tests required after Phase 3
### Parallel Work Opportunities
Within each phase, the following can be done in parallel:
- **Phase 1**: Schema design, validation rules, and parser implementation
- **Phase 2**: Change command features and legacy compatibility work
- **Phase 3**: Spec command features and final integration
@@ -0,0 +1,56 @@
# Design: Change Commands
## Architecture Decisions
### Command Structure
Similar to spec commands, we use subcommands (`change show`, `change list`, `change validate`) for:
- Consistency with spec command pattern
- Clear separation of concerns
- Future extensibility for change management features
### JSON Schema for Changes
```typescript
{
version: string, // Schema version
format: "change", // Identifies as change document
sourcePath: string, // Original markdown file path
id: string, // Change identifier
title: string, // Change title
why: string, // Motivation section
whatChanges: Array<{
type: "ADDED" | "MODIFIED" | "REMOVED" | "RENAMED",
deltas: Array<{
specId: string,
description: string,
requirements?: Array<Requirement> // Only for ADDED/MODIFIED
}>
}>
}
```
**Rationale:**
- Group deltas by operation type for clearer organization
- Optional requirements field (only relevant for ADDED/MODIFIED)
- Reuse RequirementSchema from spec commands for consistency
### Delta Operations
**Four operation types:**
1. **ADDED**: New requirements added to specs
2. **MODIFIED**: Changes to existing requirements
3. **REMOVED**: Requirements being deleted
4. **RENAMED**: Spec identifier changes
**Design choice:** Explicit operation types rather than diff-based approach for:
- Human readability in markdown
- Clear intent communication
- Easier validation and tooling
### Dependency on Spec Commands
- **Shared schemas**: RequirementSchema and ScenarioSchema reused
- **Implementation order**: spec commands must be implemented first
- **Common parser utilities**: Share markdown parsing logic
### Legacy Compatibility
- Keep existing `list` command functional with deprecation warning
- Migration path: `list` → `change list` with same functionality
- Gradual transition to avoid breaking existing workflows
@@ -0,0 +1,20 @@
# Change: Add Change Commands with JSON Output
## Why
OpenSpec change proposals currently can only be viewed as markdown files, creating the same programmatic access limitations as specs. Additionally, the current `openspec list` command only lists changes, which is inconsistent with the new resource-based command structure.
## What Changes
- Add new `openspec change` command with three subcommands: `show`, `list`, and `validate`
- Implement JSON output capability for change proposals
- Add Zod schemas for change structure validation
- Maintain backward compatibility with existing `openspec list` command
- Enable filtering options specific to changes (requirements-only view)
## Impact
- **Affected specs**: cli-list (modify to add deprecation notice)
- **Affected code**:
- src/cli/index.ts (register new command)
- src/core/list.ts (add deprecation notice)
@@ -0,0 +1,48 @@
## ADDED Requirements
### Requirement: Change Command
The system SHALL provide a `change` command with subcommands for displaying, listing, and validating change proposals.
#### Scenario: Show change as JSON
- **WHEN** executing `openspec change show update-error --json`
- **THEN** parse the markdown change file
- **AND** extract change structure and deltas
- **AND** output valid JSON to stdout
#### Scenario: List all changes
- **WHEN** executing `openspec change list`
- **THEN** scan the openspec/changes directory
- **AND** return list of all pending changes
- **AND** support JSON output with `--json` flag
#### Scenario: Show only requirement changes
- **WHEN** executing `openspec change show update-error --requirements-only`
- **THEN** display only the requirement changes (ADDED/MODIFIED/REMOVED/RENAMED)
- **AND** exclude why and what changes sections
#### Scenario: Validate change structure
- **WHEN** executing `openspec change validate update-error`
- **THEN** parse the change file
- **AND** validate against Zod schema
- **AND** ensure deltas are well-formed
### Requirement: Legacy Compatibility
The system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices.
#### Scenario: Legacy list command
- **WHEN** executing `openspec list`
- **THEN** display current list of changes (existing behavior)
- **AND** show deprecation notice: "Note: 'openspec list' is deprecated. Use 'openspec change list' instead."
#### Scenario: Legacy list with --all flag
- **WHEN** executing `openspec list --all`
- **THEN** display all changes (existing behavior)
- **AND** show same deprecation notice
@@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: List Command Behavior
The current `list` command behavior SHALL be preserved but marked as deprecated.
#### Scenario: Deprecation notice
- **WHEN** using the legacy `list` command
- **THEN** continue to work as before
- **AND** display deprecation notice
- **AND** suggest using `openspec change list` instead
@@ -0,0 +1,34 @@
# Implementation Tasks (Phase 2: Builds on add-zod-validation)
## 1. Command Implementation
- [ ] 1.1 Create src/commands/change.ts
- [ ] 1.2 Import ChangeSchema and DeltaSchema from src/core/schemas/change.schema.ts
- [ ] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [ ] 1.4 Import ChangeValidator from src/core/validation/validator.ts
- [ ] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [ ] 1.6 Implement show subcommand with JSON output using existing converter
- [ ] 1.7 Implement list subcommand
- [ ] 1.8 Implement validate subcommand using existing ChangeValidator
- [ ] 1.9 Add --requirements-only filtering option
- [ ] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [ ] 1.11 Add --json flag for validation reports
## 2. Change-Specific Parser Extensions
- [ ] 2.1 Create src/core/parsers/change-parser.ts (extends base markdown parser)
- [ ] 2.2 Parse proposal structure (Why, What Changes sections)
- [ ] 2.3 Extract ADDED/MODIFIED/REMOVED/RENAMED sections
- [ ] 2.4 Parse delta operations within each section
- [ ] 2.5 Add tests for change parser
## 3. Legacy Compatibility
- [ ] 3.1 Update src/core/list.ts to add deprecation notice
- [ ] 3.2 Ensure existing list command continues to work
- [ ] 3.3 Add console warning for deprecated command usage
## 4. Integration
- [ ] 4.1 Register change command in src/cli/index.ts
- [ ] 4.2 Add integration tests for all subcommands
- [ ] 4.3 Test JSON output for changes
- [ ] 4.4 Test legacy compatibility
- [ ] 4.5 Test validation with strict mode
- [ ] 4.6 Update CLI help documentation (add 'change' command to main help, document subcommands: show, list, validate)
@@ -0,0 +1,13 @@
## Why
The archive command currently forces users to either accept spec updates or cancel the entire archive operation. Users need flexibility to archive changes without updating specs, either through explicit flags or by declining the confirmation prompt. This is especially important for changes that don't modify specs (like tooling, documentation, or infrastructure updates).
## What Changes
- Add new `--skip-specs` flag to the archive command that bypasses all spec update operations
- Fix confirmation behavior: when users decline spec updates interactively, proceed with archiving instead of cancelling the entire operation
- When `--skip-specs` flag is used, skip both the spec discovery and update confirmation steps entirely
- Display clear message when specs are skipped (either via flag or user choice)
- Flag can be combined with existing `--yes` flag for fully automated archiving without spec updates
## Impact
- Affected specs: cli-archive
- Affected code: src/core/archive.ts, src/cli/index.ts
@@ -0,0 +1,167 @@
# CLI Archive Command Specification
## Purpose
The archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.
## Command Syntax
```bash
openspec archive [change-name] [--yes|-y] [--skip-specs]
```
Options:
- `--yes`, `-y`: Skip confirmation prompts (for automation)
- `--skip-specs`: Skip spec update operations entirely (for changes without spec modifications)
## Behavior
### Requirement: Change Selection
The command SHALL support both interactive and direct change selection methods.
#### Scenario: Interactive selection
- **WHEN** no change-name is provided
- **THEN** display interactive list of available changes (excluding archive/)
- **AND** allow user to select one
#### Scenario: Direct selection
- **WHEN** change-name is provided
- **THEN** use that change directly
- **AND** validate it exists
### Requirement: Task Completion Check
The command SHALL verify task completion status before archiving to prevent premature archival.
#### Scenario: Incomplete tasks found
- **WHEN** incomplete tasks are found (marked with `- [ ]`)
- **THEN** display all incomplete tasks to the user
- **AND** prompt for confirmation to continue
- **AND** default to "No" for safety
#### Scenario: All tasks complete
- **WHEN** all tasks are complete OR no tasks.md exists
- **THEN** proceed with archiving without prompting
### Requirement: Archive Process
The archive operation SHALL follow a structured process to safely move changes to the archive.
#### Scenario: Performing archive
- **WHEN** archiving a change
- **THEN** execute these steps:
1. Create archive/ directory if it doesn't exist
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date
3. Check if target directory already exists
4. Update main specs from the change's future state specs unless `--skip-specs` is provided (see Spec Update Process below)
5. Move the entire change directory to the archive location
#### Scenario: Archive already exists
- **WHEN** target archive already exists
- **THEN** fail with error message
- **AND** do not overwrite existing archive
#### Scenario: Successful archive
- **WHEN** move succeeds
- **THEN** display success message with archived name and list of updated specs (if any)
### Requirement: Spec Update Process
Before moving the change to archive, the command SHALL update main specs to reflect the deployed reality unless the `--skip-specs` flag is provided.
#### Scenario: Skipping spec updates
- **WHEN** the `--skip-specs` flag is provided
- **THEN** skip all spec discovery and update operations
- **AND** proceed directly to moving the change to archive
- **AND** display message indicating specs were skipped
#### Scenario: Updating specs from change
- **WHEN** the change contains specs in `changes/[name]/specs/` AND `--skip-specs` is NOT provided
- **THEN** execute these steps:
1. Analyze which specs will be affected by comparing with existing specs
2. Display a summary of spec updates to the user (see Confirmation Behavior below)
3. Prompt for confirmation unless `--yes` flag is provided
4. If confirmed, for each capability spec in the change directory:
- Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`
- Create the target directory structure if it doesn't exist
- Overwrite existing spec files (specs represent current reality, change specs are the new reality)
- Track which specs were updated for the success message
#### Scenario: No specs in change
- **WHEN** no specs exist in the change AND `--skip-specs` is NOT provided
- **THEN** skip the spec update step
- **AND** proceed with archiving
### Requirement: Confirmation Behavior
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
#### Scenario: Displaying confirmation
- **WHEN** prompting for confirmation AND `--skip-specs` is NOT provided
- **THEN** display a clear summary showing:
- Which specs will be created (new capabilities)
- Which specs will be updated (existing capabilities)
- The source path for each spec
- **AND** format the confirmation prompt as:
```
The following specs will be updated:
NEW specs to be created:
- cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)
EXISTING specs to be updated:
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
Update 2 specs and archive 'add-archive-command'? [y/N]:
```
#### Scenario: Handling confirmation response
- **WHEN** waiting for user confirmation
- **THEN** default to "No" for safety (require explicit "y" or "yes")
- **AND** skip confirmation when `--yes` or `-y` flag is provided
- **AND** skip entire spec confirmation when `--skip-specs` flag is provided
#### Scenario: User declines spec update confirmation
- **WHEN** user declines the spec update confirmation
- **THEN** skip the spec update operations
- **AND** display message: "Skipping spec updates. Proceeding with archive."
- **AND** continue with the archive operation
- **AND** display success message indicating specs were not updated
## Error Handling
### Requirement: Error Conditions
The command SHALL handle various error conditions gracefully.
#### Scenario: Handling errors
- **WHEN** errors occur
- **THEN** handle the following conditions:
- Missing openspec/changes/ directory
- Change not found
- Archive target already exists
- File system permissions issues
## Why These Decisions
**Interactive selection**: Reduces typing and helps users see available changes
**Task checking**: Prevents accidental archiving of incomplete work
**Date prefixing**: Maintains chronological order and prevents naming conflicts
**No overwrite**: Preserves historical archives and prevents data loss
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**Non-blocking confirmation**: Declining spec updates doesn't cancel archiving - users can review specs and choose to update them separately if needed
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
**--skip-specs flag**: Enables archiving of changes that don't modify specs (like infrastructure, tooling, or documentation changes) without unnecessary spec update prompts or operations
@@ -0,0 +1,57 @@
## 1. Update Archive Command Implementation
- [x] 1.1 Add `skipSpecs` option to the archive command options interface
- [x] 1.2 Modify the execute method to skip spec operations when flag is set
- [x] 1.3 Fix confirmation behavior: when user declines spec updates, proceed with archiving instead of cancelling
- [x] 1.4 Update console output to indicate when specs are being skipped (via flag or user choice)
- [x] 1.5 Ensure archive continues after declining spec updates
## 2. Update CLI Interface
- [x] 2.1 Add `--skip-specs` flag to the archive command definition
- [x] 2.2 Pass the flag value to the archive command execute method
## 3. Update Tests
- [x] 3.1 Add test case for archiving with --skip-specs flag
- [x] 3.2 Add test case for declining spec updates but continuing with archive
- [x] 3.3 Verify that spec updates are skipped when flag is used
- [x] 3.4 Verify that archive proceeds when user declines spec updates
- [x] 3.5 Ensure existing behavior remains unchanged when flag is not used
## 4. Update Documentation
- [x] 4.1 Update the cli-archive spec to document the new --skip-specs flag
- [x] 4.2 Document the new behavior when declining spec updates interactively
## Implementation Notes
### Key Design Decisions
1. **Non-blocking Confirmation Behavior**: When users decline spec updates interactively, the archive operation continues rather than cancelling entirely. This was a critical UX improvement because:
- Users may want to review specs separately before updating them
- Archiving work shouldn't be blocked by spec review decisions
- Maintains flexibility in the deployment workflow
2. **Flag Naming Convention**: Chose `--skip-specs` for clarity and consistency:
- Clearly indicates the action (skipping) and target (specs)
- Follows kebab-case convention for CLI flags
- Converts naturally to `skipSpecs` camelCase in code
3. **Console Messaging Strategy**: Added explicit messages for all spec-skipping scenarios:
- When flag is used: "Skipping spec updates (--skip-specs flag provided)."
- When user declines: "Skipping spec updates. Proceeding with archive."
- Ensures users always understand what's happening with their specs
4. **Test Coverage Approach**: Created separate test cases for:
- Flag-based skipping (explicit user choice via CLI)
- Interactive declining (runtime user decision)
- Both verify the same outcome but test different code paths
### Use Cases Addressed
- **Infrastructure Changes**: Changes to build tools, CI/CD, dependencies
- **Documentation Updates**: README updates, comment improvements
- **Tooling Modifications**: Developer tools, scripts, configuration files
- **Refactoring**: Code improvements that don't change functionality/specs
### Future Considerations
- Could potentially auto-detect when changes don't include specs and suggest using the flag
- May want to track which archives skipped spec updates for audit purposes
@@ -0,0 +1,45 @@
# Design: Spec Commands
## Architecture Decisions
### Command Hierarchy
We chose a subcommand pattern (`spec show`, `spec list`, `spec validate`) to:
- Group related functionality under a common namespace
- Enable future extensibility without polluting the top-level CLI
- Maintain consistency with the planned `change` command structure
### JSON Schema Structure
The spec JSON schema follows this structure:
```typescript
{
version: string, // Schema version for compatibility
format: "spec", // Identifies this as a spec document
sourcePath: string, // Original markdown file path
id: string, // Spec identifier from filename
title: string, // Human-readable title
overview?: string, // Optional overview section
requirements: Array<{
id: string,
text: string,
scenarios: Array<{
id: string,
text: string
}>
}>
}
```
**Rationale:**
- Flat structure for requirements array (vs nested objects) for easier iteration
- Scenarios nested within requirements to maintain relationship
- Metadata fields (version, format, sourcePath) for tooling integration
### Parser Architecture
- **Markdown-first approach**: Parse markdown headings rather than custom syntax
- **Streaming parser**: Process line-by-line to handle large files efficiently
- **Strict heading hierarchy**: Enforce ##/###/#### structure for consistency
### Validation Strategy
- **Parse-time validation**: Catch structural issues during parsing
- **Schema validation**: Use Zod for runtime type checking of parsed data
- **Separate validation command**: Allow validation without full parsing/conversion
@@ -0,0 +1,19 @@
# Change: Add Spec Commands with JSON Output
## Why
Currently, OpenSpec specs can only be viewed as markdown files. This makes programmatic access difficult and prevents integration with CI/CD pipelines, external tools, and automated processing.
## What Changes
- Add new `openspec spec` command with three subcommands: `show`, `list`, and `validate`
- Implement JSON output capability for specs using heading-based parsing
- Add Zod schemas for spec structure validation
- Enable content filtering options (requirements only, no scenarios, specific requirement)
## Impact
- **Affected specs**: None (new capability)
- **Affected code**:
- src/cli/index.ts (register new command)
- package.json (add zod dependency)
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Spec Command
The system SHALL provide a `spec` command with subcommands for displaying, listing, and validating specifications.
#### Scenario: Show spec as JSON
- **WHEN** executing `openspec spec show init --json`
- **THEN** parse the markdown spec file
- **AND** extract headings and content hierarchically
- **AND** output valid JSON to stdout
#### Scenario: List all specs
- **WHEN** executing `openspec spec list`
- **THEN** scan the openspec/specs directory
- **AND** return list of all available capabilities
- **AND** support JSON output with `--json` flag
#### Scenario: Filter spec content
- **WHEN** executing `openspec spec show init --requirements`
- **THEN** display only requirement names and SHALL statements
- **AND** exclude scenario content
#### Scenario: Validate spec structure
- **WHEN** executing `openspec spec validate init`
- **THEN** parse the spec file
- **AND** validate against Zod schema
- **AND** report any structural issues
### Requirement: JSON Schema Definition
The system SHALL define Zod schemas that accurately represent the spec structure for runtime validation.
#### Scenario: Schema validation
- **WHEN** parsing a spec into JSON
- **THEN** validate the structure using Zod schemas
- **AND** ensure all required fields are present
- **AND** provide clear error messages for validation failures
@@ -0,0 +1,22 @@
# Implementation Tasks (Phase 3: Builds on add-zod-validation and add-change-commands)
## 1. Command Implementation
- [ ] 1.1 Create src/commands/spec.ts
- [ ] 1.2 Import RequirementSchema, ScenarioSchema, SpecSchema from src/core/schemas/
- [ ] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [ ] 1.4 Import SpecValidator from src/core/validation/validator.ts
- [ ] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [ ] 1.6 Implement show subcommand with JSON output using existing converter
- [ ] 1.7 Implement list subcommand
- [ ] 1.8 Implement validate subcommand using existing SpecValidator
- [ ] 1.9 Add filtering options (--requirements, --no-scenarios, -r)
- [ ] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [ ] 1.11 Add --json flag for validation reports
## 2. Integration
- [ ] 2.1 Register spec command in src/cli/index.ts
- [ ] 2.2 Add integration tests for all subcommands
- [ ] 2.3 Test JSON output validation
- [ ] 2.4 Test filtering options
- [ ] 2.5 Test validation with strict mode
- [ ] 2.6 Update CLI help documentation (add 'spec' command to main help, document subcommands: show, list, validate)
@@ -1,19 +0,0 @@
# Add Status Command to OpenSpec CLI
## Why
Developers need to know which changes have all tasks completed and are ready to archive.
## What Changes
- Add `openspec status` command that scans the changes/ directory
- Parse each tasks.md file to count `[x]` (complete) and `[ ]` (incomplete) tasks
- Display each change with its completion status (e.g., "auth-feature: 5/5" or "auth-feature: ✓")
- Skip the archive/ subdirectory
## Impact
- Affected specs: New capability `cli-status` will be added
- Affected code:
- `src/cli/index.ts` - Add status command
- `src/core/status.ts` - New file with simple scanning and parsing logic (~50 lines)
@@ -1,58 +0,0 @@
# CLI Status Command Specification
## Purpose
The status command shows which OpenSpec changes are ready to archive by displaying task completion status for each change.
## Command Interface
```bash
# Show status of all changes
openspec status
```
## Behavior
WHEN the status command runs:
1. Scan the `openspec/changes/` directory
2. Skip the `archive/` subdirectory
3. For each change directory with a `tasks.md` file:
- Count tasks marked with `[x]` (case-insensitive)
- Count tasks marked with `[ ]`
- Display the change name and completion status
## Output Format
```
add-auth-feature: 15/15
fix-payment-bug: 8/8
refactor-api: 3/10
update-docs: 0/5
```
Or with checkmark for fully complete:
```
add-auth-feature: ✓
fix-payment-bug: ✓
refactor-api: 3/10
update-docs: 0/5
```
## Task Detection
The command recognizes these patterns as tasks:
- `- [ ]` Incomplete task
- `- [x]` Complete task (lowercase)
- `- [X]` Complete task (uppercase)
## Error Handling
- If no `tasks.md` exists, skip that change
- If `tasks.md` is empty or has no tasks, skip that change
- Continue scanning even if individual files have errors
## Exit Codes
- `0`: Success - status displayed
- `1`: Error - unable to scan changes directory
@@ -1,8 +0,0 @@
# Implementation Tasks for Status Command
## Core Implementation
- [ ] Add status command to `src/cli/index.ts`
- [ ] Create `src/core/status.ts` with directory scanning logic
- [ ] Parse tasks.md files to count `[x]` and `[ ]` patterns
- [ ] Display each change with completion status (name: complete/total)
- [ ] Skip the archive/ subdirectory when scanning
@@ -0,0 +1,104 @@
# Design: Zod Validation Framework
## Architecture Decisions
### Validation Levels
Three-tier validation system:
1. **ERROR**: Structural issues that prevent parsing (must fix)
2. **WARNING**: Quality issues that should be addressed (recommended fix)
3. **INFO**: Suggestions for improvement (optional)
**Rationale:**
- Gradual enforcement allows teams to adopt validation incrementally
- CI/CD can fail on errors but allow warnings initially
- Info level provides guidance without blocking
### Validation Rules Hierarchy
#### Spec Validation Rules
```
ERROR level:
- Missing ## Overview or ## Requirements sections
- Invalid heading hierarchy
- Malformed requirement/scenario structure
WARNING level:
- Requirements without scenarios
- Requirements missing SHALL keyword
- Empty overview section
INFO level:
- Very long requirement text (>500 chars)
- Scenarios without Given/When/Then structure
```
#### Change Validation Rules
```
ERROR level:
- Missing ## Why or ## What Changes sections
- Invalid delta operation types
- Malformed delta structure
WARNING level:
- Why section too brief (<50 chars)
- Deltas without clear descriptions
- Missing requirements in ADDED/MODIFIED
INFO level:
- Very long why section (>1000 chars)
- Too many deltas in single change (>10)
```
### Strict Mode
- **Default**: Show all levels, fail on ERROR only
- **--strict flag**: Fail on both ERROR and WARNING
- **Use case**: Gradual quality improvement in CI/CD pipelines
### Archive Command Safety
**Problem:** Invalid specs could be archived, polluting the archive.
**Solution:**
1. Pre-archive validation (default behavior)
2. --no-validate flag with safeguards:
- Interactive confirmation prompt
- Prominent warning message
- Console logging with timestamp
- Not recommended for CI/CD usage
**Rationale:**
- Protect archive integrity by default
- Allow emergency overrides with accountability
- Clear audit trail for validation bypasses
### Validation Report Format
```json
{
"valid": boolean,
"issues": [
{
"level": "ERROR" | "WARNING" | "INFO",
"path": "requirements[0].scenarios",
"message": "Requirement must have at least one scenario",
"line": 15,
"column": 0
}
],
"summary": {
"errors": 2,
"warnings": 5,
"info": 3
}
}
```
**Benefits:**
- Machine-readable for tooling integration
- Human-friendly messages
- Line/column info for IDE integration
- Summary for quick assessment
### Implementation Strategy
1. **Zod schemas with refinements**: Built-in validation in type definitions
2. **Custom validators**: Additional business logic validation
3. **Composable rules**: Mix and match for different contexts
4. **Extensible framework**: Easy to add new rules without refactoring
@@ -0,0 +1,22 @@
# Change: Add Zod Runtime Validation
## Why
While the spec and change commands can output JSON, they currently don't perform strict runtime validation beyond basic structure checking. This can lead to invalid specs or changes being processed, silent failures when required fields are missing, and poor error messages.
## What Changes
- Enhance existing `spec validate` and `change validate` commands with strict Zod validation
- Add validation to the archive command to ensure changes are valid before applying
- Add validation to the diff command to ensure changes are well-formed
- Provide detailed validation reports in JSON format
- Add `--strict` mode that fails on warnings
## Impact
- **Affected specs**: cli-spec, cli-change, cli-archive, cli-diff
- **Affected code**:
- src/commands/spec.ts (enhance validate subcommand)
- src/commands/change.ts (enhance validate subcommand)
- src/core/archive.ts (add pre-archive validation)
- src/core/diff.ts (add validation check)
@@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: Archive Validation
The archive command SHALL validate changes before applying them to ensure data integrity.
#### Scenario: Pre-archive validation
- **WHEN** executing `openspec archive change-name`
- **THEN** validate the change structure first
- **AND** only proceed if validation passes
- **AND** show validation errors if it fails
#### Scenario: Force archive without validation
- **WHEN** executing `openspec archive change-name --no-validate`
- **THEN** skip validation (unsafe mode)
- **AND** show warning about skipping validation
@@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Diff Command Enhancement
The diff command SHALL validate change structure before displaying differences.
#### Scenario: Validate before diff
- **WHEN** executing `openspec diff change-name`
- **THEN** validate change structure
- **AND** show validation warnings if present
- **AND** continue with diff display
@@ -0,0 +1,59 @@
# Implementation Tasks (Foundation Phase)
## 1. Core Schemas
- [x] 1.1 Add zod dependency to package.json
- [x] 1.2 Create src/core/schemas/base.schema.ts with ScenarioSchema and RequirementSchema
- [x] 1.3 Create src/core/schemas/spec.schema.ts with SpecSchema
- [x] 1.4 Create src/core/schemas/change.schema.ts with DeltaSchema and ChangeSchema
- [x] 1.5 Create src/core/schemas/index.ts to export all schemas
## 2. Parser Implementation
- [x] 2.1 Create src/core/parsers/markdown-parser.ts
- [x] 2.2 Implement heading extraction (##, ###, ####)
- [x] 2.3 Implement content capture between headings
- [x] 2.4 Add tests for parser edge cases
## 3. Validation Infrastructure
- [x] 3.1 Create src/core/validation/types.ts with ValidationLevel, ValidationIssue, ValidationReport types
- [x] 3.2 Create src/core/validation/constants.ts with validation rules and thresholds
- [x] 3.3 Create src/core/validation/validator.ts with SpecValidator and ChangeValidator classes
## 4. Enhanced Validation Rules
- [x] 4.1 Add RequirementValidation refinements (must have scenarios, must contain SHALL)
- [x] 4.2 Add SpecValidation refinements (must have requirements)
- [x] 4.3 Add ChangeValidation refinements (must have deltas, why section length)
- [x] 4.4 Implement custom error messages for each rule
## 5. JSON Converter
- [x] 5.1 Create src/core/converters/json-converter.ts
- [x] 5.2 Implement spec-to-JSON conversion
- [x] 5.3 Implement change-to-JSON conversion
- [x] 5.4 Add metadata fields (version, format, sourcePath)
## 6. Archive Command Enhancement
- [x] 6.1 Add pre-archive validation check using new validators
- [x] 6.2 Add --no-validate flag with required confirmation prompt and warning message: "⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)"
- [x] 6.3 Display validation errors before aborting
- [x] 6.4 Log all --no-validate usages to console with timestamp and affected files
- [x] 6.5 Add tests for validation scenarios including --no-validate confirmation flow
## 7. Diff Command Enhancement
- [x] 7.1 Add validation check before diff using new validators
- [x] 7.2 Show validation warnings (non-blocking)
- [x] 7.3 Continue with diff even if warnings present
## 8. Testing
- [x] 8.1 Unit tests for all schemas
- [x] 8.2 Unit tests for parser
- [x] 8.3 Unit tests for validation rules
- [x] 8.4 Integration tests for validation reports
- [x] 8.5 Test various invalid spec/change formats
- [x] 8.6 Test strict mode behavior
- [x] 8.7 Test pre-archive validation
- [x] 8.8 Test validation report JSON output
## 9. Documentation
- [x] 9.1 Document schema structure and validation rules (openspec/VALIDATION.md)
- [x] 9.2 Update CLI help for archive (document --no-validate flag and its warnings)
- [x] 9.3 Update CLI help for diff (document validation warnings behavior)
- [x] 9.4 Create migration guide for future command integration (openspec/MIGRATION.md)
@@ -0,0 +1,66 @@
# Adopt Delta-Based Changes for Specifications
## Why
The current approach of storing complete future states in change proposals creates a poor review experience. When reviewing changes on GitHub, reviewers see entire spec files (often 100+ lines) as "added" in green, making it impossible to identify what actually changed. With the recent structured format adoption, we now have clear section boundaries that enable a better approach: storing only additions and modifications.
## What Changes
Store only the requirements that actually change, not complete future states:
- **ADDED Requirements**: New capabilities being introduced
- **MODIFIED Requirements**: Existing requirements being changed (must match current header)
- **REMOVED Requirements**: Deprecated capabilities
- **RENAMED Requirements**: Explicit header changes (e.g., `FROM: Old Name` → `TO: New Name`)
The archive command will programmatically apply these deltas using normalized header matching (trim leading/trailing whitespace) instead of manually copying entire files.
## Impact
**Affected specs**: openspec-conventions, cli-archive, cli-diff
**Benefits**:
- GitHub diffs show only actual changes (25 lines instead of 150+)
- Reviewers immediately see what's being added, modified, or removed
- Conflicts are more apparent when two changes modify the same requirement
- Archive command can programmatically apply changes
**Format**: Delta format only - all changes must use ADDED/MODIFIED/REMOVED sections.
## Example
Instead of storing a 150-line complete future spec, store only:
```markdown
# User Authentication - Changes
## ADDED Requirements
### Requirement: OAuth Support
Users SHALL authenticate via OAuth providers including Google and GitHub.
#### Scenario: OAuth login flow
- **WHEN** user selects OAuth provider
- **THEN** redirect to provider authorization
- **AND** exchange authorization code for tokens
## MODIFIED Requirements
### Requirement: Session Management
Sessions SHALL expire after 30 minutes of inactivity.
#### Scenario: Inactive session timeout
- **WHEN** no activity for 30 minutes ← (was 60 minutes)
- **THEN** invalidate session token
- **AND** require re-authentication
## RENAMED Requirements
- FROM: `### Requirement: Basic Authentication`
- TO: `### Requirement: Email Authentication`
```
This makes reviews focused and changes explicit.
## Conflict Resolution
Git naturally detects conflicts when two changes modify the same requirement header. This is actually better than full-state storage where Git might silently merge incompatible changes.
@@ -0,0 +1,46 @@
# CLI Archive Command - Changes
## MODIFIED Requirements
### Requirement: Spec Update Process
Before moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality.
#### Scenario: Applying delta changes
- **WHEN** archiving a change with delta-based specs
- **THEN** parse and apply delta changes as defined in openspec-conventions
- **AND** validate all operations before applying
#### Scenario: Validating delta changes
- **WHEN** processing delta changes
- **THEN** perform validations as specified in openspec-conventions
- **AND** if validation fails, show specific errors and abort
#### Scenario: Conflict detection
- **WHEN** applying deltas would create duplicate requirement headers
- **THEN** abort with error message showing the conflict
- **AND** suggest manual resolution
### Requirement: Display Output
The command SHALL provide clear feedback about delta operations.
#### Scenario: Showing delta application
- **WHEN** applying delta changes
- **THEN** display for each spec:
- Number of requirements added
- Number of requirements modified
- Number of requirements removed
- Number of requirements renamed
- **AND** use standard output symbols (+ ~ - →) as defined in openspec-conventions:
```
Applying changes to specs/user-auth/spec.md:
+ 2 added
~ 3 modified
- 1 removed
→ 1 renamed
```
@@ -0,0 +1,35 @@
# CLI Diff Command - Changes
## REMOVED Requirements
### Requirement: Display Format
**Reason for removal**: The standard unified diff format is replaced by requirement-level side-by-side comparison that better shows semantic changes rather than line-by-line text differences.
## MODIFIED Requirements
### Requirement: Diff Output
The command SHALL show a requirement-level comparison displaying only changed requirements.
#### Scenario: Side-by-side comparison of changes
- **WHEN** running `openspec diff <change>`
- **THEN** display only requirements that have changed
- **AND** show them in a side-by-side format that:
- Clearly shows the current version on the left
- Shows the future version on the right
- Indicates new requirements (not in current)
- Indicates removed requirements (not in future)
- Aligns modified requirements for easy comparison
### Requirement: Validation
The command SHALL validate that changes can be applied successfully.
#### Scenario: Invalid delta references
- **WHEN** delta references non-existent requirement
- **THEN** show error message with specific requirement
- **AND** continue showing other valid changes
- **AND** clearly mark failed changes in the output
@@ -0,0 +1,109 @@
# OpenSpec Conventions - Changes
## ADDED Requirements
### Requirement: Header-Based Requirement Identification
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
#### Scenario: Matching requirements programmatically
- **WHEN** processing delta changes
- **THEN** use the `### Requirement: [Name]` header as the unique identifier
- **AND** match using normalized headers: `normalize(header) = trim(header)`
- **AND** compare headers with case-sensitive equality after normalization
#### Scenario: Handling requirement renames
- **WHEN** renaming a requirement
- **THEN** use a special `## RENAMED Requirements` section
- **AND** specify both old and new names explicitly:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
- **AND** if content also changes, include under MODIFIED using the NEW header
#### Scenario: Validating header uniqueness
- **WHEN** creating or modifying requirements
- **THEN** ensure no duplicate headers exist within a spec
- **AND** validation tools SHALL flag duplicate headers as errors
## MODIFIED Requirements
### Requirement: Change Storage Convention
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
#### Scenario: Creating change proposals with additions
- **WHEN** creating a change proposal that adds new requirements
- **THEN** include only the new requirements under `## ADDED Requirements`
- **AND** each requirement SHALL include its complete content
- **AND** use the standard structured format for requirements and scenarios
#### Scenario: Creating change proposals with modifications
- **WHEN** creating a change proposal that modifies existing requirements
- **THEN** include the modified requirements under `## MODIFIED Requirements`
- **AND** use the same header text as in the current spec (normalized)
- **AND** include the complete modified requirement (not a diff)
- **AND** optionally annotate what changed with inline comments like `← (was X)`
#### Scenario: Creating change proposals with removals
- **WHEN** creating a change proposal that removes requirements
- **THEN** list them under `## REMOVED Requirements`
- **AND** use the normalized header text for identification
- **AND** include reason for removal
- **AND** document any migration path if applicable
The `changes/[name]/specs/` directory SHALL contain:
- Delta files showing only what changes
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
- Normalized header matching for requirement identification
- Complete requirements using the structured format
- Clear indication of change type for each requirement
#### Scenario: Using standard output symbols
- **WHEN** displaying delta operations in CLI output
- **THEN** use these standard symbols:
- `+` for ADDED (green)
- `~` for MODIFIED (yellow)
- `-` for REMOVED (red)
- `→` for RENAMED (cyan)
### Requirement: Archive Process Enhancement
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
#### Scenario: Archiving changes with deltas
- **WHEN** archiving a completed change
- **THEN** the archive command SHALL:
1. Parse RENAMED sections first and apply renames
2. Parse REMOVED sections and remove by normalized header match
3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)
4. Parse ADDED sections and append new requirements
- **AND** validate that all MODIFIED/REMOVED headers exist in current spec
- **AND** validate that ADDED headers don't already exist
- **AND** generate the updated spec in the main specs/ directory
#### Scenario: Handling conflicts during archive
- **WHEN** delta changes conflict with current spec state
- **THEN** the archive command SHALL report specific conflicts
- **AND** require manual resolution before proceeding
- **AND** provide clear guidance on resolving conflicts
## REMOVED Requirements
### Requirement: Future State Storage
**Reason for removal**: Replaced by delta-based change storage which provides better review experience and clearer change tracking.
**Migration path**: All new changes must use delta format.
@@ -0,0 +1,39 @@
# Implementation Tasks
## 1. Update Conventions
- [x] 1.1 Update openspec-conventions spec with delta-based approach
- [x] 1.2 Add Header-Based Requirement Identification
- [x] 1.3 Define ADDED/MODIFIED/REMOVED/RENAMED sections
- [x] 1.4 Document standard output symbols (+ ~ - →)
- [x] 1.5 Update openspec/README.md with delta-based conventions
- [x] 1.6 Update examples to use delta format
## 2. Update Diff Command
- [ ] 2.1 Update cli-diff spec with requirement-level comparison
- [ ] 2.2 Parse specs into requirement-level structures
- [ ] 2.3 Apply deltas to generate future state
- [ ] 2.4 Implement side-by-side comparison view (changes only)
- [ ] 2.5 Add tests for requirement-level comparison
- [ ] 2.6 Add tests for side-by-side view formatting
## 3. Update Archive Command
- [ ] 3.1 Update cli-archive spec with delta processing behavior
- [ ] 3.2 Implement normalized header matching (trim whitespace)
- [ ] 3.3 Parse delta sections (ADDED/MODIFIED/REMOVED/RENAMED)
- [ ] 3.4 Apply changes in order: RENAMED → REMOVED → MODIFIED → ADDED
- [ ] 3.5 Validate delta operations:
- [ ] 3.5.1 MODIFIED/REMOVED requirements exist
- [ ] 3.5.2 ADDED requirements don't already exist
- [ ] 3.5.3 RENAMED FROM headers exist, TO headers don't
- [ ] 3.5.4 No duplicate headers within specs
- [ ] 3.5.5 Renamed requirements aren't also in ADDED
- [ ] 3.6 Display operation counts (+ 2 added, ~ 3 modified, etc.)
- [ ] 3.7 Add tests for header normalization
- [ ] 3.8 Add tests for applying deltas in correct order
- [ ] 3.9 Add tests for validation edge cases
## Notes
- Archive command is critical path - must work reliably
- All new changes must use delta format
- Header normalization: normalize(header) = trim(header)
- Diff command shows only changed requirements in side-by-side comparison
@@ -0,0 +1,20 @@
# Add List Command to OpenSpec CLI
## Why
Developers need visibility into available changes and their status to understand the project's evolution and pending work.
## What Changes
- Add `openspec list` command that displays all changes in the changes/ directory
- Show each change name with task completion count (e.g., "add-auth: 3/5 tasks")
- Display completion status indicator (✓ for fully complete, progress for partial)
- Skip the archive/ subdirectory to focus on active changes
- Simple table output for easy scanning
## Impact
- Affected specs: New capability `cli-list` will be added
- Affected code:
- `src/cli/index.ts` - Add list command
- `src/core/list.ts` - New file with directory scanning and task parsing (~60 lines)
@@ -0,0 +1,69 @@
# List Command Specification
## Purpose
The `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status.
## Behavior
### Command Execution
WHEN `openspec list` is executed
THEN scan the `openspec/changes/` directory for change directories
AND exclude the `archive/` subdirectory from results
AND parse each change's `tasks.md` file to count task completion
### Task Counting
WHEN parsing a `tasks.md` file
THEN count tasks matching these patterns:
- Completed: Lines containing `- [x]`
- Incomplete: Lines containing `- [ ]`
AND calculate total tasks as the sum of completed and incomplete
### Output Format
WHEN displaying the list
THEN show a table with columns:
- Change name (directory name)
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
- Status indicator:
- `✓` for fully completed changes (all tasks done)
- Progress fraction for partial completion
Example output:
```
Changes:
add-auth-feature 3/5 tasks
update-api-docs ✓ Complete
fix-validation 0/2 tasks
add-list-command 1/4 tasks
```
### Empty State
WHEN no active changes exist (only archive/ or empty changes/)
THEN display: "No active changes found."
### Error Handling
IF a change directory has no `tasks.md` file
THEN display the change with "No tasks" status
IF `openspec/changes/` directory doesn't exist
THEN display error: "No OpenSpec changes directory found. Run 'openspec init' first."
AND exit with code 1
### Sorting
Changes SHALL be displayed in alphabetical order by change name for consistency.
## Why
Developers need a quick way to:
- See what changes are in progress
- Identify which changes are ready to archive
- Understand the overall project evolution status
- Get a bird's-eye view without opening multiple files
This command provides that visibility with minimal effort, following OpenSpec's philosophy of simplicity and clarity.
@@ -0,0 +1,26 @@
# Implementation Tasks
## 1. Core Implementation
- [x] 1.1 Create `src/core/list.ts` with list logic
- [x] 1.1.1 Implement directory scanning (exclude archive/)
- [x] 1.1.2 Implement task counting from tasks.md files
- [x] 1.1.3 Format output as simple table
- [x] 1.2 Add list command to CLI in `src/cli/index.ts`
- [x] 1.2.1 Register `openspec list` command
- [x] 1.2.2 Connect to list.ts implementation
## 2. Error Handling
- [x] 2.1 Handle missing openspec/changes/ directory
- [x] 2.2 Handle changes without tasks.md files
- [x] 2.3 Handle empty changes directory
## 3. Testing
- [x] 3.1 Add tests for list functionality
- [x] 3.1.1 Test with multiple changes
- [x] 3.1.2 Test with completed changes
- [x] 3.1.3 Test with no changes
- [x] 3.1.4 Test error conditions
## 4. Documentation
- [x] 4.1 Update CLI help text with list command
- [x] 4.2 Add list command to README if applicable
@@ -0,0 +1,15 @@
## Why
Need a command to archive completed changes to the archive folder with proper date prefixing, following OpenSpec conventions. Currently changes must be manually moved and renamed.
## What Changes
- Add new `archive` command to CLI that moves changes to `changes/archive/YYYY-MM-DD-[change-name]/`
- Check for incomplete tasks before archiving and warn user
- Allow interactive selection of change to archive
- Prevent archiving if target directory already exists
- Update main specs from the change's future state specs (copy from `changes/[name]/specs/` to `openspec/specs/`)
- Show confirmation prompt before updating specs, displaying which specs will be created/updated
- Support `--yes` flag to skip confirmations for automation
## Impact
- Affected specs: cli-archive (new)
- Affected code: src/cli/index.ts, src/core/archive.ts (new)
@@ -0,0 +1,111 @@
# CLI Archive Command Specification
## Purpose
The archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.
## Command Syntax
```bash
openspec archive [change-name] [--yes|-y]
```
Options:
- `--yes`, `-y`: Skip confirmation prompts (for automation)
## Behavior
### Change Selection
WHEN no change-name is provided
THEN display interactive list of available changes (excluding archive/)
AND allow user to select one
WHEN change-name is provided
THEN use that change directly
AND validate it exists
### Task Completion Check
The command SHALL scan the change's tasks.md file for incomplete tasks (marked with `- [ ]`)
WHEN incomplete tasks are found
THEN display all incomplete tasks to the user
AND prompt for confirmation to continue
AND default to "No" for safety
WHEN all tasks are complete OR no tasks.md exists
THEN proceed with archiving without prompting
### Archive Process
The archive operation SHALL:
1. Create archive/ directory if it doesn't exist
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date
3. Check if target directory already exists
4. Update main specs from the change's future state specs (see Spec Update Process below)
5. Move the entire change directory to the archive location
WHEN target archive already exists
THEN fail with error message
AND do not overwrite existing archive
WHEN move succeeds
THEN display success message with archived name and list of updated specs
### Spec Update Process
Before moving the change to archive, the command SHALL update main specs to reflect the deployed reality:
WHEN the change contains specs in `changes/[name]/specs/`
THEN:
1. Analyze which specs will be affected by comparing with existing specs
2. Display a summary of spec updates to the user (see Confirmation Behavior below)
3. Prompt for confirmation unless `--yes` flag is provided
4. If confirmed, for each capability spec in the change directory:
- Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`
- Create the target directory structure if it doesn't exist
- Overwrite existing spec files (specs represent current reality, change specs are the new reality)
- Track which specs were updated for the success message
WHEN no specs exist in the change
THEN skip the spec update step
AND proceed with archiving
### Confirmation Behavior
The spec update confirmation SHALL:
- Display a clear summary showing:
- Which specs will be created (new capabilities)
- Which specs will be updated (existing capabilities)
- The source path for each spec
- Format the confirmation prompt as:
```
The following specs will be updated:
NEW specs to be created:
- cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)
EXISTING specs to be updated:
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
Update 2 specs and archive 'add-archive-command'? [y/N]:
```
- Default to "No" for safety (require explicit "y" or "yes")
- Skip confirmation when `--yes` or `-y` flag is provided
WHEN user declines the confirmation
THEN abort the entire archive operation
AND display message: "Archive cancelled. No changes were made."
AND exit with non-zero status code
## Error Handling
SHALL handle the following error conditions:
- Missing openspec/changes/ directory
- Change not found
- Archive target already exists
- File system permissions issues
## Why These Decisions
**Interactive selection**: Reduces typing and helps users see available changes
**Task checking**: Prevents accidental archiving of incomplete work
**Date prefixing**: Maintains chronological order and prevents naming conflicts
**No overwrite**: Preserves historical archives and prevents data loss
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
@@ -0,0 +1,44 @@
# Implementation Tasks
## 1. Core Implementation
- [ ] 1.1 Create `src/core/archive.ts` with ArchiveCommand class
- [ ] 1.1.1 Implement change selection (interactive if not provided)
- [ ] 1.1.2 Implement incomplete task checking from tasks.md
- [ ] 1.1.3 Implement confirmation prompt for incomplete tasks
- [ ] 1.1.4 Implement spec update functionality
- [ ] 1.1.4.1 Detect specs in change directory
- [ ] 1.1.4.2 Compare with existing main specs
- [ ] 1.1.4.3 Display summary of new vs updated specs
- [ ] 1.1.4.4 Show confirmation prompt for spec updates
- [ ] 1.1.4.5 Copy specs to main spec directory
- [ ] 1.1.5 Implement archive move with date prefixing
- [ ] 1.1.6 Support --yes flag to skip confirmations
## 2. CLI Integration
- [ ] 2.1 Add archive command to `src/cli/index.ts`
- [ ] 2.1.1 Import ArchiveCommand
- [ ] 2.1.2 Register command with commander
- [ ] 2.1.3 Add --yes/-y flag option
- [ ] 2.1.4 Add proper error handling
## 3. Error Handling
- [ ] 3.1 Handle missing openspec/changes/ directory
- [ ] 3.2 Handle change not found
- [ ] 3.3 Handle archive target already exists
- [ ] 3.4 Handle user cancellation
## 4. Testing
- [ ] 4.1 Test with fully completed change
- [ ] 4.2 Test with incomplete tasks (warning shown)
- [ ] 4.3 Test interactive selection mode
- [ ] 4.4 Test duplicate archive prevention
- [ ] 4.5 Test spec update functionality
- [ ] 4.5.1 Test creating new specs
- [ ] 4.5.2 Test updating existing specs
- [ ] 4.5.3 Test confirmation prompt display
- [ ] 4.5.4 Test declining confirmation (no changes made)
- [ ] 4.5.5 Test --yes flag skips confirmation
## 5. Build and Validation
- [ ] 5.1 Ensure TypeScript compilation succeeds
- [ ] 5.2 Test command execution
@@ -0,0 +1,28 @@
# Fix Update Command Tool Selection
## Problem
The `openspec update` command currently forces the creation/update of CLAUDE.md regardless of which AI tool was selected during initialization. This violates the tool-agnostic design principle and creates confusion for users who selected different AI assistants.
Additionally, different team members may use different AI tools, so we cannot rely on a shared configuration file.
## Solution
Modify the update command to:
1. Only update AI tool configuration files that already exist
2. Never create new AI tool configuration files
3. Always update the core OpenSpec files (README.md, etc.)
## Implementation
- Remove hardcoded CLAUDE.md update from update command
- Implement file existence check before updating any AI tool config
- Update each existing AI tool config file with its appropriate markers
- No configuration file needed (avoids team conflicts)
## Success Criteria
- Update command only modifies existing AI tool configuration files
- No new AI tool files created during update
- Team members can use different AI tools without conflicts
- Existing projects continue to work (backward compatibility)
@@ -0,0 +1,113 @@
# Update Command Specification
## Purpose
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
## Core Requirements
### Requirement: Update Behavior
The update command SHALL update OpenSpec instruction files to the latest templates.
#### Scenario: Running update command
- **WHEN** a user runs `openspec update`
- **THEN** the command SHALL:
- Check if the `openspec` directory exists
- Replace `openspec/README.md` with the latest template (complete replacement)
- For each supported AI tool configuration file:
- Check if the file exists (e.g., CLAUDE.md, COPILOT.md)
- If it exists, update it using appropriate markers
- If it doesn't exist, skip it (do NOT create)
- Preserve user content outside markers
- Display ASCII-safe success message: "Updated OpenSpec instructions"
### Requirement: Prerequisites
The command SHALL require an existing OpenSpec structure before allowing updates.
#### Scenario: Checking prerequisites
- **GIVEN** the command requires an existing `openspec` directory (created by `openspec init`)
- **WHEN** the `openspec` directory does not exist
- **THEN** display error: "No OpenSpec directory found. Run 'openspec init' first."
- **AND** exit with code 1
### Requirement: File Handling
The update command SHALL handle file updates in a predictable and safe manner.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/README.md` with the latest template
- **AND** update only the AI tool configuration files that already exist
- **AND** use the default directory name `openspec`
- **AND** be idempotent (repeated runs have no additional effect)
### Requirement: Tool-Agnostic Updates
The update command SHALL work for any team member regardless of their AI tool choice.
#### Scenario: Team member using Claude
- **GIVEN** a team member has CLAUDE.md in their project
- **WHEN** running `openspec update`
- **THEN** update the CLAUDE.md file with the latest template
- **AND** preserve user content outside OpenSpec markers
- **AND** NOT create files for other tools
#### Scenario: Team member using different tool
- **GIVEN** a team member has COPILOT.md but no CLAUDE.md
- **WHEN** running `openspec update`
- **THEN** update the COPILOT.md file if implementation exists
- **AND** NOT create CLAUDE.md
- **AND** preserve user content outside OpenSpec markers
#### Scenario: Mixed team environment
- **GIVEN** a repository with both CLAUDE.md and COPILOT.md (different team members)
- **WHEN** any team member runs `openspec update`
- **THEN** update all existing AI tool configuration files
- **AND** NOT create new AI tool configuration files
- **AND** each team member's preferred tool remains configured
## Edge Cases
### Requirement: Error Handling
The command SHALL handle edge cases gracefully.
#### Scenario: File permission errors
- **WHEN** file write fails
- **THEN** let the error bubble up naturally with file path
#### Scenario: No AI tool files exist
- **GIVEN** no AI tool configuration files exist
- **WHEN** running update
- **THEN** only update openspec/README.md
- **AND** display success message
#### Scenario: Custom directory names
- **WHEN** considering custom directory names
- **THEN** not supported in this change
- **AND** the default directory name `openspec` SHALL be used
## Success Criteria
Users SHALL be able to:
- Update OpenSpec instructions with a single command
- Get the latest AI agent instructions for their existing tools
- Work in teams where members use different AI tools
- NOT have unwanted AI tool configuration files created
The update process SHALL be:
- Simple and fast (no version checking)
- Predictable (same result every time)
- Self-contained (no network required)
- Team-friendly (respects individual tool choices)
@@ -0,0 +1,21 @@
# Implementation Tasks
## 1. Update Update Command
- [x] Remove hardcoded CLAUDE.md update from `src/core/update.ts`
- [x] Add logic to check for existing AI tool configuration files
- [x] Update only existing files using their appropriate configurators
- [x] Iterate through all registered configurators to check for existing files
## 2. Update Configurator Registry
- [x] Add method to get all configurators for update command
- [x] Ensure each configurator can check if its file exists
## 3. Add Tests
- [x] Test update command with only CLAUDE.md present
- [x] Test update command with no AI tool files present
- [x] Test update command with multiple AI tool files present
- [x] Test that update never creates new AI tool files
## 4. Update Documentation
- [x] Update README to clarify team-friendly behavior
- [x] Document that update only modifies existing files
@@ -0,0 +1,36 @@
## Why
OpenSpec specifications lack a consistent structure that makes sections visually identifiable and programmatically parseable across different specs. This makes it harder to maintain consistency and build tooling.
## What Changes
**Specification Format Section**
- From: No formal structure requirements for specifications
- To: Structured format with `### Requirement:` and `#### Scenario:` headers
- Reason: Visual consistency and parseability across all specs
- Impact: Non-breaking - existing specs can migrate gradually
**Keyword Formatting**
- From: Inconsistent use of WHEN/THEN/AND keywords
- To: Bold keywords (**WHEN**, **THEN**, **AND**) in scenario bullets
- Reason: Improved readability and consistent visual hierarchy
- Impact: Non-breaking - formatting enhancement only
**Format Flexibility**
- From: Implicit understanding that different content needs different formats
- To: Explicit allowance for alternative formats (OpenAPI, JSON Schema, etc.)
- Reason: Address concern that not all specs fit requirement/scenario pattern
- Impact: Non-breaking - clarifies existing practice
**Migration Guidelines**
- From: No migration guidance
- To: Documented gradual migration approach
- Reason: Allows incremental adoption without disrupting existing specs
- Impact: Non-breaking - opt-in migration as specs are modified
## Impact
- Affected specs: openspec-conventions (enhancement to existing capability)
- Affected code: None initially - this is a documentation standard enhancement
- Migration: Gradual - existing specs migrate as they're modified
- Tooling: Enables future parsing tools but doesn't require them
@@ -0,0 +1,180 @@
# OpenSpec Conventions Specification
## Purpose
OpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.
## Core Principles
The system SHALL follow these principles:
- Specs reflect what IS currently built and deployed
- Changes contain proposals for what SHOULD be changed
- AI drives the documentation process
- Specs are living documentation kept in sync with deployed code
## Directory Structure
WHEN an OpenSpec project is initialized
THEN it SHALL have this structure:
```
openspec/
├── project.md # Project-specific context
├── README.md # AI assistant instructions
├── specs/ # Current deployed capabilities
│ └── [capability]/ # Single, focused capability
│ ├── spec.md # WHAT and WHY
│ └── design.md # HOW (optional, for established patterns)
└── changes/ # Proposed changes
├── [change-name]/ # Descriptive change identifier
│ ├── proposal.md # Why, what, and impact
│ ├── tasks.md # Implementation checklist
│ ├── design.md # Technical decisions (optional)
│ └── specs/ # Complete future state
│ └── [capability]/
│ └── spec.md # Clean markdown (no diff syntax)
└── archive/ # Completed changes
└── YYYY-MM-DD-[name]/
```
## Specification Format
### Requirement: Structured Format for Behavioral Specs
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
#### Scenario: Writing requirement sections
- **WHEN** documenting a requirement in a behavioral specification
- **THEN** use a level-3 heading with format `### Requirement: [Name]`
- **AND** immediately follow with a SHALL statement describing core behavior
- **AND** keep requirement names descriptive and under 50 characters
#### Scenario: Documenting scenarios
- **WHEN** documenting specific behaviors or use cases
- **THEN** use level-4 headings with format `#### Scenario: [Description]`
- **AND** use bullet points with bold keywords for steps:
- **GIVEN** for initial state (optional)
- **WHEN** for conditions or triggers
- **THEN** for expected outcomes
- **AND** for additional outcomes or conditions
#### Scenario: Adding implementation details
- **WHEN** a step requires additional detail
- **THEN** use sub-bullets under the main step
- **AND** maintain consistent indentation
- Sub-bullets provide examples or specifics
- Keep sub-bullets concise
### Requirement: Format Flexibility
The structured format SHALL be the default for behavioral specifications, but alternative formats MAY be used when more appropriate for the content type.
#### Scenario: Documenting API specifications
- **WHEN** documenting REST API endpoints or GraphQL schemas
- **THEN** OpenAPI, GraphQL SDL, or similar formats MAY be used
- **AND** the spec SHALL clearly indicate the format being used
- **AND** behavioral aspects SHALL still follow the structured format
#### Scenario: Documenting data schemas
- **WHEN** documenting data structures, database schemas, or configurations
- **THEN** JSON Schema, SQL DDL, or similar formats MAY be used
- **AND** include the structured format for behavioral rules and constraints
#### Scenario: Using simplified format
- **WHEN** documenting simple capabilities without complex scenarios
- **THEN** a simplified WHEN/THEN format without full structure MAY be used
- **AND** this should be consistent within the capability
## Change Storage Convention
### Future State Storage
WHEN creating a change proposal
THEN store the complete future state of affected specs
AND use clean markdown without diff syntax
The `changes/[name]/specs/` directory SHALL contain:
- Complete spec files as they will exist after the change
- Clean markdown without `+` or `-` prefixes
- All formatting and structure of the final intended state
### Proposal Format
WHEN documenting what changes
THEN the proposal SHALL explicitly describe each change:
```markdown
**[Section or Behavior Name]**
- From: [current state/requirement]
- To: [future state/requirement]
- Reason: [why this change is needed]
- Impact: [breaking/non-breaking, who's affected]
```
This explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change.
## Change Lifecycle
The change process SHALL follow these states:
1. **Propose**: AI creates change with future state specs and explicit proposal
2. **Review**: Humans review proposal and future state
3. **Approve**: Change is approved for implementation
4. **Implement**: Follow tasks.md checklist (can span multiple PRs)
5. **Deploy**: Changes are deployed to production
6. **Update**: Specs in `specs/` are updated to match deployed reality
7. **Archive**: Change is moved to `archive/YYYY-MM-DD-[name]/`
## Viewing Changes
WHEN reviewing proposed changes
THEN reviewers can compare using:
- GitHub PR diff view when changes are committed
- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`
- Any visual diff tool comparing current vs future state
The system relies on tools to generate diffs rather than storing them.
## Capability Naming
Capabilities SHALL use:
- Verb-noun patterns (e.g., `user-auth`, `payment-capture`)
- Hyphenated lowercase names
- Singular focus (one responsibility per capability)
- No nesting (flat structure under `specs/`)
## When Changes Require Proposals
A proposal SHALL be created for:
- New features or capabilities
- Breaking changes to existing behavior
- Architecture or pattern changes
- Performance optimizations that change behavior
- Security updates affecting access patterns
A proposal is NOT required for:
- Bug fixes restoring intended behavior
- Typos or formatting fixes
- Non-breaking dependency updates
- Adding tests for existing behavior
- Documentation clarifications
## Why This Approach
Clean future state storage provides:
- **Readability**: No diff syntax pollution
- **AI-compatibility**: Standard markdown that AI tools understand
- **Simplicity**: No special parsing or processing needed
- **Tool-agnostic**: Any diff tool can show changes
- **Clear intent**: Explicit proposals document reasoning
The structured format adds:
- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable
- **Parseability**: Consistent structure enables tooling and automation
- **Flexibility**: Alternative formats supported where appropriate
- **Gradual Adoption**: Existing specs can migrate incrementally
@@ -0,0 +1,19 @@
## 1. Update OpenSpec Conventions Spec
- [x] 1.1 Add "Specification Format" section to openspec-conventions
- [x] 1.2 Document structured format with Requirement/Scenario headers
- [x] 1.3 Define bold keyword usage (WHEN/THEN/AND) for scenarios
- [x] 1.4 Include examples demonstrating the format within the spec itself
## 2. Update Documentation
- [x] 2.1 Update the "Why This Approach" section with structured format benefits
- [x] 2.2 Ensure spec follows its own format as a demonstration
## 3. Update Existing Specs
- [x] 3.1 Update cli-init spec to use structured format in Behavior section
- [x] 3.2 Update cli-list spec to use structured format in Behavior section
- [x] 3.3 Update cli-update spec to use structured format in Behavior section
- [x] 3.4 Update cli-diff spec to use structured format in Behavior section
- [x] 3.5 Update cli-archive spec to use structured format in Behavior section
+155
View File
@@ -0,0 +1,155 @@
# CLI Archive Command Specification
## Purpose
The archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.
## Command Syntax
```bash
openspec archive [change-name] [--yes|-y]
```
Options:
- `--yes`, `-y`: Skip confirmation prompts (for automation)
## Behavior
### Requirement: Change Selection
The command SHALL support both interactive and direct change selection methods.
#### Scenario: Interactive selection
- **WHEN** no change-name is provided
- **THEN** display interactive list of available changes (excluding archive/)
- **AND** allow user to select one
#### Scenario: Direct selection
- **WHEN** change-name is provided
- **THEN** use that change directly
- **AND** validate it exists
### Requirement: Task Completion Check
The command SHALL verify task completion status before archiving to prevent premature archival.
#### Scenario: Incomplete tasks found
- **WHEN** incomplete tasks are found (marked with `- [ ]`)
- **THEN** display all incomplete tasks to the user
- **AND** prompt for confirmation to continue
- **AND** default to "No" for safety
#### Scenario: All tasks complete
- **WHEN** all tasks are complete OR no tasks.md exists
- **THEN** proceed with archiving without prompting
### Requirement: Archive Process
The archive operation SHALL follow a structured process to safely move changes to the archive.
#### Scenario: Performing archive
- **WHEN** archiving a change
- **THEN** execute these steps:
1. Create archive/ directory if it doesn't exist
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date
3. Check if target directory already exists
4. Update main specs from the change's future state specs (see Spec Update Process below)
5. Move the entire change directory to the archive location
#### Scenario: Archive already exists
- **WHEN** target archive already exists
- **THEN** fail with error message
- **AND** do not overwrite existing archive
#### Scenario: Successful archive
- **WHEN** move succeeds
- **THEN** display success message with archived name and list of updated specs
### Requirement: Spec Update Process
Before moving the change to archive, the command SHALL update main specs to reflect the deployed reality.
#### Scenario: Updating specs from change
- **WHEN** the change contains specs in `changes/[name]/specs/`
- **THEN** execute these steps:
1. Analyze which specs will be affected by comparing with existing specs
2. Display a summary of spec updates to the user (see Confirmation Behavior below)
3. Prompt for confirmation unless `--yes` flag is provided
4. If confirmed, for each capability spec in the change directory:
- Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`
- Create the target directory structure if it doesn't exist
- Overwrite existing spec files (specs represent current reality, change specs are the new reality)
- Track which specs were updated for the success message
#### Scenario: No specs in change
- **WHEN** no specs exist in the change
- **THEN** skip the spec update step
- **AND** proceed with archiving
### Requirement: Confirmation Behavior
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
#### Scenario: Displaying confirmation
- **WHEN** prompting for confirmation
- **THEN** display a clear summary showing:
- Which specs will be created (new capabilities)
- Which specs will be updated (existing capabilities)
- The source path for each spec
- **AND** format the confirmation prompt as:
```
The following specs will be updated:
NEW specs to be created:
- cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)
EXISTING specs to be updated:
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
Update 2 specs and archive 'add-archive-command'? [y/N]:
```
#### Scenario: Handling confirmation response
- **WHEN** waiting for user confirmation
- **THEN** default to "No" for safety (require explicit "y" or "yes")
- **AND** skip confirmation when `--yes` or `-y` flag is provided
#### Scenario: User declines confirmation
- **WHEN** user declines the confirmation
- **THEN** abort the entire archive operation
- **AND** display message: "Archive cancelled. No changes were made."
- **AND** exit with non-zero status code
## Error Handling
### Requirement: Error Conditions
The command SHALL handle various error conditions gracefully.
#### Scenario: Handling errors
- **WHEN** errors occur
- **THEN** handle the following conditions:
- Missing openspec/changes/ directory
- Change not found
- Archive target already exists
- File system permissions issues
## Why These Decisions
**Interactive selection**: Reduces typing and helps users see available changes
**Task checking**: Prevents accidental archiving of incomplete work
**Date prefixing**: Maintains chronological order and prevents naming conflicts
**No overwrite**: Preserves historical archives and prevents data loss
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
+120
View File
@@ -0,0 +1,120 @@
# CLI Diff Command Specification
## Purpose
The `openspec diff` command provides developers with a visual comparison between proposed spec changes and the current deployed specs.
## Command Syntax
```bash
openspec diff [change-name]
```
## Behavior
### Requirement: Without Arguments
The command SHALL provide an interactive selection when no change is specified.
#### Scenario: Running without arguments
- **WHEN** running `openspec diff` without arguments
- **THEN** list all available changes in the `changes/` directory (excluding archive)
- **AND** prompt user to select a change
### Requirement: With Change Name
The command SHALL compare specs when a specific change is provided.
#### Scenario: Running with change name
- **WHEN** running `openspec diff <change-name>`
- **THEN** compare all spec files in `changes/<change-name>/specs/` with corresponding files in `specs/`
### Requirement: Diff Output
The command SHALL generate appropriate diff output for all spec changes.
#### Scenario: Comparing existing files
- **WHEN** file exists in both locations
- **THEN** show unified diff
#### Scenario: New files
- **WHEN** file only exists in change
- **THEN** show as new file (all lines with +)
#### Scenario: Deleted files
- **WHEN** file only exists in current specs
- **THEN** show as deleted (all lines with -)
### Requirement: Display Format
The command SHALL use standard unified diff format for consistency with existing tools.
#### Scenario: Formatting diff output
- **WHEN** displaying diff output
- **THEN** use standard unified diff format:
- Lines prefixed with `-` for removed content
- Lines prefixed with `+` for added content
- Lines without prefix for unchanged context
- File headers showing the paths being compared
### Requirement: Color Support
The command SHALL enhance readability with colors when supported.
#### Scenario: Terminal with color support
- **WHEN** terminal supports colors
- **THEN** display:
- Removed lines in red
- Added lines in green
- File headers in bold
- Context lines in default color
### Requirement: Error Handling
The command SHALL provide clear error messages for various failure conditions.
#### Scenario: Change not found
- **WHEN** specified change doesn't exist
- **THEN** display error "Change '<name>' not found"
#### Scenario: No specs in change
- **WHEN** no specs directory in change
- **THEN** display "No spec changes found for '<name>'"
#### Scenario: Missing changes directory
- **WHEN** changes directory doesn't exist
- **THEN** display "No OpenSpec changes directory found"
## Examples
```bash
# View diff for specific change
$ openspec diff add-auth-feature
--- specs/user-auth/spec.md
+++ changes/add-auth-feature/specs/user-auth/spec.md
@@ -10,6 +10,8 @@
Users SHALL authenticate with email and password.
+Users MAY authenticate with OAuth providers.
+
WHEN credentials are valid THEN issue JWT token.
# List all changes and select
$ openspec diff
Available changes:
1. add-auth-feature
2. update-payment-flow
3. add-status-command
Select a change (1-3):
```
+110 -62
View File
@@ -6,20 +6,28 @@ The `openspec init` command SHALL create a complete OpenSpec directory structure
## Behavior
### Progress Indicators
### Requirement: Progress Indicators
WHEN executing initialization steps
THEN validate environment silently in background (no output unless error)
AND display progress with ora spinners:
- Show spinner: "⠋ Creating OpenSpec structure..."
- Then success: "✔ OpenSpec structure created"
- Show spinner: "⠋ Configuring AI tools..."
- Then success: "✔ AI tools configured"
The command SHALL display progress indicators during initialization to provide clear feedback about each step.
### Directory Creation
#### Scenario: Displaying initialization progress
WHEN `openspec init` is executed
THEN create the following directory structure:
- **WHEN** executing initialization steps
- **THEN** validate environment silently in background (no output unless error)
- **AND** display progress with ora spinners:
- Show spinner: "⠋ Creating OpenSpec structure..."
- Then success: "✔ OpenSpec structure created"
- Show spinner: "⠋ Configuring AI tools..."
- Then success: "✔ AI tools configured"
### Requirement: Directory Creation
The command SHALL create the complete OpenSpec directory structure with all required directories and files.
#### Scenario: Creating OpenSpec structure
- **WHEN** `openspec init` is executed
- **THEN** create the following directory structure:
```
openspec/
├── project.md
@@ -29,27 +37,41 @@ openspec/
└── archive/
```
### File Generation
### Requirement: File Generation
The command SHALL generate:
- `README.md` containing complete OpenSpec instructions for AI assistants
- `project.md` with project context template
The command SHALL generate required template files with appropriate content for immediate use.
### AI Tool Configuration
#### Scenario: Generating template files
WHEN run interactively
THEN prompt user to select AI tools to configure:
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
- Cursor (future)
- Aider (future)
- **WHEN** initializing OpenSpec
- **THEN** generate `README.md` containing complete OpenSpec instructions for AI assistants
- **AND** generate `project.md` with project context template
### AI Tool Configuration Details
### Requirement: AI Tool Configuration
WHEN Claude Code is selected
THEN create or update `CLAUDE.md` in the project root directory (not inside openspec/)
The command SHALL configure AI coding assistants with OpenSpec instructions based on user selection.
WHEN CLAUDE.md does not exist
THEN create new file with OpenSpec content wrapped in markers:
#### Scenario: Prompting for AI tool selection
- **WHEN** run interactively
- **THEN** prompt user to select AI tools to configure:
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
- Cursor (future)
- Aider (future)
### Requirement: AI Tool Configuration Details
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
#### Scenario: Configuring Claude Code
- **WHEN** Claude Code is selected
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
#### Scenario: Creating new CLAUDE.md
- **WHEN** CLAUDE.md does not exist
- **THEN** create new file with OpenSpec content wrapped in markers:
```markdown
<!-- OPENSPEC:START -->
# OpenSpec Project
@@ -62,51 +84,71 @@ See @openspec/README.md for detailed conventions and guidelines.
<!-- OPENSPEC:END -->
```
WHEN CLAUDE.md already exists
THEN preserve all existing content
AND insert OpenSpec content at the beginning of the file using markers
AND ensure markers don't duplicate if they already exist
#### Scenario: Updating existing CLAUDE.md
The marker system SHALL:
- Use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
- Use `<!-- OPENSPEC:END -->` to mark the end of managed content
- Allow OpenSpec to update its content without affecting user customizations
- Preserve all content outside the markers intact
- **WHEN** CLAUDE.md already exists
- **THEN** preserve all existing content
- **AND** insert OpenSpec content at the beginning of the file using markers
- **AND** ensure markers don't duplicate if they already exist
#### Scenario: Managing content with markers
- **WHEN** using the marker system
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
- **AND** allow OpenSpec to update its content without affecting user customizations
- **AND** preserve all content outside the markers intact
WHY use markers:
- Users may have existing CLAUDE.md instructions they want to keep
- OpenSpec can update its instructions in future versions
- Clear boundary between OpenSpec-managed and user-managed content
### Interactive Mode
### Requirement: Interactive Mode
WHEN run
THEN prompt user with: "Which AI tool do you use?"
AND show single-select menu with available tools:
- Claude Code
AND show disabled options as "coming soon" (not selectable):
- Cursor (coming soon)
- Aider (coming soon)
- Continue (coming soon)
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
User navigation:
- Use arrow keys to move between options
- Press Enter to select the highlighted option
#### Scenario: Displaying interactive menu
### Safety Checks
- **WHEN** run
- **THEN** prompt user with: "Which AI tool do you use?"
- **AND** show single-select menu with available tools:
- Claude Code
- **AND** show disabled options as "coming soon" (not selectable):
- Cursor (coming soon)
- Aider (coming soon)
- Continue (coming soon)
WHEN `openspec/` directory already exists
THEN display error with ora fail indicator:
"✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
#### Scenario: Navigating the menu
WHEN checking initialization feasibility
THEN verify write permissions in the target directory silently
AND only display error if permissions are insufficient
- **WHEN** user is in the menu
- **THEN** allow arrow keys to move between options
- **AND** allow Enter key to select the highlighted option
### Success Output
### Requirement: Safety Checks
WHEN initialization completes successfully
THEN display actionable prompts for AI-driven workflow:
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
#### Scenario: Detecting existing initialization
- **WHEN** `openspec/` directory already exists
- **THEN** display error with ora fail indicator:
- "✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
#### Scenario: Checking write permissions
- **WHEN** checking initialization feasibility
- **THEN** verify write permissions in the target directory silently
- **AND** only display error if permissions are insufficient
### Requirement: Success Output
The command SHALL provide clear, actionable next steps upon successful initialization.
#### Scenario: Displaying success message
- **WHEN** initialization completes successfully
- **THEN** display actionable prompts for AI-driven workflow:
```
✔ OpenSpec initialized successfully!
@@ -132,12 +174,18 @@ The prompts SHALL:
- Guide users through the AI-driven workflow
- Replace placeholder text ([YOUR FEATURE HERE]) with actual features
### Exit Codes
### Requirement: Exit Codes
- 0: Success
- 1: General error (including when OpenSpec directory already exists)
- 2: Insufficient permissions (reserved for future use)
- 3: User cancelled operation (reserved for future use)
The command SHALL use consistent exit codes to indicate different failure modes.
#### Scenario: Returning exit codes
- **WHEN** the command completes
- **THEN** return appropriate exit code:
- 0: Success
- 1: General error (including when OpenSpec directory already exists)
- 2: Insufficient permissions (reserved for future use)
- 3: User cancelled operation (reserved for future use)
## Why
+96
View File
@@ -0,0 +1,96 @@
# List Command Specification
## Purpose
The `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status.
## Behavior
### Requirement: Command Execution
The command SHALL scan and analyze all active changes to provide a comprehensive overview.
#### Scenario: Scanning for changes
- **WHEN** `openspec list` is executed
- **THEN** scan the `openspec/changes/` directory for change directories
- **AND** exclude the `archive/` subdirectory from results
- **AND** parse each change's `tasks.md` file to count task completion
### Requirement: Task Counting
The command SHALL accurately count task completion status using standard markdown checkbox patterns.
#### Scenario: Counting tasks in tasks.md
- **WHEN** parsing a `tasks.md` file
- **THEN** count tasks matching these patterns:
- Completed: Lines containing `- [x]`
- Incomplete: Lines containing `- [ ]`
- **AND** calculate total tasks as the sum of completed and incomplete
### Requirement: Output Format
The command SHALL display changes in a clear, readable table format with progress indicators.
#### Scenario: Displaying change list
- **WHEN** displaying the list
- **THEN** show a table with columns:
- Change name (directory name)
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
- **AND** use status indicators:
- `✓` for fully completed changes (all tasks done)
- Progress fraction for partial completion
Example output:
```
Changes:
add-auth-feature 3/5 tasks
update-api-docs ✓ Complete
fix-validation 0/2 tasks
add-list-command 1/4 tasks
```
### Requirement: Empty State
The command SHALL provide clear feedback when no active changes are present.
#### Scenario: Handling empty state
- **WHEN** no active changes exist (only archive/ or empty changes/)
- **THEN** display: "No active changes found."
### Requirement: Error Handling
The command SHALL gracefully handle missing files and directories with appropriate messages.
#### Scenario: Missing tasks.md file
- **WHEN** a change directory has no `tasks.md` file
- **THEN** display the change with "No tasks" status
#### Scenario: Missing changes directory
- **WHEN** `openspec/changes/` directory doesn't exist
- **THEN** display error: "No OpenSpec changes directory found. Run 'openspec init' first."
- **AND** exit with code 1
### Requirement: Sorting
The command SHALL maintain consistent ordering of changes for predictable output.
#### Scenario: Ordering changes
- **WHEN** displaying multiple changes
- **THEN** sort them in alphabetical order by change name
## Why
Developers need a quick way to:
- See what changes are in progress
- Identify which changes are ready to archive
- Understand the overall project evolution status
- Get a bird's-eye view without opening multiple files
This command provides that visibility with minimal effort, following OpenSpec's philosophy of simplicity and clarity.
+52 -27
View File
@@ -6,45 +6,70 @@ As a developer using OpenSpec, I want to update the OpenSpec instructions in my
## Core Requirements
### Update Behavior
### Requirement: Update Behavior
The update command SHALL update OpenSpec instruction files to the latest templates.
The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.
WHEN a user runs `openspec update` THEN the command SHALL:
- Check if the `openspec` directory exists
- Replace `openspec/README.md` with the latest template (complete replacement)
- Update the OpenSpec-managed block in `CLAUDE.md` using markers
- Preserve user content outside markers
- Create `CLAUDE.md` if missing
- Display ASCII-safe success message: "Updated OpenSpec instructions"
#### Scenario: Running update command
### Prerequisites
- **WHEN** a user runs `openspec update`
- **THEN** the command SHALL:
- Check if the `openspec` directory exists
- Replace `openspec/README.md` with the latest template (complete replacement)
- Update **only existing** AI tool configuration files (e.g., CLAUDE.md)
- Check each registered AI tool configurator
- For each configurator, check if its file exists
- Update only files that already exist using their markers
- Preserve user content outside markers
- **Never create new AI tool configuration files**
- Display success message listing updated files
The command SHALL require:
- An existing `openspec` directory (created by `openspec init`)
### Requirement: Prerequisites
IF the `openspec` directory does not exist THEN:
- Display error: "No OpenSpec directory found. Run 'openspec init' first."
- Exit with code 1
The command SHALL require an existing OpenSpec structure before allowing updates.
### File Handling
#### Scenario: Checking prerequisites
The update command SHALL:
- Completely replace `openspec/README.md` with the latest template
- Update only the OpenSpec-managed block in `CLAUDE.md` using markers
- Use the default directory name `openspec`
- Be idempotent (repeated runs have no additional effect)
- **GIVEN** the command requires an existing `openspec` directory (created by `openspec init`)
- **WHEN** the `openspec` directory does not exist
- **THEN** display error: "No OpenSpec directory found. Run 'openspec init' first."
- **AND** exit with code 1
### Requirement: File Handling
The update command SHALL handle file updates in a predictable and safe manner.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/README.md` with the latest template
- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers
- **AND** use the default directory name `openspec`
- **AND** be idempotent (repeated runs have no additional effect)
- **AND** respect team members' AI tool choices by not creating unwanted files
## Edge Cases
### File Permissions
IF file write fails THEN let the error bubble up naturally with file path.
### Requirement: Error Handling
### Missing CLAUDE.md
IF CLAUDE.md doesn't exist THEN create it with the template content.
The command SHALL handle edge cases gracefully.
### Custom Directory Name
Not supported in this change. The default directory name `openspec` SHALL be used.
#### Scenario: File permission errors
- **WHEN** file write fails
- **THEN** let the error bubble up naturally with file path
#### Scenario: Missing AI tool files
- **WHEN** an AI tool configuration file doesn't exist
- **THEN** skip updating that file
- **AND** do not create it
#### Scenario: Custom directory names
- **WHEN** considering custom directory names
- **THEN** not supported in this change
- **AND** the default directory name `openspec` SHALL be used
## Success Criteria
+153 -15
View File
@@ -14,8 +14,14 @@ The system SHALL follow these principles:
## Directory Structure
WHEN an OpenSpec project is initialized
THEN it SHALL have this structure:
### Requirement: Project Structure
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
#### Scenario: Initializing project structure
- **WHEN** an OpenSpec project is initialized
- **THEN** it SHALL have this structure:
```
openspec/
├── project.md # Project-specific context
@@ -36,23 +42,144 @@ openspec/
└── YYYY-MM-DD-[name]/
```
## Specification Format
### Requirement: Structured Format for Behavioral Specs
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
#### Scenario: Writing requirement sections
- **WHEN** documenting a requirement in a behavioral specification
- **THEN** use a level-3 heading with format `### Requirement: [Name]`
- **AND** immediately follow with a SHALL statement describing core behavior
- **AND** keep requirement names descriptive and under 50 characters
#### Scenario: Documenting scenarios
- **WHEN** documenting specific behaviors or use cases
- **THEN** use level-4 headings with format `#### Scenario: [Description]`
- **AND** use bullet points with bold keywords for steps:
- **GIVEN** for initial state (optional)
- **WHEN** for conditions or triggers
- **THEN** for expected outcomes
- **AND** for additional outcomes or conditions
#### Scenario: Adding implementation details
- **WHEN** a step requires additional detail
- **THEN** use sub-bullets under the main step
- **AND** maintain consistent indentation
- Sub-bullets provide examples or specifics
- Keep sub-bullets concise
## Change Storage Convention
### Future State Storage
### Requirement: Header-Based Requirement Identification
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
#### Scenario: Matching requirements programmatically
- **WHEN** processing delta changes
- **THEN** use the `### Requirement: [Name]` header as the unique identifier
- **AND** match using normalized headers: `normalize(header) = trim(header)`
- **AND** compare headers with case-sensitive equality after normalization
#### Scenario: Handling requirement renames
- **WHEN** renaming a requirement
- **THEN** use a special `## RENAMED Requirements` section
- **AND** specify both old and new names explicitly:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
- **AND** if content also changes, include under MODIFIED using the NEW header
#### Scenario: Validating header uniqueness
- **WHEN** creating or modifying requirements
- **THEN** ensure no duplicate headers exist within a spec
- **AND** validation tools SHALL flag duplicate headers as errors
### Requirement: Change Storage Convention
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
#### Scenario: Creating change proposals with additions
- **WHEN** creating a change proposal that adds new requirements
- **THEN** include only the new requirements under `## ADDED Requirements`
- **AND** each requirement SHALL include its complete content
- **AND** use the standard structured format for requirements and scenarios
#### Scenario: Creating change proposals with modifications
- **WHEN** creating a change proposal that modifies existing requirements
- **THEN** include the modified requirements under `## MODIFIED Requirements`
- **AND** use the same header text as in the current spec (normalized)
- **AND** include the complete modified requirement (not a diff)
- **AND** optionally annotate what changed with inline comments like `← (was X)`
#### Scenario: Creating change proposals with removals
- **WHEN** creating a change proposal that removes requirements
- **THEN** list them under `## REMOVED Requirements`
- **AND** use the normalized header text for identification
- **AND** include reason for removal
- **AND** document any migration path if applicable
WHEN creating a change proposal
THEN store the complete future state of affected specs
AND use clean markdown without diff syntax
The `changes/[name]/specs/` directory SHALL contain:
- Complete spec files as they will exist after the change
- Clean markdown without `+` or `-` prefixes
- All formatting and structure of the final intended state
- Delta files showing only what changes
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
- Normalized header matching for requirement identification
- Complete requirements using the structured format
- Clear indication of change type for each requirement
### Proposal Format
#### Scenario: Using standard output symbols
WHEN documenting what changes
THEN the proposal SHALL explicitly describe each change:
- **WHEN** displaying delta operations in CLI output
- **THEN** use these standard symbols:
- `+` for ADDED (green)
- `~` for MODIFIED (yellow)
- `-` for REMOVED (red)
- `→` for RENAMED (cyan)
### Requirement: Archive Process Enhancement
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
#### Scenario: Archiving changes with deltas
- **WHEN** archiving a completed change
- **THEN** the archive command SHALL:
1. Parse RENAMED sections first and apply renames
2. Parse REMOVED sections and remove by normalized header match
3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)
4. Parse ADDED sections and append new requirements
- **AND** validate that all MODIFIED/REMOVED headers exist in current spec
- **AND** validate that ADDED headers don't already exist
- **AND** generate the updated spec in the main specs/ directory
#### Scenario: Handling conflicts during archive
- **WHEN** delta changes conflict with current spec state
- **THEN** the archive command SHALL report specific conflicts
- **AND** require manual resolution before proceeding
- **AND** provide clear guidance on resolving conflicts
### Requirement: Proposal Format
Proposals SHALL explicitly document all changes with clear from/to comparisons.
#### Scenario: Documenting changes
- **WHEN** documenting what changes
- **THEN** the proposal SHALL explicitly describe each change:
```markdown
**[Section or Behavior Name]**
@@ -78,8 +205,14 @@ The change process SHALL follow these states:
## Viewing Changes
WHEN reviewing proposed changes
THEN reviewers can compare using:
### Requirement: Change Review
The system SHALL support multiple methods for reviewing proposed changes.
#### Scenario: Reviewing changes
- **WHEN** reviewing proposed changes
- **THEN** reviewers can compare using:
- GitHub PR diff view when changes are committed
- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`
- Any visual diff tool comparing current vs future state
@@ -117,4 +250,9 @@ Clean future state storage provides:
- **AI-compatibility**: Standard markdown that AI tools understand
- **Simplicity**: No special parsing or processing needed
- **Tool-agnostic**: Any diff tool can show changes
- **Clear intent**: Explicit proposals document reasoning
- **Clear intent**: Explicit proposals document reasoning
The structured format adds:
- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable
- **Parseability**: Consistent structure enables tooling and automation
- **Gradual Adoption**: Existing specs can migrate incrementally
+2 -1
View File
@@ -56,6 +56,7 @@
"chalk": "^5.5.0",
"commander": "^14.0.0",
"jest-diff": "^30.0.5",
"ora": "^8.2.0"
"ora": "^8.2.0",
"zod": "^4.0.17"
}
}
+8
View File
@@ -23,6 +23,9 @@ importers:
ora:
specifier: ^8.2.0
version: 8.2.0
zod:
specifier: ^4.0.17
version: 4.0.17
devDependencies:
'@types/node':
specifier: ^24.2.0
@@ -900,6 +903,9 @@ packages:
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
engines: {node: '>=18'}
zod@4.0.17:
resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==}
snapshots:
'@esbuild/aix-ppc64@0.25.8':
@@ -1631,3 +1637,5 @@ snapshots:
strip-ansi: 6.0.1
yoctocolors-cjs@2.1.2: {}
zod@4.0.17: {}
+34 -1
View File
@@ -5,6 +5,8 @@ import { promises as fs } from 'fs';
import { InitCommand } from '../core/init.js';
import { UpdateCommand } from '../core/update.js';
import { DiffCommand } from '../core/diff.js';
import { ListCommand } from '../core/list.js';
import { ArchiveCommand } from '../core/archive.js';
const program = new Command();
@@ -63,7 +65,7 @@ program
program
.command('diff [change-name]')
.description('Show differences between proposed spec changes and current specs')
.description('Show differences between proposed spec changes and current specs (includes validation warnings)')
.action(async (changeName?: string) => {
try {
const diffCommand = new DiffCommand();
@@ -75,4 +77,35 @@ program
}
});
program
.command('list')
.description('List all active changes with their task status')
.action(async () => {
try {
const listCommand = new ListCommand();
await listCommand.execute();
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program
.command('archive [change-name]')
.description('Archive a completed change and update main specs')
.option('-y, --yes', 'Skip confirmation prompts')
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
.option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean }) => {
try {
const archiveCommand = new ArchiveCommand();
await archiveCommand.execute(changeName, options);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program.parse();
+318
View File
@@ -0,0 +1,318 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
interface SpecUpdate {
source: string;
target: string;
exists: boolean;
}
export class ArchiveCommand {
async execute(changeName?: string, options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean } = {}): Promise<void> {
const targetPath = '.';
const changesDir = path.join(targetPath, 'openspec', 'changes');
const archiveDir = path.join(changesDir, 'archive');
const mainSpecsDir = path.join(targetPath, 'openspec', 'specs');
// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}
// Get change name interactively if not provided
if (!changeName) {
const selectedChange = await this.selectChange(changesDir);
if (!selectedChange) {
console.log('No change selected. Aborting.');
return;
}
changeName = selectedChange;
}
const changeDir = path.join(changesDir, changeName);
// Verify change exists
try {
const stat = await fs.stat(changeDir);
if (!stat.isDirectory()) {
throw new Error(`Change '${changeName}' not found.`);
}
} catch {
throw new Error(`Change '${changeName}' not found.`);
}
// Validate specs and change before archiving
if (!options.noValidate) {
const validator = new Validator();
let hasValidationErrors = false;
// Validate change.md file
const changeFile = path.join(changeDir, 'change.md');
try {
await fs.access(changeFile);
const changeReport = await validator.validateChange(changeFile);
if (!changeReport.valid) {
hasValidationErrors = true;
console.log(chalk.red(`\nValidation errors in change.md:`));
for (const issue of changeReport.issues) {
if (issue.level === 'ERROR') {
console.log(chalk.red(` ✗ ${issue.message}`));
} else if (issue.level === 'WARNING') {
console.log(chalk.yellow(` ⚠ ${issue.message}`));
}
}
}
} catch {
// Change file doesn't exist, skip validation
}
// Validate spec files
const changeSpecsDir = path.join(changeDir, 'specs');
try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
try {
await fs.access(specFile);
const report = await validator.validateSpec(specFile);
if (!report.valid) {
hasValidationErrors = true;
console.log(chalk.red(`\nValidation errors in ${entry.name}/spec.md:`));
for (const issue of report.issues) {
if (issue.level === 'ERROR') {
console.log(chalk.red(` ✗ ${issue.message}`));
} else if (issue.level === 'WARNING') {
console.log(chalk.yellow(` ⚠ ${issue.message}`));
}
}
}
} catch {
// Spec file doesn't exist, skip validation
}
}
}
} catch {
// No specs directory, skip validation
}
if (hasValidationErrors) {
console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.'));
console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.'));
return;
}
} else {
// Log warning when validation is skipped
const timestamp = new Date().toISOString();
if (!options.yes) {
const proceed = await confirm({
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
default: false
});
if (!proceed) {
console.log('Archive cancelled.');
return;
}
} else {
console.log(chalk.yellow(`\n⚠️ WARNING: Skipping validation may archive invalid specs.`));
}
console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`));
console.log(chalk.yellow(`Affected files: ${changeDir}`));
}
// Check for incomplete tasks
const tasksPath = path.join(changeDir, 'tasks.md');
const incompleteTasks = await this.checkIncompleteTasks(tasksPath);
if (incompleteTasks > 0) {
if (!options.yes) {
const proceed = await confirm({
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
default: false
});
if (!proceed) {
console.log('Archive cancelled.');
return;
}
} else {
console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`);
}
}
// Handle spec updates unless skipSpecs flag is set
if (options.skipSpecs) {
console.log('Skipping spec updates (--skip-specs flag provided).');
} else {
// Find specs to update
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
if (specUpdates.length > 0) {
console.log('\nSpecs to update:');
for (const update of specUpdates) {
const status = update.exists ? 'update' : 'create';
const capability = path.basename(path.dirname(update.target));
console.log(` ${capability}: ${status}`);
}
let shouldUpdateSpecs = true;
if (!options.yes) {
shouldUpdateSpecs = await confirm({
message: 'Proceed with spec updates?',
default: true
});
if (!shouldUpdateSpecs) {
console.log('Skipping spec updates. Proceeding with archive.');
}
}
if (shouldUpdateSpecs) {
// Update specs
for (const update of specUpdates) {
await this.updateSpec(update);
}
console.log('Specs updated successfully.');
}
}
}
// Create archive directory with date prefix
const archiveName = `${this.getArchiveDate()}-${changeName}`;
const archivePath = path.join(archiveDir, archiveName);
// Check if archive already exists
try {
await fs.access(archivePath);
throw new Error(`Archive '${archiveName}' already exists.`);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// Create archive directory if needed
await fs.mkdir(archiveDir, { recursive: true });
// Move change to archive
await fs.rename(changeDir, archivePath);
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
}
private async selectChange(changesDir: string): Promise<string | null> {
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name)
.sort();
if (changeDirs.length === 0) {
console.log('No active changes found.');
return null;
}
console.log('Available changes:');
const choices = changeDirs.map(name => ({
name: name,
value: name
}));
try {
const answer = await select({
message: 'Select a change to archive',
choices
});
return answer;
} catch (error) {
// User cancelled (Ctrl+C)
return null;
}
}
private async checkIncompleteTasks(tasksPath: string): Promise<number> {
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
let incompleteTasks = 0;
for (const line of lines) {
if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
return incompleteTasks;
} catch {
// No tasks.md file or error reading it
return 0;
}
}
private async findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {
const updates: SpecUpdate[] = [];
const changeSpecsDir = path.join(changeDir, 'specs');
try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
try {
await fs.access(specFile);
// Check if target exists
let exists = false;
try {
await fs.access(targetFile);
exists = true;
} catch {
exists = false;
}
updates.push({
source: specFile,
target: targetFile,
exists
});
} catch {
// Source spec doesn't exist, skip
}
}
}
} catch {
// No specs directory in change
}
return updates;
}
private async updateSpec(update: SpecUpdate): Promise<void> {
// Create target directory if needed
const targetDir = path.dirname(update.target);
await fs.mkdir(targetDir, { recursive: true });
// Copy spec file
const content = await fs.readFile(update.source, 'utf-8');
await fs.writeFile(update.target, content);
}
private getArchiveDate(): string {
// Returns date in YYYY-MM-DD format
return new Date().toISOString().split('T')[0];
}
}
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'fs';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { Spec, Change } from '../schemas/index.js';
export class JsonConverter {
convertSpecToJson(filePath: string): string {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const specName = this.extractNameFromPath(filePath);
const spec = parser.parseSpec(specName);
const jsonSpec = {
...spec,
metadata: {
...spec.metadata,
sourcePath: filePath,
},
};
return JSON.stringify(jsonSpec, null, 2);
}
convertChangeToJson(filePath: string): string {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const changeName = this.extractNameFromPath(filePath);
const change = parser.parseChange(changeName);
const jsonChange = {
...change,
metadata: {
...change.metadata,
sourcePath: filePath,
},
};
return JSON.stringify(jsonChange, null, 2);
}
private extractNameFromPath(filePath: string): string {
const parts = filePath.split('/');
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] === 'specs' || parts[i] === 'changes') {
if (i < parts.length - 1) {
return parts[i + 1];
}
}
}
const fileName = parts[parts.length - 1];
return fileName.replace('.md', '');
}
}
+49 -1
View File
@@ -3,6 +3,7 @@ import path from 'path';
import chalk from 'chalk';
import { diffStringsUnified } from 'jest-diff';
import { select } from '@inquirer/prompts';
import { Validator } from './validation/validator.js';
// Constants
const ARCHIVE_DIR = 'archive';
@@ -47,6 +48,53 @@ export class DiffCommand {
return;
}
// Validate specs and show warnings (non-blocking)
const validator = new Validator();
let hasWarnings = false;
try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
try {
await fs.access(specFile);
const report = await validator.validateSpec(specFile);
if (report.issues.length > 0) {
const warnings = report.issues.filter(i => i.level === 'WARNING');
const errors = report.issues.filter(i => i.level === 'ERROR');
if (errors.length > 0 || warnings.length > 0) {
if (!hasWarnings) {
console.log(chalk.yellow('\n⚠️ Validation warnings found:'));
hasWarnings = true;
}
console.log(chalk.yellow(`\n ${entry.name}/spec.md:`));
for (const issue of errors) {
console.log(chalk.red(` ✗ ${issue.message}`));
}
for (const issue of warnings) {
console.log(chalk.yellow(` ⚠ ${issue.message}`));
}
}
}
} catch {
// Spec file doesn't exist, skip validation
}
}
}
if (hasWarnings) {
console.log(chalk.yellow('\nConsider fixing these issues before archiving.\n'));
}
} catch {
// No specs directory, skip validation
}
// Reset counters
this.filesChanged = 0;
this.linesAdded = 0;
@@ -82,7 +130,7 @@ export class DiffCommand {
choices
});
return answer;
return answer as string;
}
private async showDiffs(changeSpecsDir: string): Promise<void> {
+1 -1
View File
@@ -64,7 +64,7 @@ export class InitCommand {
}))
});
config.aiTools = [selectedTool];
config.aiTools = [selectedTool as string];
return config;
}
+90
View File
@@ -0,0 +1,90 @@
import { promises as fs } from 'fs';
import path from 'path';
interface ChangeInfo {
name: string;
completedTasks: number;
totalTasks: number;
}
export class ListCommand {
async execute(targetPath: string = '.'): Promise<void> {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name);
if (changeDirs.length === 0) {
console.log('No active changes found.');
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const tasksPath = path.join(changesDir, changeDir, 'tasks.md');
let completedTasks = 0;
let incompleteTasks = 0;
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
if (line.includes('- [x]')) {
completedTasks++;
} else if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
} catch {
// No tasks.md file
changes.push({
name: changeDir,
completedTasks: 0,
totalTasks: 0
});
continue;
}
changes.push({
name: changeDir,
completedTasks,
totalTasks: completedTasks + incompleteTasks
});
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Display results
console.log('Changes:');
for (const change of changes) {
const padding = ' ';
const nameWidth = Math.max(...changes.map(c => c.name.length));
const paddedName = change.name.padEnd(nameWidth);
let status: string;
if (change.totalTasks === 0) {
status = 'No tasks';
} else if (change.completedTasks === change.totalTasks) {
status = '✓ Complete';
} else {
status = `${change.completedTasks}/${change.totalTasks} tasks`;
}
console.log(`${padding}${paddedName} ${status}`);
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
interface Section {
level: number;
title: string;
content: string;
children: Section[];
}
export class MarkdownParser {
private lines: string[];
private currentLine: number;
constructor(content: string) {
this.lines = content.split('\n');
this.currentLine = 0;
}
parseSpec(name: string): Spec {
const sections = this.parseSections();
const overview = this.findSection(sections, 'Overview')?.content || '';
const requirementsSection = this.findSection(sections, 'Requirements');
if (!overview) {
throw new Error('Spec must have an Overview section');
}
if (!requirementsSection) {
throw new Error('Spec must have a Requirements section');
}
const requirements = this.parseRequirements(requirementsSection);
return {
name,
overview: overview.trim(),
requirements,
metadata: {
version: '1.0.0',
format: 'openspec',
},
};
}
parseChange(name: string): Change {
const sections = this.parseSections();
const why = this.findSection(sections, 'Why')?.content || '';
const whatChanges = this.findSection(sections, 'What Changes')?.content || '';
if (!why) {
throw new Error('Change must have a Why section');
}
if (!whatChanges) {
throw new Error('Change must have a What Changes section');
}
const deltas = this.parseDeltas(whatChanges);
return {
name,
why: why.trim(),
whatChanges: whatChanges.trim(),
deltas,
metadata: {
version: '1.0.0',
format: 'openspec-change',
},
};
}
private parseSections(): Section[] {
const sections: Section[] = [];
const stack: Section[] = [];
for (let i = 0; i < this.lines.length; i++) {
const line = this.lines[i];
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const title = headerMatch[2].trim();
const content = this.getContentUntilNextHeader(i + 1, level);
const section: Section = {
level,
title,
content,
children: [],
};
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
if (stack.length === 0) {
sections.push(section);
} else {
stack[stack.length - 1].children.push(section);
}
stack.push(section);
}
}
return sections;
}
private getContentUntilNextHeader(startLine: number, currentLevel: number): string {
const contentLines: string[] = [];
for (let i = startLine; i < this.lines.length; i++) {
const line = this.lines[i];
const headerMatch = line.match(/^(#{1,6})\s+/);
if (headerMatch && headerMatch[1].length <= currentLevel) {
break;
}
contentLines.push(line);
}
return contentLines.join('\n').trim();
}
private findSection(sections: Section[], title: string): Section | undefined {
for (const section of sections) {
if (section.title.toLowerCase() === title.toLowerCase()) {
return section;
}
const child = this.findSection(section.children, title);
if (child) {
return child;
}
}
return undefined;
}
private parseRequirements(section: Section): Requirement[] {
const requirements: Requirement[] = [];
for (const child of section.children) {
const text = child.title;
const scenarios = this.parseScenarios(child);
requirements.push({
text,
scenarios,
});
}
return requirements;
}
private parseScenarios(requirementSection: Section): Scenario[] {
const scenarios: Scenario[] = [];
for (const scenarioSection of requirementSection.children) {
// Store the raw text content of the scenario section
if (scenarioSection.content.trim()) {
scenarios.push({
rawText: scenarioSection.content
});
}
}
return scenarios;
}
private parseDeltas(content: string): Delta[] {
const deltas: Delta[] = [];
const lines = content.split('\n');
for (const line of lines) {
const deltaMatch = line.match(/^\s*-\s*\*\*([^:]+):\*\*\s*(.+)$/);
if (deltaMatch) {
const specName = deltaMatch[1].trim();
const description = deltaMatch[2].trim();
let operation: DeltaOperation = 'MODIFIED';
const lowerDesc = description.toLowerCase();
// Use word boundaries to avoid false matches (e.g., "address" matching "add")
if (/\badd(s|ed|ing)?\b/.test(lowerDesc) || /\bcreate(s|d|ing)?\b/.test(lowerDesc) || /\bnew\b/.test(lowerDesc)) {
operation = 'ADDED';
} else if (/\bremove(s|d|ing)?\b/.test(lowerDesc) || /\bdelete(s|d|ing)?\b/.test(lowerDesc)) {
operation = 'REMOVED';
}
deltas.push({
spec: specName,
operation,
description,
});
}
}
return deltas;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
import { VALIDATION_MESSAGES } from '../validation/constants.js';
export const ScenarioSchema = z.object({
rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
});
export const RequirementSchema = z.object({
text: z.string()
.min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY)
.refine(
(text) => text.includes('SHALL') || text.includes('MUST'),
VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL
),
scenarios: z.array(ScenarioSchema)
.min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS),
});
export type Scenario = z.infer<typeof ScenarioSchema>;
export type Requirement = z.infer<typeof RequirementSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod';
import { RequirementSchema } from './base.schema.js';
import {
MIN_WHY_SECTION_LENGTH,
MAX_WHY_SECTION_LENGTH,
MAX_DELTAS_PER_CHANGE,
VALIDATION_MESSAGES
} from '../validation/constants.js';
export const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED']);
export const DeltaSchema = z.object({
spec: z.string().min(1, VALIDATION_MESSAGES.DELTA_SPEC_EMPTY),
operation: DeltaOperationType,
description: z.string().min(1, VALIDATION_MESSAGES.DELTA_DESCRIPTION_EMPTY),
requirements: z.array(RequirementSchema).optional(),
});
export const ChangeSchema = z.object({
name: z.string().min(1, VALIDATION_MESSAGES.CHANGE_NAME_EMPTY),
why: z.string()
.min(MIN_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_SHORT)
.max(MAX_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_LONG),
whatChanges: z.string().min(1, VALIDATION_MESSAGES.CHANGE_WHAT_EMPTY),
deltas: z.array(DeltaSchema)
.min(1, VALIDATION_MESSAGES.CHANGE_NO_DELTAS)
.max(MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS),
metadata: z.object({
version: z.string().default('1.0.0'),
format: z.literal('openspec-change'),
sourcePath: z.string().optional(),
}).optional(),
});
export type DeltaOperation = z.infer<typeof DeltaOperationType>;
export type Delta = z.infer<typeof DeltaSchema>;
export type Change = z.infer<typeof ChangeSchema>;
+20
View File
@@ -0,0 +1,20 @@
export {
ScenarioSchema,
RequirementSchema,
type Scenario,
type Requirement,
} from './base.schema.js';
export {
SpecSchema,
type Spec,
} from './spec.schema.js';
export {
DeltaOperationType,
DeltaSchema,
ChangeSchema,
type DeltaOperation,
type Delta,
type Change,
} from './change.schema.js';
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { RequirementSchema } from './base.schema.js';
import { VALIDATION_MESSAGES } from '../validation/constants.js';
export const SpecSchema = z.object({
name: z.string().min(1, VALIDATION_MESSAGES.SPEC_NAME_EMPTY),
overview: z.string().min(1, VALIDATION_MESSAGES.SPEC_OVERVIEW_EMPTY),
requirements: z.array(RequirementSchema)
.min(1, VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS),
metadata: z.object({
version: z.string().default('1.0.0'),
format: z.literal('openspec'),
sourcePath: z.string().optional(),
}).optional(),
});
export type Spec = z.infer<typeof SpecSchema>;
+60 -15
View File
@@ -43,9 +43,9 @@ openspec/
│ │ ├── proposal.md # Why, what, impact (consolidated)
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional, for complex changes)
│ │ └── specs/ # Future state of affected specs
│ │ └── specs/ # Delta changes to specs
│ │ └── [capability]/
│ │ └── spec.md # Clean markdown (no diff syntax)
│ │ └── spec.md # Delta format (ADDED/MODIFIED/REMOVED/RENAMED)
│ └── archive/ # Completed changes (dated)
\`\`\`
@@ -94,7 +94,35 @@ Before any task:
- If adding dependencies, frameworks, or architectural patterns, document why simpler alternatives won't work
- Default to single-file implementations until proven insufficient
### 3. Creating a Change Proposal
### 3. Delta-Based Change Format
Changes use a delta format with clear sections:
\`\`\`markdown
## ADDED Requirements
### Requirement: New Feature
[Complete requirement content in structured format]
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement (header must match current spec)]
## REMOVED Requirements
### Requirement: Old Feature
**Reason for removal**: [Why removing]
**Migration path**: [How to handle existing usage]
## RENAMED Requirements
- FROM: \`### Requirement: Old Name\`
- TO: \`### Requirement: New Name\`
\`\`\`
Key rules:
- Headers are matched using \`normalize(header) = trim(header)\`
- Include complete requirements (not diffs)
- Use standard symbols in CLI output: + (added), ~ (modified), - (removed), → (renamed)
### 4. Creating a Change Proposal
When a user requests a significant change:
@@ -113,13 +141,21 @@ openspec/changes/[descriptive-name]/
- Affected specs: [list capabilities that will change]
- Affected code: [list key files/systems]
# 3. Create future state specs for ALL affected capabilities
# - Store complete spec files as they will exist after the change
# - Use clean markdown without diff syntax (+/- prefixes)
# - Include all formatting and structure of the final intended state
# 3. Create delta specs for ALL affected capabilities
# - Store only the changes (not complete future state)
# - Use sections: ## ADDED, ## MODIFIED, ## REMOVED, ## RENAMED
# - Include complete requirements in their final form
# Example spec.md content:
# ## ADDED Requirements
# ### Requirement: Password Reset
# Users SHALL be able to reset passwords via email...
#
# ## MODIFIED Requirements
# ### Requirement: User Authentication
# [Complete modified requirement with new password reset hook]
specs/
└── [capability]/
└── spec.md
└── spec.md # Contains delta sections
# 4. Create tasks.md with implementation steps
## 1. [Task Group]
@@ -130,16 +166,16 @@ specs/
[Technical decisions and trade-offs]
\`\`\`
### 4. The Change Lifecycle
### 5. The Change Lifecycle
1. **Propose** → Create change directory with all documentation
1. **Propose** → Create change directory with delta-based documentation
2. **Review** → User reviews and approves the proposal
3. **Implement** → Follow the approved tasks.md (can be multiple PRs)
4. **Deploy** → User confirms deployment
5. **Update Specs** → Sync specs/ with new reality (IF the change affects system capabilities)
5. **Update Specs** → Apply deltas to sync specs/ with new reality (IF the change affects system capabilities)
6. **Archive** → Move to \`changes/archive/YYYY-MM-DD-[name]/\`
### 5. Implementing Changes
### 6. Implementing Changes
When implementing an approved change:
1. Follow the tasks.md checklist exactly
@@ -154,7 +190,7 @@ When implementing an approved change:
- Different developers can work on different task groups
- Example: PR #1 completes tasks 1.1-1.3, PR #2 completes tasks 2.1-2.4
### 6. Updating Specs and Archiving After Deployment
### 7. Updating Specs and Archiving After Deployment
**Create a separate PR after deployment** that:
1. Moves change to \`changes/archive/YYYY-MM-DD-[name]/\`
@@ -163,7 +199,7 @@ When implementing an approved change:
This ensures changes are only archived when truly complete and deployed.
### 7. Types of Changes That Don't Require Specs
### 8. Types of Changes That Don't Require Specs
Some changes only affect development infrastructure and don't need specs:
- Initial project setup (package.json, tsconfig.json, etc.)
@@ -216,7 +252,16 @@ User: "Add password reset functionality"
You should:
1. Read specs/user-auth/spec.md
2. Check changes/ for pending auth changes
3. Create changes/add-password-reset/ with proposal
3. Create changes/add-password-reset/ with:
- proposal.md describing the change
- specs/user-auth/spec.md with:
## ADDED Requirements
### Requirement: Password Reset
[Complete requirement for password reset]
## MODIFIED Requirements
### Requirement: User Authentication
[Updated to integrate with password reset]
4. Wait for approval before implementing
\`\`\`
+32 -12
View File
@@ -1,8 +1,8 @@
import path from 'path';
import { FileSystemUtils } from '../utils/file-system.js';
import { TemplateManager } from './templates/index.js';
import { OPENSPEC_DIR_NAME, OPENSPEC_MARKERS } from './config.js';
import { OPENSPEC_DIR_NAME } from './config.js';
import { readmeTemplate } from './templates/readme-template.js';
import { ToolRegistry } from './configurators/registry.js';
export class UpdateCommand {
async execute(projectPath: string): Promise<void> {
@@ -19,17 +19,37 @@ export class UpdateCommand {
const readmePath = path.join(openspecPath, 'README.md');
await FileSystemUtils.writeFile(readmePath, readmeTemplate);
// 3. Update CLAUDE.md (marker-based)
const claudePath = path.join(resolvedProjectPath, 'CLAUDE.md');
const claudeContent = TemplateManager.getClaudeTemplate();
await FileSystemUtils.updateFileWithMarkers(
claudePath,
claudeContent,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
// 3. Update existing AI tool configuration files only
const configurators = ToolRegistry.getAll();
let updatedFiles: string[] = [];
let failedFiles: string[] = [];
for (const configurator of configurators) {
const configFilePath = path.join(resolvedProjectPath, configurator.configFileName);
// Only update if the file already exists
if (await FileSystemUtils.fileExists(configFilePath)) {
try {
await configurator.configure(resolvedProjectPath, openspecPath);
updatedFiles.push(configurator.configFileName);
} catch (error) {
failedFiles.push(configurator.configFileName);
console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
// 4. Success message (ASCII-safe)
console.log('Updated OpenSpec instructions');
const messages: string[] = ['Updated OpenSpec instructions (README.md)'];
if (updatedFiles.length > 0) {
messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`);
}
if (failedFiles.length > 0) {
messages.push(`Failed to update: ${failedFiles.join(', ')}`);
}
console.log(messages.join('\n'));
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Validation threshold constants
*/
// Minimum character lengths
export const MIN_WHY_SECTION_LENGTH = 50;
export const MIN_OVERVIEW_LENGTH = 50;
// Maximum character/item limits
export const MAX_WHY_SECTION_LENGTH = 1000;
export const MAX_REQUIREMENT_TEXT_LENGTH = 500;
export const MAX_DELTAS_PER_CHANGE = 10;
// Validation messages
export const VALIDATION_MESSAGES = {
// Required content
SCENARIO_EMPTY: 'Scenario text cannot be empty',
REQUIREMENT_EMPTY: 'Requirement text cannot be empty',
REQUIREMENT_NO_SHALL: 'Requirement must contain SHALL or MUST keyword',
REQUIREMENT_NO_SCENARIOS: 'Requirement must have at least one scenario',
SPEC_NAME_EMPTY: 'Spec name cannot be empty',
SPEC_OVERVIEW_EMPTY: 'Overview section cannot be empty',
SPEC_NO_REQUIREMENTS: 'Spec must have at least one requirement',
CHANGE_NAME_EMPTY: 'Change name cannot be empty',
CHANGE_WHY_TOO_SHORT: `Why section must be at least ${MIN_WHY_SECTION_LENGTH} characters`,
CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`,
CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty',
CHANGE_NO_DELTAS: 'Change must have at least one delta',
CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`,
DELTA_SPEC_EMPTY: 'Spec name cannot be empty',
DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',
// Warnings
OVERVIEW_TOO_BRIEF: `Overview section is too brief (less than ${MIN_OVERVIEW_LENGTH} characters)`,
REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Consider breaking it down.`,
DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
} as const;
+19
View File
@@ -0,0 +1,19 @@
export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';
export interface ValidationIssue {
level: ValidationLevel;
path: string;
message: string;
line?: number;
column?: number;
}
export interface ValidationReport {
valid: boolean;
issues: ValidationIssue[];
summary: {
errors: number;
warnings: number;
info: number;
};
}
+184
View File
@@ -0,0 +1,184 @@
import { z, ZodError } from 'zod';
import { readFileSync } from 'fs';
import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
import {
MIN_OVERVIEW_LENGTH,
MAX_REQUIREMENT_TEXT_LENGTH,
VALIDATION_MESSAGES
} from './constants.js';
export class Validator {
private strictMode: boolean;
constructor(strictMode: boolean = false) {
this.strictMode = strictMode;
}
async validateSpec(filePath: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
try {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const specName = this.extractNameFromPath(filePath);
const spec = parser.parseSpec(specName);
const result = SpecSchema.safeParse(spec);
if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
}
issues.push(...this.applySpecRules(spec, content));
} catch (error) {
issues.push({
level: 'ERROR',
path: 'file',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
return this.createReport(issues);
}
async validateChange(filePath: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
try {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const changeName = this.extractNameFromPath(filePath);
const change = parser.parseChange(changeName);
const result = ChangeSchema.safeParse(change);
if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
}
issues.push(...this.applyChangeRules(change, content));
} catch (error) {
issues.push({
level: 'ERROR',
path: 'file',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
return this.createReport(issues);
}
private convertZodErrors(error: ZodError): ValidationIssue[] {
return error.issues.map(err => ({
level: 'ERROR' as ValidationLevel,
path: err.path.join('.'),
message: err.message,
}));
}
private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
if (spec.overview.length < MIN_OVERVIEW_LENGTH) {
issues.push({
level: 'WARNING',
path: 'overview',
message: VALIDATION_MESSAGES.OVERVIEW_TOO_BRIEF,
});
}
spec.requirements.forEach((req, index) => {
if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
issues.push({
level: 'INFO',
path: `requirements[${index}]`,
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
});
}
if (req.scenarios.length === 0) {
issues.push({
level: 'WARNING',
path: `requirements[${index}].scenarios`,
message: 'Requirement has no scenarios',
});
}
});
return issues;
}
private applyChangeRules(change: Change, content: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const MIN_DELTA_DESCRIPTION_LENGTH = 10;
change.deltas.forEach((delta, index) => {
if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {
issues.push({
level: 'WARNING',
path: `deltas[${index}].description`,
message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,
});
}
if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') &&
(!delta.requirements || delta.requirements.length === 0)) {
issues.push({
level: 'WARNING',
path: `deltas[${index}].requirements`,
message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,
});
}
});
return issues;
}
private extractNameFromPath(filePath: string): string {
const parts = filePath.split('/');
// Look for the directory name after 'specs' or 'changes'
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] === 'specs' || parts[i] === 'changes') {
if (i < parts.length - 1) {
return parts[i + 1];
}
}
}
// Fallback to filename without extension if not in expected structure
const fileName = parts[parts.length - 1];
return fileName.replace('.md', '');
}
private createReport(issues: ValidationIssue[]): ValidationReport {
const errors = issues.filter(i => i.level === 'ERROR').length;
const warnings = issues.filter(i => i.level === 'WARNING').length;
const info = issues.filter(i => i.level === 'INFO').length;
const valid = this.strictMode
? errors === 0 && warnings === 0
: errors === 0;
return {
valid,
issues,
summary: {
errors,
warnings,
info,
},
};
}
isValid(report: ValidationReport): boolean {
return report.valid;
}
}
+365
View File
@@ -0,0 +1,365 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ArchiveCommand } from '../../src/core/archive.js';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Mock @inquirer/prompts
vi.mock('@inquirer/prompts', () => ({
select: vi.fn(),
confirm: vi.fn()
}));
describe('ArchiveCommand', () => {
let tempDir: string;
let archiveCommand: ArchiveCommand;
const originalConsoleLog = console.log;
beforeEach(async () => {
// Create temp directory
tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
// Change to temp directory
process.chdir(tempDir);
// Create OpenSpec structure
const openspecDir = path.join(tempDir, 'openspec');
await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true });
await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true });
await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true });
// Suppress console.log during tests
console.log = vi.fn();
archiveCommand = new ArchiveCommand();
});
afterEach(async () => {
// Restore console.log
console.log = originalConsoleLog;
// Clear mocks
vi.clearAllMocks();
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});
describe('execute', () => {
it('should archive a change successfully', async () => {
// Create a test change
const changeName = 'test-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with completed tasks
const tasksContent = '- [x] Task 1\n- [x] Task 2';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Execute archive with --yes flag
await archiveCommand.execute(changeName, { yes: true });
// Check that change was moved to archive
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
// Verify original change directory no longer exists
await expect(fs.access(changeDir)).rejects.toThrow();
});
it('should warn about incomplete tasks', async () => {
const changeName = 'incomplete-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with incomplete tasks
const tasksContent = '- [x] Task 1\n- [ ] Task 2\n- [ ] Task 3';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Execute archive with --yes flag
await archiveCommand.execute(changeName, { yes: true });
// Verify warning was logged
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Warning: 2 incomplete task(s) found')
);
});
it('should update specs when archiving', async () => {
const changeName = 'spec-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create valid spec in change
const specContent = `# Test Capability Spec
## Overview
This is a test capability specification for testing purposes.
## Requirements
### The system SHALL provide test capability
#### Scenario: Basic test
Given a test condition
When an action occurs
Then expected result happens`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive with --yes flag and skip validation for speed
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify spec was copied to main specs
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
const copiedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(copiedContent).toBe(specContent);
});
it('should throw error if change does not exist', async () => {
await expect(
archiveCommand.execute('non-existent-change', { yes: true })
).rejects.toThrow("Change 'non-existent-change' not found.");
});
it('should throw error if archive already exists', async () => {
const changeName = 'duplicate-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create existing archive with same date
const date = new Date().toISOString().split('T')[0];
const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`);
await fs.mkdir(archivePath, { recursive: true });
// Try to archive
await expect(
archiveCommand.execute(changeName, { yes: true })
).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`);
});
it('should handle changes without tasks.md', async () => {
const changeName = 'no-tasks-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Execute archive without tasks.md
await archiveCommand.execute(changeName, { yes: true });
// Should complete without warnings
expect(console.log).not.toHaveBeenCalledWith(
expect.stringContaining('incomplete task(s)')
);
// Verify change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
});
it('should handle changes without specs', async () => {
const changeName = 'no-specs-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Execute archive without specs
await archiveCommand.execute(changeName, { yes: true });
// Should complete without spec updates
expect(console.log).not.toHaveBeenCalledWith(
expect.stringContaining('Specs to update')
);
// Verify change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
});
it('should skip spec updates when --skip-specs flag is used', async () => {
const changeName = 'skip-specs-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create spec in change
const specContent = '# Test Capability Spec\n\nTest content';
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive with --skip-specs flag and noValidate to skip validation
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, noValidate: true });
// Verify skip message was logged
expect(console.log).toHaveBeenCalledWith(
'Skipping spec updates (--skip-specs flag provided).'
);
// Verify spec was NOT copied to main specs
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
await expect(fs.access(mainSpecPath)).rejects.toThrow();
// Verify change was still archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
});
it('should proceed with archive when user declines spec updates', async () => {
const { confirm } = await import('@inquirer/prompts');
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
const changeName = 'decline-specs-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create valid spec in change
const specContent = `# Test Capability Spec
## Overview
This is a test capability specification.
## Requirements
### The system SHALL provide test capability
#### Scenario: Basic test
Given a test condition
When an action occurs
Then expected result happens`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Mock confirm to return false (decline spec updates)
mockConfirm.mockResolvedValueOnce(false);
// Execute archive without --yes flag
await archiveCommand.execute(changeName);
// Verify user was prompted about specs
expect(mockConfirm).toHaveBeenCalledWith({
message: 'Proceed with spec updates?',
default: true
});
// Verify skip message was logged
expect(console.log).toHaveBeenCalledWith(
'Skipping spec updates. Proceeding with archive.'
);
// Verify spec was NOT copied to main specs
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
await expect(fs.access(mainSpecPath)).rejects.toThrow();
// Verify change was still archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
});
});
describe('error handling', () => {
it('should throw error when openspec directory does not exist', async () => {
// Remove openspec directory
await fs.rm(path.join(tempDir, 'openspec'), { recursive: true });
await expect(
archiveCommand.execute('any-change', { yes: true })
).rejects.toThrow("No OpenSpec changes directory found. Run 'openspec init' first.");
});
});
describe('interactive mode', () => {
it('should use select prompt for change selection', async () => {
const { select } = await import('@inquirer/prompts');
const mockSelect = select as unknown as ReturnType<typeof vi.fn>;
// Create test changes
const change1 = 'feature-a';
const change2 = 'feature-b';
await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true });
await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true });
// Mock select to return first change
mockSelect.mockResolvedValueOnce(change1);
// Execute without change name
await archiveCommand.execute(undefined, { yes: true });
// Verify select was called with correct options
expect(mockSelect).toHaveBeenCalledWith({
message: 'Select a change to archive',
choices: [
{ name: change1, value: change1 },
{ name: change2, value: change2 }
]
});
// Verify the selected change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives[0]).toContain(change1);
});
it('should use confirm prompt for task warnings', async () => {
const { confirm } = await import('@inquirer/prompts');
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
const changeName = 'incomplete-interactive';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with incomplete tasks
const tasksContent = '- [ ] Task 1';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Mock confirm to return true (proceed)
mockConfirm.mockResolvedValueOnce(true);
// Execute without --yes flag
await archiveCommand.execute(changeName);
// Verify confirm was called
expect(mockConfirm).toHaveBeenCalledWith({
message: 'Warning: 1 incomplete task(s) found. Continue?',
default: false
});
});
it('should cancel when user declines task warning', async () => {
const { confirm } = await import('@inquirer/prompts');
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
const changeName = 'cancel-test';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with incomplete tasks
const tasksContent = '- [ ] Task 1';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Mock confirm to return false (cancel) for validation skip
mockConfirm.mockResolvedValueOnce(false);
// Mock another false for task warning
mockConfirm.mockResolvedValueOnce(false);
// Execute without --yes flag but skip validation to test task warning
await archiveCommand.execute(changeName, { noValidate: true });
// Verify archive was cancelled
expect(console.log).toHaveBeenCalledWith('Archive cancelled.');
// Verify change was not archived
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { JsonConverter } from '../../../src/core/converters/json-converter.js';
describe('JsonConverter', () => {
const testDir = path.join(process.cwd(), 'test-json-converter-tmp');
const converter = new JsonConverter();
beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('convertSpecToJson', () => {
it('should convert a spec to JSON format', async () => {
const specContent = `# User Authentication Spec
## Overview
This specification defines the requirements for user authentication.
## Requirements
### The system SHALL provide secure user authentication
Users need to be able to log in securely.
#### Scenario: Successful login
Given a user with valid credentials
When they submit the login form
Then they are authenticated`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const json = converter.convertSpecToJson(specPath);
const parsed = JSON.parse(json);
expect(parsed.name).toBe('spec');
expect(parsed.overview).toContain('user authentication');
expect(parsed.requirements).toHaveLength(1);
expect(parsed.requirements[0].scenarios).toHaveLength(1);
expect(parsed.metadata).toBeDefined();
expect(parsed.metadata.format).toBe('openspec');
expect(parsed.metadata.sourcePath).toBe(specPath);
});
it('should extract spec name from directory structure', async () => {
const specsDir = path.join(testDir, 'specs', 'user-auth');
await fs.mkdir(specsDir, { recursive: true });
const specContent = `# User Auth
## Overview
Auth spec overview
## Requirements
### The system SHALL authenticate users
#### Scenario: Login
Given a user
When they login
Then authenticated`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const json = converter.convertSpecToJson(specPath);
const parsed = JSON.parse(json);
expect(parsed.name).toBe('user-auth');
});
});
describe('convertChangeToJson', () => {
it('should convert a change to JSON format', async () => {
const changeContent = `# Add User Authentication
## Why
We need to implement user authentication to secure the application and protect user data from unauthorized access.
## What Changes
- **user-auth:** Add new user authentication specification
- **api-endpoints:** Modify to include authentication endpoints`;
const changePath = path.join(testDir, 'change.md');
await fs.writeFile(changePath, changeContent);
const json = converter.convertChangeToJson(changePath);
const parsed = JSON.parse(json);
expect(parsed.name).toBe('change');
expect(parsed.why).toContain('secure the application');
expect(parsed.deltas).toHaveLength(2);
expect(parsed.deltas[0].spec).toBe('user-auth');
expect(parsed.deltas[0].operation).toBe('ADDED');
expect(parsed.metadata).toBeDefined();
expect(parsed.metadata.format).toBe('openspec-change');
expect(parsed.metadata.sourcePath).toBe(changePath);
});
it('should extract change name from directory structure', async () => {
const changesDir = path.join(testDir, 'changes', 'add-auth');
await fs.mkdir(changesDir, { recursive: true });
const changeContent = `# Add Auth
## Why
We need authentication for security reasons and to protect user data properly.
## What Changes
- **auth:** Add authentication`;
const changePath = path.join(changesDir, 'proposal.md');
await fs.writeFile(changePath, changeContent);
const json = converter.convertChangeToJson(changePath);
const parsed = JSON.parse(json);
expect(parsed.name).toBe('add-auth');
});
});
describe('JSON formatting', () => {
it('should produce properly formatted JSON with indentation', async () => {
const specContent = `# Test
## Overview
Test overview
## Requirements
### The system SHALL test
#### Scenario: Test
Given test
When action
Then result`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const json = converter.convertSpecToJson(specPath);
// Check for proper indentation (2 spaces)
expect(json).toContain(' "name"');
expect(json).toContain(' "overview"');
expect(json).toContain(' "requirements"');
// Check it's valid JSON
expect(() => JSON.parse(json)).not.toThrow();
});
it('should handle special characters in content', async () => {
const specContent = `# Test
## Overview
This has "quotes" and \\ backslashes and
newlines
## Requirements
### The system SHALL handle "special" characters
#### Scenario: Special chars
Given a string with "quotes"
When processing \\ backslash
Then handle correctly`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const json = converter.convertSpecToJson(specPath);
const parsed = JSON.parse(json);
expect(parsed.overview).toContain('"quotes"');
expect(parsed.overview).toContain('\\');
expect(parsed.requirements[0].text).toContain('"special"');
});
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { ListCommand } from '../../src/core/list.js';
describe('ListCommand', () => {
let tempDir: string;
let originalLog: typeof console.log;
let logOutput: string[] = [];
beforeEach(async () => {
// Create temp directory
tempDir = path.join(os.tmpdir(), `openspec-list-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
// Mock console.log to capture output
originalLog = console.log;
console.log = (...args: any[]) => {
logOutput.push(args.join(' '));
};
logOutput = [];
});
afterEach(async () => {
// Restore console.log
console.log = originalLog;
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('execute', () => {
it('should handle missing openspec/changes directory', async () => {
const listCommand = new ListCommand();
await expect(listCommand.execute(tempDir)).rejects.toThrow(
"No OpenSpec changes directory found. Run 'openspec init' first."
);
});
it('should handle empty changes directory', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toEqual(['No active changes found.']);
});
it('should exclude archive directory', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true });
// Create tasks.md with some tasks
await fs.writeFile(
path.join(changesDir, 'my-change', 'tasks.md'),
'- [x] Task 1\n- [ ] Task 2\n'
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('my-change'))).toBe(true);
expect(logOutput.some(line => line.includes('archive'))).toBe(false);
});
it('should count tasks correctly', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'test-change', 'tasks.md'),
`# Tasks
- [x] Completed task 1
- [x] Completed task 2
- [ ] Incomplete task 1
- [ ] Incomplete task 2
- [ ] Incomplete task 3
Regular text that should be ignored
`
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('2/5 tasks'))).toBe(true);
});
it('should show complete status for fully completed changes', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n'
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true);
});
it('should handle changes without tasks.md', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
it('should sort changes alphabetically', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'zebra'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'alpha'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'middle'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
const changeLines = logOutput.filter(line =>
line.includes('alpha') || line.includes('middle') || line.includes('zebra')
);
expect(changeLines[0]).toContain('alpha');
expect(changeLines[1]).toContain('middle');
expect(changeLines[2]).toContain('zebra');
});
it('should handle multiple changes with various states', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
// Complete change
await fs.mkdir(path.join(changesDir, 'completed'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed', 'tasks.md'),
'- [x] Task 1\n- [x] Task 2\n'
);
// Partial change
await fs.mkdir(path.join(changesDir, 'partial'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'partial', 'tasks.md'),
'- [x] Done\n- [ ] Not done\n- [ ] Also not done\n'
);
// No tasks
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('completed') && line.includes('✓ Complete'))).toBe(true);
expect(logOutput.some(line => line.includes('partial') && line.includes('1/3 tasks'))).toBe(true);
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
});
});
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect } from 'vitest';
import { MarkdownParser } from '../../../src/core/parsers/markdown-parser.js';
describe('MarkdownParser', () => {
describe('parseSpec', () => {
it('should parse a valid spec', () => {
const content = `# User Authentication Spec
## Overview
This specification defines the requirements for user authentication.
## Requirements
### The system SHALL provide secure user authentication
Users need to be able to log in securely.
#### Scenario: Successful login
Given a user with valid credentials
When they submit the login form
Then they are authenticated
### The system SHALL handle invalid login attempts
The system must handle incorrect credentials.
#### Scenario: Invalid credentials
Given a user with invalid credentials
When they submit the login form
Then they see an error message`;
const parser = new MarkdownParser(content);
const spec = parser.parseSpec('user-auth');
expect(spec.name).toBe('user-auth');
expect(spec.overview).toContain('requirements for user authentication');
expect(spec.requirements).toHaveLength(2);
const firstReq = spec.requirements[0];
expect(firstReq.text).toContain('SHALL provide secure user authentication');
expect(firstReq.scenarios).toHaveLength(1);
const scenario = firstReq.scenarios[0];
expect(scenario.rawText).toContain('Given a user with valid credentials');
expect(scenario.rawText).toContain('When they submit the login form');
expect(scenario.rawText).toContain('Then they are authenticated');
});
it('should handle multi-line scenarios', () => {
const content = `# Test Spec
## Overview
Test overview
## Requirements
### The system SHALL handle complex scenarios
#### Scenario: Multi-line scenario
Given a user with valid credentials
and the user has admin privileges
and the system is in maintenance mode
When they attempt to login
and provide their MFA token
Then they are authenticated
and redirected to admin dashboard
and see a maintenance warning`;
const parser = new MarkdownParser(content);
const spec = parser.parseSpec('test');
const scenario = spec.requirements[0].scenarios[0];
expect(scenario.rawText).toContain('Given a user with valid credentials');
expect(scenario.rawText).toContain('and the user has admin privileges');
expect(scenario.rawText).toContain('When they attempt to login');
expect(scenario.rawText).toContain('and provide their MFA token');
expect(scenario.rawText).toContain('Then they are authenticated');
expect(scenario.rawText).toContain('and see a maintenance warning');
});
it('should throw error for missing overview', () => {
const content = `# Test Spec
## Requirements
### The system SHALL do something
#### Scenario: Test
Given test
When action
Then result`;
const parser = new MarkdownParser(content);
expect(() => parser.parseSpec('test')).toThrow('must have an Overview section');
});
it('should throw error for missing requirements', () => {
const content = `# Test Spec
## Overview
This is a test spec`;
const parser = new MarkdownParser(content);
expect(() => parser.parseSpec('test')).toThrow('must have a Requirements section');
});
});
describe('parseChange', () => {
it('should parse a valid change', () => {
const content = `# Add User Authentication
## Why
We need to implement user authentication to secure the application and protect user data from unauthorized access.
## What Changes
- **user-auth:** Add new user authentication specification
- **api-endpoints:** Modify to include authentication endpoints
- **database:** Remove old session management tables`;
const parser = new MarkdownParser(content);
const change = parser.parseChange('add-user-auth');
expect(change.name).toBe('add-user-auth');
expect(change.why).toContain('secure the application');
expect(change.whatChanges).toContain('user-auth');
expect(change.deltas).toHaveLength(3);
expect(change.deltas[0].spec).toBe('user-auth');
expect(change.deltas[0].operation).toBe('ADDED');
expect(change.deltas[0].description).toContain('Add new user authentication');
expect(change.deltas[1].spec).toBe('api-endpoints');
expect(change.deltas[1].operation).toBe('MODIFIED');
expect(change.deltas[2].spec).toBe('database');
expect(change.deltas[2].operation).toBe('REMOVED');
});
it('should throw error for missing why section', () => {
const content = `# Test Change
## What Changes
- **test:** Add test`;
const parser = new MarkdownParser(content);
expect(() => parser.parseChange('test')).toThrow('must have a Why section');
});
it('should throw error for missing what changes section', () => {
const content = `# Test Change
## Why
Because we need it`;
const parser = new MarkdownParser(content);
expect(() => parser.parseChange('test')).toThrow('must have a What Changes section');
});
it('should handle changes without deltas', () => {
const content = `# Test Change
## Why
We need to make some changes for important reasons that justify this work.
## What Changes
Some general description of changes without specific deltas`;
const parser = new MarkdownParser(content);
const change = parser.parseChange('test');
expect(change.deltas).toHaveLength(0);
});
});
describe('section parsing', () => {
it('should handle nested sections correctly', () => {
const content = `# Test Spec
## Overview
This is the overview section for testing nested sections.
## Requirements
### The system SHALL handle nested sections
#### Scenario: Test nested
Given a nested structure
When parsing sections
Then handle correctly
### Another requirement SHALL work
#### Scenario: Another test
Given another test
When running
Then success`;
const parser = new MarkdownParser(content);
const spec = parser.parseSpec('test');
// Should find the correct sections at different levels
expect(spec).toBeDefined();
expect(spec.overview).toContain('testing nested sections');
expect(spec.requirements).toHaveLength(2);
});
it('should preserve content between headers', () => {
const content = `# Test
## Overview
This is the overview.
It has multiple lines.
Some more content here.
## Requirements
### Requirement 1
Content for requirement 1`;
const parser = new MarkdownParser(content);
const spec = parser.parseSpec('test');
expect(spec.overview).toContain('multiple lines');
expect(spec.overview).toContain('more content');
});
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { UpdateCommand } from '../../src/core/update.js';
import { FileSystemUtils } from '../../src/utils/file-system.js';
import { ToolRegistry } from '../../src/core/configurators/registry.js';
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
describe('UpdateCommand', () => {
let testDir: string;
let updateCommand: UpdateCommand;
beforeEach(async () => {
// Create a temporary test directory
testDir = path.join(os.tmpdir(), `openspec-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
// Create openspec directory
const openspecDir = path.join(testDir, 'openspec');
await fs.mkdir(openspecDir, { recursive: true });
updateCommand = new UpdateCommand();
});
afterEach(async () => {
// Clean up test directory
await fs.rm(testDir, { recursive: true, force: true });
});
it('should update only existing CLAUDE.md file', async () => {
// Create CLAUDE.md file with initial content
const claudePath = path.join(testDir, 'CLAUDE.md');
const initialContent = `# Project Instructions
Some existing content here.
<!-- OPENSPEC:START -->
Old OpenSpec content
<!-- OPENSPEC:END -->
More content after.`;
await fs.writeFile(claudePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
// Execute update command
await updateCommand.execute(testDir);
// Check that CLAUDE.md was updated
const updatedContent = await fs.readFile(claudePath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('This project uses OpenSpec');
expect(updatedContent).toContain('Some existing content here');
expect(updatedContent).toContain('More content after');
// Check console output
expect(consoleSpy).toHaveBeenCalledWith(
'Updated OpenSpec instructions (README.md)\nUpdated AI tool files: CLAUDE.md'
);
consoleSpy.mockRestore();
});
it('should not create CLAUDE.md if it does not exist', async () => {
// Ensure CLAUDE.md does not exist
const claudePath = path.join(testDir, 'CLAUDE.md');
// Execute update command
await updateCommand.execute(testDir);
// Check that CLAUDE.md was not created
const fileExists = await FileSystemUtils.fileExists(claudePath);
expect(fileExists).toBe(false);
});
it('should handle no AI tool files present', async () => {
// Execute update command with no AI tool files
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
// Should only update OpenSpec instructions
expect(consoleSpy).toHaveBeenCalledWith('Updated OpenSpec instructions (README.md)');
consoleSpy.mockRestore();
});
it('should update multiple AI tool files if present', async () => {
// TODO: When additional configurators are added (Cursor, Aider, etc.),
// enhance this test to create multiple AI tool files and verify
// that all existing files are updated in a single operation.
// For now, we test with just CLAUDE.md.
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
// Should report updating with new format
expect(consoleSpy).toHaveBeenCalledWith(
'Updated OpenSpec instructions (README.md)\nUpdated AI tool files: CLAUDE.md'
);
consoleSpy.mockRestore();
});
it('should never create new AI tool files', async () => {
// Get all configurators
const configurators = ToolRegistry.getAll();
// Execute update command
await updateCommand.execute(testDir);
// Check that no new AI tool files were created
for (const configurator of configurators) {
const configPath = path.join(testDir, configurator.configFileName);
const fileExists = await FileSystemUtils.fileExists(configPath);
expect(fileExists).toBe(false);
}
});
it('should update README.md in openspec directory', async () => {
// Execute update command
await updateCommand.execute(testDir);
// Check that README.md was created/updated
const readmePath = path.join(testDir, 'openspec', 'README.md');
const fileExists = await FileSystemUtils.fileExists(readmePath);
expect(fileExists).toBe(true);
const content = await fs.readFile(readmePath, 'utf-8');
expect(content).toContain('# OpenSpec Instructions');
});
it('should throw error if openspec directory does not exist', async () => {
// Remove openspec directory
await fs.rm(path.join(testDir, 'openspec'), { recursive: true, force: true });
// Execute update command and expect error
await expect(updateCommand.execute(testDir)).rejects.toThrow(
"No OpenSpec directory found. Run 'openspec init' first."
);
});
it('should handle configurator errors gracefully', async () => {
// Create CLAUDE.md file but make it read-only to cause an error
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
await fs.chmod(claudePath, 0o444); // Read-only
const consoleSpy = vi.spyOn(console, 'log');
const errorSpy = vi.spyOn(console, 'error');
// Execute update command - should not throw
await updateCommand.execute(testDir);
// Should report the failure
expect(errorSpy).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
'Updated OpenSpec instructions (README.md)\nFailed to update: CLAUDE.md'
);
// Restore permissions for cleanup
await fs.chmod(claudePath, 0o644);
consoleSpy.mockRestore();
errorSpy.mockRestore();
});
});
+341
View File
@@ -0,0 +1,341 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { Validator } from '../../src/core/validation/validator.js';
import {
ScenarioSchema,
RequirementSchema,
SpecSchema,
ChangeSchema,
DeltaSchema
} from '../../src/core/schemas/index.js';
describe('Validation Schemas', () => {
describe('ScenarioSchema', () => {
it('should validate a valid scenario', () => {
const scenario = {
rawText: 'Given a user is logged in\nWhen they click logout\nThen they are redirected to login page',
};
const result = ScenarioSchema.safeParse(scenario);
expect(result.success).toBe(true);
});
it('should reject scenario with empty text', () => {
const scenario = {
rawText: '',
};
const result = ScenarioSchema.safeParse(scenario);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Scenario text cannot be empty');
}
});
});
describe('RequirementSchema', () => {
it('should validate a valid requirement', () => {
const requirement = {
text: 'The system SHALL provide user authentication',
scenarios: [
{
rawText: 'Given a user with valid credentials\nWhen they submit the login form\nThen they are authenticated',
},
],
};
const result = RequirementSchema.safeParse(requirement);
expect(result.success).toBe(true);
});
it('should reject requirement without SHALL or MUST', () => {
const requirement = {
text: 'The system provides user authentication',
scenarios: [
{
rawText: 'Given a user\nWhen they login\nThen authenticated',
},
],
};
const result = RequirementSchema.safeParse(requirement);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Requirement must contain SHALL or MUST keyword');
}
});
it('should reject requirement without scenarios', () => {
const requirement = {
text: 'The system SHALL provide user authentication',
scenarios: [],
};
const result = RequirementSchema.safeParse(requirement);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Requirement must have at least one scenario');
}
});
});
describe('SpecSchema', () => {
it('should validate a valid spec', () => {
const spec = {
name: 'user-auth',
overview: 'This spec defines user authentication requirements',
requirements: [
{
text: 'The system SHALL provide user authentication',
scenarios: [
{
rawText: 'Given a user with valid credentials\nWhen they submit the login form\nThen they are authenticated',
},
],
},
],
};
const result = SpecSchema.safeParse(spec);
expect(result.success).toBe(true);
});
it('should reject spec without requirements', () => {
const spec = {
name: 'user-auth',
overview: 'This spec defines user authentication requirements',
requirements: [],
};
const result = SpecSchema.safeParse(spec);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Spec must have at least one requirement');
}
});
});
describe('ChangeSchema', () => {
it('should validate a valid change', () => {
const change = {
name: 'add-user-auth',
why: 'We need user authentication to secure the application and protect user data',
whatChanges: 'Add authentication module with login and logout capabilities',
deltas: [
{
spec: 'user-auth',
operation: 'ADDED',
description: 'Add new user authentication spec',
},
],
};
const result = ChangeSchema.safeParse(change);
expect(result.success).toBe(true);
});
it('should reject change with short why section', () => {
const change = {
name: 'add-user-auth',
why: 'Need auth',
whatChanges: 'Add authentication',
deltas: [
{
spec: 'user-auth',
operation: 'ADDED',
description: 'Add auth',
},
],
};
const result = ChangeSchema.safeParse(change);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Why section must be at least 50 characters');
}
});
it('should warn about too many deltas', () => {
const deltas = Array.from({ length: 11 }, (_, i) => ({
spec: `spec-${i}`,
operation: 'ADDED' as const,
description: `Add spec ${i}`,
}));
const change = {
name: 'massive-change',
why: 'This is a massive change that affects many parts of the system',
whatChanges: 'Update everything',
deltas,
};
const result = ChangeSchema.safeParse(change);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Consider splitting changes with more than 10 deltas');
}
});
});
});
describe('Validator', () => {
const testDir = path.join(process.cwd(), 'test-validation-tmp');
beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('validateSpec', () => {
it('should validate a valid spec file', async () => {
const specContent = `# User Authentication Spec
## Overview
This specification defines the requirements for user authentication in the system.
## Requirements
### The system SHALL provide secure user authentication
Users need to be able to log in and out of the system securely.
#### Scenario: Successful login
Given a user with valid credentials
When they submit the login form
Then they are authenticated and redirected to the dashboard
### The system SHALL handle invalid login attempts
The system must gracefully handle incorrect credentials.
#### Scenario: Invalid credentials
Given a user with invalid credentials
When they submit the login form
Then they see an error message`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const validator = new Validator();
const report = await validator.validateSpec(specPath);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
});
it('should detect missing overview section', async () => {
const specContent = `# User Authentication Spec
## Requirements
### The system SHALL provide secure user authentication
#### Scenario: Login
Given a user
When they login
Then authenticated`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const validator = new Validator();
const report = await validator.validateSpec(specPath);
expect(report.valid).toBe(false);
expect(report.summary.errors).toBeGreaterThan(0);
expect(report.issues.some(i => i.message.includes('Overview'))).toBe(true);
});
});
describe('validateChange', () => {
it('should validate a valid change file', async () => {
const changeContent = `# Add User Authentication
## Why
We need to implement user authentication to secure the application and protect user data from unauthorized access.
## What Changes
- **user-auth:** Add new user authentication specification
- **api-endpoints:** Modify to include auth endpoints`;
const changePath = path.join(testDir, 'change.md');
await fs.writeFile(changePath, changeContent);
const validator = new Validator();
const report = await validator.validateChange(changePath);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
});
it('should detect missing why section', async () => {
const changeContent = `# Add User Authentication
## What Changes
- **user-auth:** Add new user authentication specification`;
const changePath = path.join(testDir, 'change.md');
await fs.writeFile(changePath, changeContent);
const validator = new Validator();
const report = await validator.validateChange(changePath);
expect(report.valid).toBe(false);
expect(report.summary.errors).toBeGreaterThan(0);
expect(report.issues.some(i => i.message.includes('Why'))).toBe(true);
});
});
describe('strict mode', () => {
it('should fail on warnings in strict mode', async () => {
const specContent = `# Test Spec
## Overview
Brief overview
## Requirements
### The system SHALL do something
#### Scenario: Test
Given test
When action
Then result`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const validator = new Validator(true); // strict mode
const report = await validator.validateSpec(specPath);
expect(report.valid).toBe(false); // Should fail due to brief overview warning
});
it('should pass warnings in non-strict mode', async () => {
const specContent = `# Test Spec
## Overview
Brief overview
## Requirements
### The system SHALL do something
#### Scenario: Test
Given test
When action
Then result`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const validator = new Validator(false); // non-strict mode
const report = await validator.validateSpec(specPath);
expect(report.valid).toBe(true); // Should pass despite warnings
expect(report.summary.warnings).toBeGreaterThan(0);
});
});
});