Compare commits

..
Author SHA1 Message Date
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
12 changed files with 643 additions and 0 deletions
@@ -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
- [ ] 1.1 Create `src/core/list.ts` with list logic
- [ ] 1.1.1 Implement directory scanning (exclude archive/)
- [ ] 1.1.2 Implement task counting from tasks.md files
- [ ] 1.1.3 Format output as simple table
- [ ] 1.2 Add list command to CLI in `src/cli/index.ts`
- [ ] 1.2.1 Register `openspec list` command
- [ ] 1.2.2 Connect to list.ts implementation
## 2. Error Handling
- [ ] 2.1 Handle missing openspec/changes/ directory
- [ ] 2.2 Handle changes without tasks.md files
- [ ] 2.3 Handle empty changes directory
## 3. Testing
- [ ] 3.1 Add tests for list functionality
- [ ] 3.1.1 Test with multiple changes
- [ ] 3.1.2 Test with completed changes
- [ ] 3.1.3 Test with no changes
- [ ] 3.1.4 Test error conditions
## 4. Documentation
- [ ] 4.1 Update CLI help text with list command
- [ ] 4.2 Add list command to README if applicable
@@ -0,0 +1,15 @@
# Add @requirement Markers for Requirement Identification
## Why
Specs contain WHEN/THEN patterns that define system requirements, but extracting these programmatically requires brittle regex parsing that may miss edge cases or break with formatting changes.
## What Changes
- Define @requirement marker convention for identifying key requirements in specs
- Each marker includes a brief identifier (e.g., @requirement user-register)
- Markers appear directly before their WHEN/THEN blocks
- Document convention in openspec-conventions spec
## Impact
- Affected specs: openspec-conventions (new)
- Affected code: None initially - enables future tooling
- Breaking changes: None - additive convention only
@@ -0,0 +1,219 @@
# 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]/
```
## 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
## Requirement Markers
### Marker Syntax
@requirement marker-syntax
WHEN writing a requirement in a spec
THEN prefix it with @requirement followed by a brief kebab-case identifier
AND place the marker on the line immediately before the WHEN statement
@requirement marker-identifier
WHEN choosing an identifier for @requirement
THEN use kebab-case (lowercase with hyphens)
AND keep it brief but descriptive (2-4 words)
AND ensure it's unique within the spec
@requirement marker-placement
WHEN adding @requirement markers to a spec
THEN place them in the ## Behavior or ## Behaviors section
AND ensure each WHEN/THEN block has exactly one marker
AND maintain a blank line after each THEN block for readability
### Examples
@requirement valid-marker-example
WHEN a spec includes properly formatted markers
THEN tools can extract and identify requirements programmatically
AND the spec remains human-readable
Example of correct usage:
```markdown
## Behavior
@requirement user-register
WHEN user registers with valid email
THEN create account and send confirmation
@requirement user-login
WHEN user logs in with correct credentials
THEN return JWT token with user data
@requirement invalid-credentials
WHEN user provides invalid credentials
THEN return 401 unauthorized error
```
@requirement invalid-marker-detection
WHEN a requirement lacks an @requirement marker
THEN tools should gracefully skip it
AND optionally warn about unmarked requirements
### Edge Cases
@requirement multiline-when-then
WHEN a WHEN or THEN clause spans multiple lines
THEN the @requirement marker still goes on the line before WHEN
AND the entire block is considered part of that requirement
@requirement multiple-then-clauses
WHEN a requirement has multiple THEN clauses using AND
THEN treat them as part of the same requirement
AND use a single @requirement marker for the entire block
@requirement nested-conditions
WHEN requirements have nested conditions or complex logic
THEN keep the @requirement marker simple
AND let the WHEN/THEN content contain the complexity
## Spec Structure
@requirement spec-file-location
WHEN creating a spec file
THEN place it in openspec/specs/[capability-name]/spec.md
AND use kebab-case for the capability name
@requirement spec-sections
WHEN structuring a spec
THEN include these sections in order:
- # [Capability Name] Specification
- ## Purpose (brief description)
- ## Behavior or ## Behaviors (with @requirement markers)
- ## Examples (optional, for complex requirements)
## Benefits of Requirement Markers
@requirement tooling-extraction
WHEN tools need to extract requirements from specs
THEN they can parse @requirement markers reliably
AND avoid complex regex patterns for WHEN/THEN extraction
@requirement requirement-counting
WHEN displaying change summaries
THEN tools can count requirements by counting @requirement markers
AND show accurate requirement counts per spec
@requirement requirement-referencing
WHEN documenting or discussing specific requirements
THEN use the @requirement identifier for clear reference
AND maintain consistency across documentation
## 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
@@ -0,0 +1,24 @@
# Implementation Tasks
## 1. Define Convention
- [ ] 1.1 Document @requirement marker syntax
- [ ] 1.2 Define identifier naming guidelines
- [ ] 1.3 Specify marker placement rules
- [ ] 1.4 Add examples of proper usage
## 2. Create Specification
- [ ] 2.1 Write openspec-conventions spec
- [ ] 2.2 Include requirement marker section
- [ ] 2.3 Add good and bad examples
- [ ] 2.4 Document edge cases
## 3. Update Existing Specs
- [ ] 3.1 Add @requirement markers to cli-init spec
- [ ] 3.2 Add @requirement markers to cli-update spec
- [ ] 3.3 Add @requirement markers to cli-view spec
- [ ] 3.4 Review and update any other existing specs
## 4. Documentation
- [ ] 4.1 Update README with marker convention
- [ ] 4.2 Add marker usage to CLAUDE.md
- [ ] 4.3 Create examples for AI assistants
+15
View File
@@ -5,6 +5,7 @@ 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';
const program = new Command();
@@ -75,4 +76,18 @@ 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.parse();
+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}`);
}
}
}
+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);
});
});
});