Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale acdce5b2ba fix: validate change name format to prevent path traversal
Add validation in validateChangeExists() to ensure --change parameter
is a valid kebab-case ID before constructing file paths. This prevents
path traversal attacks like --change "../foo" or --change "/etc/passwd".

- Reuses existing validateChangeName() from change-utils.ts
- Adds 3 tests for path traversal, absolute paths, and slashes
2025-12-29 16:49:09 +11:00
Tabish Bidiwale 4f47bd2f37 feat: unify change state model for scaffolded changes
- Update artifact workflow commands to work with scaffolded changes
- Add draft changes section to dashboard view
- Fix completed changes to require tasks.total > 0
- Archive unify-change-state-model change
2025-12-29 16:29:47 +11:00
Tabish Bidiwale 9eb22410a9 chore: archive add-artifact-workflow-cli change
- Move change to archive/2025-12-28-add-artifact-workflow-cli
- Create cli-artifact-workflow spec
2025-12-29 01:30:57 +11:00
Tabish Bidiwale f8747203a7 test: update test to match new specs glob pattern 2025-12-29 01:05:17 +11:00
Tabish Bidiwale 17016d5eaa fix: update specs glob to match nested directory structure
The schema used specs/*.md but specs are stored as specs/<capability>/spec.md.
Updated to specs/**/*.md so openspec status/next correctly detect spec completion.
2025-12-29 00:43:55 +11:00
Tabish Bidiwale a91adbdbd7 feat: implement artifact workflow CLI commands (Slice 4)
Add experimental CLI commands for artifact-based workflow management:
- `openspec status --change <id>` - display artifact completion status
- `openspec next --change <id>` - show artifacts ready to create
- `openspec instructions <artifact> --change <id>` - output enriched template
- `openspec templates [--schema <name>]` - show resolved template paths
- `openspec new change <name>` - create new change directory

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

All commands are isolated in src/commands/artifact-workflow.ts for easy
removal if the feature doesn't work out. Help text marks them as experimental.
2025-12-29 00:04:27 +11:00
Tabish Bidiwale ac8f6d281c rename: cli-workflow -> cli-artifact-workflow
More specific capability name that clarifies which workflow the CLI
commands are for.
2025-12-28 23:22:01 +11:00
Tabish Bidiwale 44ca42fbe1 fix: remove --change from templates command
Templates are schema-level, not change-level. The command now uses
--schema instead of --change for consistency with how templates
are actually resolved.
2025-12-28 20:21:30 +11:00
Tabish Bidiwale 39645d9284 proposal: add artifact workflow CLI commands (Slice 4)
Add CLI commands for artifact workflow operations:
- `openspec status --change <id>` - Show artifact completion state
- `openspec next --change <id>` - Show ready artifacts
- `openspec instructions <artifact> --change <id>` - Get enriched template
- `openspec templates --change <id>` - Show template paths
- `openspec new change <name>` - Create new change

Commands are top-level for fluid UX and implemented in isolation
for easy removal (experimental feature).
2025-12-28 20:07:20 +11:00
18 changed files with 2102 additions and 28 deletions
@@ -0,0 +1,112 @@
## Context
Slice 4 of the artifact workflow POC. The core functionality (ArtifactGraph, InstructionLoader, change-utils) is complete. This slice adds CLI commands to expose the artifact workflow to users.
**Key constraint**: This is experimental. Commands must be isolated for easy removal if the feature doesn't work out.
## Goals / Non-Goals
- **Goals:**
- Expose artifact workflow status and instructions via CLI
- Provide fluid UX with top-level verb commands
- Support both human-readable and JSON output
- Enable agents to programmatically query workflow state
- Keep implementation isolated for easy removal
- **Non-Goals:**
- Interactive artifact creation wizards (future work)
- Schema management commands (deferred)
- Auto-detection of active change (CLI is deterministic, agents infer)
## Decisions
### Command Structure: Top-Level Verbs
Commands are top-level for maximum fluidity:
```
openspec status --change <id>
openspec next --change <id>
openspec instructions <artifact> --change <id>
openspec templates [--schema <name>]
openspec new change <name>
```
**Rationale:**
- Most fluid UX - fewest keystrokes
- Commands are unique enough to avoid conflicts
- Simple mental model for users
**Trade-off accepted:** Slight namespace pollution, but commands are distinct and can be removed cleanly.
### Experimental Isolation
All artifact workflow commands are implemented in a single file:
```
src/commands/artifact-workflow.ts
```
**To remove the feature:**
1. Delete `src/commands/artifact-workflow.ts`
2. Remove ~5 lines from `src/cli/index.ts`
No other files touched, no risk to stable functionality.
### Deterministic CLI with Explicit `--change`
All change-specific commands require `--change <id>`:
```bash
openspec status --change add-auth # explicit, works
openspec status # error: missing --change
```
**Rationale:**
- CLI is pure, testable, no hidden state
- Agents infer change from conversation and pass explicitly
- No config file tracking "active change"
- Consistent with POC design philosophy
### New Change Command Structure
Creating changes uses explicit subcommand:
```bash
openspec new change add-feature
```
**Rationale:**
- `openspec new <name>` is ambiguous (new what?)
- `openspec new change <name>` is clear and extensible
- Can add `openspec new spec <name>` later if needed
### Output Formats
- **Default**: Human-readable text with visual indicators
- Status: `[x]` done, `[ ]` ready, `[-]` blocked
- Colors: green (done), yellow (ready), red (blocked)
- **JSON** (`--json`): Machine-readable for scripts and agents
### Error Handling
- Missing `--change`: Error listing available changes
- Unknown change: Error with suggestion
- Unknown artifact: Error listing valid artifacts
- Missing schema: Error with schema resolution details
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Top-level commands pollute namespace | Commands are distinct; isolated for easy removal |
| `status` confused with git | Context (`--change`) makes it clear |
| Feature doesn't work out | Single file deletion removes everything |
## Implementation Notes
- All commands in `src/commands/artifact-workflow.ts`
- Imports from `src/core/artifact-graph/` for all operations
- Uses `getActiveChangeIds()` from `item-discovery.ts` for change listing
- Follows existing CLI patterns (ora spinners, commander.js options)
- Help text marks commands as "Experimental"
@@ -0,0 +1,33 @@
## Why
The ArtifactGraph (Slice 1) and InstructionLoader (Slice 3) provide programmatic APIs for artifact-based workflow management. Users currently have no CLI interface to:
- See artifact completion status for a change
- Discover what artifacts are ready to create
- Get enriched instructions for creating artifacts
- Create new changes with proper validation
This proposal adds CLI commands that expose the artifact workflow functionality to users and agents.
## What Changes
- **NEW**: `openspec status --change <id>` shows artifact completion state
- **NEW**: `openspec next --change <id>` shows artifacts ready to create
- **NEW**: `openspec instructions <artifact> --change <id>` outputs enriched template
- **NEW**: `openspec templates [--schema <name>]` shows resolved template paths
- **NEW**: `openspec new change <name>` creates a new change directory
All commands are top-level for fluid UX. They integrate with existing core modules:
- Uses `loadChangeContext()`, `formatChangeStatus()`, `generateInstructions()` from instruction-loader
- Uses `ArtifactGraph`, `detectCompleted()` from artifact-graph
- Uses `createChange()`, `validateChangeName()` from change-utils
**Experimental isolation**: All commands are implemented in a single file (`src/commands/artifact-workflow.ts`) for easy removal if the feature doesn't work out. Help text marks them as experimental.
## Impact
- Affected specs: NEW `cli-artifact-workflow` capability
- Affected code:
- `src/cli/index.ts` - register new commands
- `src/commands/artifact-workflow.ts` - new command implementations
- No changes to existing commands or specs
- Builds on completed Slice 1, 2, and 3 implementations
@@ -0,0 +1,153 @@
# cli-artifact-workflow Specification
## Purpose
CLI commands for artifact workflow operations, exposing the artifact graph and instruction loader functionality to users and agents. Commands are top-level for fluid UX and implemented in isolation for easy removal.
## ADDED Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change.
#### Scenario: Show status with all states
- **WHEN** user runs `openspec status --change <id>`
- **THEN** the system displays each artifact with status indicator:
- `[x]` for completed artifacts
- `[ ]` for ready artifacts
- `[-]` for blocked artifacts (with missing dependencies listed)
#### Scenario: Status shows completion summary
- **WHEN** user runs `openspec status --change <id>`
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Missing change parameter
- **WHEN** user runs `openspec status` without `--change`
- **THEN** the system displays an error with list of available changes
#### Scenario: Unknown change
- **WHEN** user runs `openspec status --change unknown-id`
- **THEN** the system displays an error indicating the change does not exist
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact.
#### Scenario: Show enriched instructions
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
- **THEN** the system outputs:
- Artifact metadata (ID, output path, description)
- Template content
- Dependency status (done/missing)
- Unlocked artifacts (what becomes available after completion)
#### Scenario: Instructions JSON output
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
- **THEN** the system outputs JSON matching ArtifactInstructions interface
#### Scenario: Unknown artifact
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
- **THEN** the system displays an error listing valid artifact IDs for the schema
#### Scenario: Artifact with unmet dependencies
- **WHEN** user requests instructions for a blocked artifact
- **THEN** the system displays instructions with a warning about missing dependencies
### Requirement: Templates Command
The system SHALL show resolved template paths for all artifacts in a schema.
#### Scenario: List template paths with default schema
- **WHEN** user runs `openspec templates`
- **THEN** the system displays each artifact with its resolved template path using the default schema
#### Scenario: List template paths with custom schema
- **WHEN** user runs `openspec templates --schema tdd`
- **THEN** the system displays template paths for the specified schema
#### Scenario: Templates JSON output
- **WHEN** user runs `openspec templates --json`
- **THEN** the system outputs JSON mapping artifact IDs to template paths
#### Scenario: Template resolution source
- **WHEN** displaying template paths
- **THEN** the system indicates whether each template is from user override or package built-in
### Requirement: New Change Command
The system SHALL create new change directories with validation.
#### Scenario: Create valid change
- **WHEN** user runs `openspec new change add-feature`
- **THEN** the system creates `openspec/changes/add-feature/` directory
#### Scenario: Invalid change name
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
- **THEN** the system displays validation error with guidance
#### Scenario: Duplicate change name
- **WHEN** user runs `openspec new change existing-change` for an existing change
- **THEN** the system displays an error indicating the change already exists
#### Scenario: Create with description
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
- **THEN** the system creates the change directory with description in README.md
### Requirement: Schema Selection
The system SHALL support custom schema selection for workflow commands.
#### Scenario: Default schema
- **WHEN** user runs workflow commands without `--schema`
- **THEN** the system uses the "spec-driven" schema
#### Scenario: Custom schema
- **WHEN** user runs `openspec status --change <id> --schema tdd`
- **THEN** the system uses the specified schema for artifact graph
#### Scenario: Unknown schema
- **WHEN** user specifies an unknown schema
- **THEN** the system displays an error listing available schemas
### Requirement: Output Formatting
The system SHALL provide consistent output formatting.
#### Scenario: Color output
- **WHEN** terminal supports colors
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
#### Scenario: No color output
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
- **THEN** output uses text-only indicators without ANSI colors
#### Scenario: Progress indication
- **WHEN** loading change state takes time
- **THEN** the system displays a spinner during loading
### Requirement: Experimental Isolation
The system SHALL implement artifact workflow commands in isolation for easy removal.
#### Scenario: Single file implementation
- **WHEN** artifact workflow feature is implemented
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
#### Scenario: Help text marking
- **WHEN** user runs `--help` on any artifact workflow command
- **THEN** help text indicates the command is experimental
@@ -0,0 +1,48 @@
## 1. Core Command Implementation
- [x] 1.1 Create `src/commands/artifact-workflow.ts` with all commands
- [x] 1.2 Implement `status` command with text output
- [x] 1.3 Implement `next` command with text output
- [x] 1.4 Implement `instructions` command with text output
- [x] 1.5 Implement `templates` command with text output
- [x] 1.6 Implement `new change` subcommand using createChange()
## 2. CLI Registration
- [x] 2.1 Register `status` command in `src/cli/index.ts`
- [x] 2.2 Register `next` command in `src/cli/index.ts`
- [x] 2.3 Register `instructions` command in `src/cli/index.ts`
- [x] 2.4 Register `templates` command in `src/cli/index.ts`
- [x] 2.5 Register `new` command group with `change` subcommand
## 3. Output Formatting
- [x] 3.1 Add `--json` flag support to all commands
- [x] 3.2 Add color-coded status indicators (done/ready/blocked)
- [x] 3.3 Add progress spinner for loading operations
- [x] 3.4 Support `--no-color` flag
## 4. Error Handling
- [x] 4.1 Handle missing `--change` parameter with helpful error
- [x] 4.2 Handle unknown change names with list of available changes
- [x] 4.3 Handle unknown artifact names with valid options
- [x] 4.4 Handle schema resolution errors
## 5. Options and Flags
- [x] 5.1 Add `--schema` option for custom schema selection
- [x] 5.2 Add `--description` option to `new change` command
- [x] 5.3 Ensure options follow existing CLI patterns
## 6. Testing
- [x] 6.1 Add smoke tests for each command
- [x] 6.2 Test error cases (missing change, unknown artifact)
- [x] 6.3 Test JSON output format
- [x] 6.4 Test with different schemas
## 7. Documentation
- [x] 7.1 Add help text for all commands marked as "Experimental"
- [ ] 7.2 Update AGENTS.md with new commands (post-archive)
@@ -0,0 +1,151 @@
# Design: Unify Change State Model
## Overview
This change fixes two bugs with minimal disruption to the existing system:
1. **View bug**: Empty changes incorrectly shown as "Completed"
2. **Artifact workflow bug**: Commands fail on scaffolded changes
## Key Design Decision: Two Systems, Two Purposes
The task-based and artifact-based systems serve **different purposes** and should coexist:
| System | Purpose | Used By |
|--------|---------|---------|
| **Task Progress** | Track implementation work | `openspec view`, `openspec list` |
| **Artifact Progress** | Track planning/spec work | `openspec status`, `openspec next` |
We do NOT merge these systems. Instead, we fix each to work correctly in its domain.
## Change 1: Fix View Command
### Current Logic (Buggy)
```typescript
// view.ts line 90
if (progress.total === 0 || progress.completed === progress.total) {
completed.push({ name: entry.name });
}
```
Problem: `total === 0` means "no tasks defined yet", not "all tasks done".
### New Logic
```typescript
if (progress.total === 0) {
draft.push({ name: entry.name });
} else if (progress.completed === progress.total) {
completed.push({ name: entry.name });
} else {
active.push({ name: entry.name, progress });
}
```
### View Output Change
**Before:**
```
Completed Changes
─────────────────
✓ add-feature (all tasks done - correct)
✓ test-workflow (no tasks - WRONG)
```
**After:**
```
Draft Changes
─────────────────
○ test-workflow (no tasks yet)
Active Changes
─────────────────
◉ add-scaffold [████░░░░] 3/7 tasks
Completed Changes
─────────────────
✓ add-feature (all tasks done)
```
## Change 2: Fix Artifact Workflow Discovery
### Current Logic (Buggy)
```typescript
// artifact-workflow.ts - validateChangeExists()
const activeChanges = await getActiveChangeIds(projectRoot);
if (!activeChanges.includes(changeName)) {
throw new Error(`Change '${changeName}' not found...`);
}
```
Problem: `getActiveChangeIds()` requires `proposal.md`, but artifact workflow should work on empty directories to help create the first artifact.
### New Logic
```typescript
async function validateChangeExists(changeName: string, projectRoot: string): Promise<string> {
const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
// Check directory existence directly, not proposal.md
if (!fs.existsSync(changePath) || !fs.statSync(changePath).isDirectory()) {
// List available changes for helpful error message
const entries = await fs.promises.readdir(
path.join(projectRoot, 'openspec', 'changes'),
{ withFileTypes: true }
);
const available = entries
.filter(e => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
.map(e => e.name);
if (available.length === 0) {
throw new Error('No changes found. Create one with: openspec new change <name>');
}
throw new Error(`Change '${changeName}' not found. Available:\n ${available.join('\n ')}`);
}
return changeName;
}
```
### Behavior Change
```bash
# Before
$ openspec new change foo
$ openspec status --change foo
Error: Change 'foo' not found.
# After
$ openspec new change foo
$ openspec status --change foo
Change: foo
Progress: 0/4 artifacts complete
[ ] proposal
[-] specs (blocked by: proposal)
[-] design (blocked by: proposal)
[-] tasks (blocked by: specs, design)
```
## What Stays the Same
1. **`getActiveChangeIds()`** - Still requires `proposal.md` (used by validate, show)
2. **`getArchivedChangeIds()`** - Unchanged
3. **Active/Completed semantics** - Still based on task checkboxes
4. **Validation** - Still requires `proposal.md` to have something to validate
## File Changes
| File | Change |
|------|--------|
| `src/core/view.ts` | Add draft category, fix completion logic |
| `src/commands/artifact-workflow.ts` | Update `validateChangeExists()` to use directory existence |
| `test/commands/artifact-workflow.test.ts` | Add tests for scaffolded changes |
## Testing Strategy
1. **Unit test**: `validateChangeExists()` with scaffolded change
2. **View test**: Verify three categories render correctly
3. **Manual test**: Full workflow from `new change` → `status` → `view`
@@ -0,0 +1,101 @@
# Proposal: Unify Change State Model
## Problem Statement
Two bugs create inconsistent behavior when working with changes:
### Bug 1: Empty changes shown as "Completed" in view
```typescript
// view.ts line 90
if (progress.total === 0 || progress.completed === progress.total) {
completed.push({ name: entry.name }); // BUG: total === 0 ≠ completed
}
```
Result: `openspec new change foo && openspec view` shows `foo` as "Completed" when it has no content.
### Bug 2: Artifact workflow commands can't find scaffolded changes
```typescript
// item-discovery.ts - getActiveChangeIds()
const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
await fs.access(proposalPath); // Only returns changes WITH proposal.md
```
Result: `openspec status --change foo` says "not found" even though the directory exists.
## Root Cause
The system conflates two different concepts:
| Concept | Question | Source of Truth |
|---------|----------|-----------------|
| **Planning Progress** | Are all spec documents created? | File existence (ArtifactGraph) |
| **Implementation Progress** | Is the coding work done? | Task checkboxes (tasks.md) |
## Proposed Solution
### Fix 1: Add "Draft" state to view command
Keep Active/Completed with their existing meanings, but fix the bug:
| State | Criteria | Meaning |
|-------|----------|---------|
| **Draft** | No tasks.md OR `tasks.total === 0` | Still planning |
| **Active** | `tasks.total > 0` AND `completed < total` | Implementing |
| **Completed** | `tasks.total > 0` AND `completed === total` | Done |
### Fix 2: Artifact workflow uses directory existence
Update `validateChangeExists()` to check if the directory exists, not if `proposal.md` exists. This allows the artifact workflow to guide users through creating their first artifact.
### Keep existing discovery functions
`getActiveChangeIds()` continues to require `proposal.md` for backward compatibility with validation and other commands.
## What Changes
| Command | Before | After |
|---------|--------|-------|
| `openspec view` | Empty = "Completed" | Empty = "Draft" |
| `openspec status --change X` | Requires proposal.md | Works on any directory |
| `openspec validate X` | Requires proposal.md | Unchanged (still requires it) |
## Breaking Changes
### Minimal Breaking Change
1. **`openspec view` output**: Empty changes move from "Completed" section to new "Draft" section
### Non-Breaking
- Active/Completed semantics unchanged (still task-based)
- `getActiveChangeIds()` unchanged
- `openspec validate` unchanged
- Archived changes unaffected
## Out of Scope
- Merging task-based and artifact-based progress (they serve different purposes)
- Changing what "Completed" means (it stays = all tasks done)
- Adding artifact progress to view command (separate enhancement)
- Shell tab completions for artifact workflow commands (not yet registered)
## Related Commands Analysis
| Command | Uses `getActiveChangeIds()` | Should include scaffolded? | Change needed? |
|---------|-----------------------------|-----------------------------|----------------|
| `openspec view` | No (reads dirs directly) | Yes → Draft section | **Yes** |
| `openspec list` | No (reads dirs directly) | Yes (shows "No tasks") | No |
| `openspec status/next/instructions` | Yes | Yes | **Yes** |
| `openspec validate` | Yes | No (can't validate empty) | No |
| `openspec show` | Yes | No (nothing to show) | No |
| Tab completions | Yes | Future enhancement | No |
## Success Criteria
1. `openspec new change foo && openspec view` shows `foo` in "Draft" section
2. `openspec new change foo && openspec status --change foo` works
3. Changes with all tasks done still show as "Completed"
4. All existing tests pass
@@ -0,0 +1,109 @@
# cli-artifact-workflow Specification Delta
## MODIFIED Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
#### Scenario: Show status with all states
- **WHEN** user runs `openspec status --change <id>`
- **THEN** the system displays each artifact with status indicator:
- `[x]` for completed artifacts
- `[ ]` for ready artifacts
- `[-]` for blocked artifacts (with missing dependencies listed)
#### Scenario: Status shows completion summary
- **WHEN** user runs `openspec status --change <id>`
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Status on scaffolded change
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
- **THEN** system displays all artifacts with their status
- **AND** root artifacts (no dependencies) show as ready `[ ]`
- **AND** dependent artifacts show as blocked `[-]`
#### Scenario: Missing change parameter
- **WHEN** user runs `openspec status` without `--change`
- **THEN** the system displays an error with list of available changes
- **AND** includes scaffolded changes (directories without proposal.md)
#### Scenario: Unknown change
- **WHEN** user runs `openspec status --change unknown-id`
- **AND** directory `openspec/changes/unknown-id/` does not exist
- **THEN** the system displays an error listing all available change directories
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
#### Scenario: Next on scaffolded change
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
#### Scenario: Show enriched instructions
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
- **THEN** the system outputs:
- Artifact metadata (ID, output path, description)
- Template content
- Dependency status (done/missing)
- Unlocked artifacts (what becomes available after completion)
#### Scenario: Instructions JSON output
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
- **THEN** the system outputs JSON matching ArtifactInstructions interface
#### Scenario: Unknown artifact
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
- **THEN** the system displays an error listing valid artifact IDs for the schema
#### Scenario: Artifact with unmet dependencies
- **WHEN** user requests instructions for a blocked artifact
- **THEN** the system displays instructions with a warning about missing dependencies
#### Scenario: Instructions on scaffolded change
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
- **THEN** system outputs template and metadata for creating the proposal
- **AND** does not require any artifacts to already exist
@@ -0,0 +1,60 @@
# cli-view Specification Delta
## ADDED Requirements
### Requirement: Draft Changes Display
The dashboard SHALL display changes without tasks in a separate "Draft" section.
#### Scenario: Draft changes listing
- **WHEN** there are changes with no tasks.md or zero tasks defined
- **THEN** system shows them in a "Draft Changes" section
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
#### Scenario: Draft section ordering
- **WHEN** multiple draft changes exist
- **THEN** system sorts them alphabetically by name
## MODIFIED Requirements
### Requirement: Completed Changes Display
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
#### Scenario: Completed changes listing
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
- **THEN** system shows them with checkmark indicators in a dedicated section
#### Scenario: Mixed completion states
- **WHEN** some changes are complete and others active
- **THEN** system separates them into appropriate sections
#### Scenario: Empty changes not completed
- **WHEN** a change has no tasks.md or zero tasks defined
- **THEN** system does NOT show it in "Completed Changes" section
- **AND** shows it in "Draft Changes" section instead
### Requirement: Summary Section
The dashboard SHALL display a summary section with key project metrics, including draft change count.
#### Scenario: Complete summary display
- **WHEN** dashboard is rendered with specs and changes
- **THEN** system shows total number of specifications and requirements
- **AND** shows number of draft changes
- **AND** shows number of active changes in progress
- **AND** shows number of completed changes
- **AND** shows overall task progress percentage
#### Scenario: Empty project summary
- **WHEN** no specs or changes exist
- **THEN** summary shows zero counts for all metrics
@@ -0,0 +1,25 @@
# Tasks: Unify Change State Model
## Phase 1: Fix Artifact Workflow Discovery
- [x] Update `validateChangeExists()` in `artifact-workflow.ts` to check directory existence instead of using `getActiveChangeIds()`
- [x] Update error message to list all change directories (not just those with proposal.md)
- [x] Add test for `openspec status --change <scaffolded-change>`
- [x] Add test for `openspec next --change <scaffolded-change>`
- [x] Add test for `openspec instructions proposal --change <scaffolded-change>`
## Phase 2: Fix View Command
- [x] Update `getChangesData()` in `view.ts` to return three categories: draft, active, completed
- [x] Fix completion logic: `total === 0` → draft, not completed
- [x] Add "Draft Changes" section to dashboard rendering
- [x] Update summary to include draft count
- [x] Add test for draft changes appearing correctly in view
## Phase 3: Cleanup and Validation
- [x] Clean up test changes (`test-workflow`, `test-workflow-2`)
- [x] Run full test suite
- [x] Manual test: `openspec new change foo && openspec status --change foo`
- [x] Manual test: `openspec new change foo && openspec view` shows foo in Draft
- [x] Validate with `openspec validate unify-change-state-model --strict`
@@ -0,0 +1,190 @@
# cli-artifact-workflow Specification
## Purpose
TBD - created by archiving change add-artifact-workflow-cli. Update Purpose after archive.
## Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
#### Scenario: Show status with all states
- **WHEN** user runs `openspec status --change <id>`
- **THEN** the system displays each artifact with status indicator:
- `[x]` for completed artifacts
- `[ ]` for ready artifacts
- `[-]` for blocked artifacts (with missing dependencies listed)
#### Scenario: Status shows completion summary
- **WHEN** user runs `openspec status --change <id>`
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Status on scaffolded change
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
- **THEN** system displays all artifacts with their status
- **AND** root artifacts (no dependencies) show as ready `[ ]`
- **AND** dependent artifacts show as blocked `[-]`
#### Scenario: Missing change parameter
- **WHEN** user runs `openspec status` without `--change`
- **THEN** the system displays an error with list of available changes
- **AND** includes scaffolded changes (directories without proposal.md)
#### Scenario: Unknown change
- **WHEN** user runs `openspec status --change unknown-id`
- **AND** directory `openspec/changes/unknown-id/` does not exist
- **THEN** the system displays an error listing all available change directories
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
#### Scenario: Next on scaffolded change
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
#### Scenario: Show enriched instructions
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
- **THEN** the system outputs:
- Artifact metadata (ID, output path, description)
- Template content
- Dependency status (done/missing)
- Unlocked artifacts (what becomes available after completion)
#### Scenario: Instructions JSON output
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
- **THEN** the system outputs JSON matching ArtifactInstructions interface
#### Scenario: Unknown artifact
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
- **THEN** the system displays an error listing valid artifact IDs for the schema
#### Scenario: Artifact with unmet dependencies
- **WHEN** user requests instructions for a blocked artifact
- **THEN** the system displays instructions with a warning about missing dependencies
#### Scenario: Instructions on scaffolded change
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
- **THEN** system outputs template and metadata for creating the proposal
- **AND** does not require any artifacts to already exist
### Requirement: Templates Command
The system SHALL show resolved template paths for all artifacts in a schema.
#### Scenario: List template paths with default schema
- **WHEN** user runs `openspec templates`
- **THEN** the system displays each artifact with its resolved template path using the default schema
#### Scenario: List template paths with custom schema
- **WHEN** user runs `openspec templates --schema tdd`
- **THEN** the system displays template paths for the specified schema
#### Scenario: Templates JSON output
- **WHEN** user runs `openspec templates --json`
- **THEN** the system outputs JSON mapping artifact IDs to template paths
#### Scenario: Template resolution source
- **WHEN** displaying template paths
- **THEN** the system indicates whether each template is from user override or package built-in
### Requirement: New Change Command
The system SHALL create new change directories with validation.
#### Scenario: Create valid change
- **WHEN** user runs `openspec new change add-feature`
- **THEN** the system creates `openspec/changes/add-feature/` directory
#### Scenario: Invalid change name
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
- **THEN** the system displays validation error with guidance
#### Scenario: Duplicate change name
- **WHEN** user runs `openspec new change existing-change` for an existing change
- **THEN** the system displays an error indicating the change already exists
#### Scenario: Create with description
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
- **THEN** the system creates the change directory with description in README.md
### Requirement: Schema Selection
The system SHALL support custom schema selection for workflow commands.
#### Scenario: Default schema
- **WHEN** user runs workflow commands without `--schema`
- **THEN** the system uses the "spec-driven" schema
#### Scenario: Custom schema
- **WHEN** user runs `openspec status --change <id> --schema tdd`
- **THEN** the system uses the specified schema for artifact graph
#### Scenario: Unknown schema
- **WHEN** user specifies an unknown schema
- **THEN** the system displays an error listing available schemas
### Requirement: Output Formatting
The system SHALL provide consistent output formatting.
#### Scenario: Color output
- **WHEN** terminal supports colors
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
#### Scenario: No color output
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
- **THEN** output uses text-only indicators without ANSI colors
#### Scenario: Progress indication
- **WHEN** loading change state takes time
- **THEN** the system displays a spinner during loading
### Requirement: Experimental Isolation
The system SHALL implement artifact workflow commands in isolation for easy removal.
#### Scenario: Single file implementation
- **WHEN** artifact workflow feature is implemented
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
#### Scenario: Help text marking
- **WHEN** user runs `--help` on any artifact workflow command
- **THEN** help text indicates the command is experimental
+27 -3
View File
@@ -20,12 +20,13 @@ The system SHALL provide a `view` command that displays a dashboard overview of
### Requirement: Summary Section
The dashboard SHALL display a summary section with key project metrics.
The dashboard SHALL display a summary section with key project metrics, including draft change count.
#### Scenario: Complete summary display
- **WHEN** dashboard is rendered with specs and changes
- **THEN** system shows total number of specifications and requirements
- **AND** shows number of draft changes
- **AND** shows number of active changes in progress
- **AND** shows number of completed changes
- **AND** shows overall task progress percentage
@@ -46,11 +47,13 @@ The dashboard SHALL show active changes with visual progress indicators.
### Requirement: Completed Changes Display
The dashboard SHALL list completed changes in a separate section.
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
#### Scenario: Completed changes listing
- **WHEN** there are completed changes (all tasks done)
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
- **THEN** system shows them with checkmark indicators in a dedicated section
#### Scenario: Mixed completion states
@@ -58,6 +61,12 @@ The dashboard SHALL list completed changes in a separate section.
- **WHEN** some changes are complete and others active
- **THEN** system separates them into appropriate sections
#### Scenario: Empty changes not completed
- **WHEN** a change has no tasks.md or zero tasks defined
- **THEN** system does NOT show it in "Completed Changes" section
- **AND** shows it in "Draft Changes" section instead
### Requirement: Specifications Display
The dashboard SHALL display specifications sorted by requirement count.
@@ -103,3 +112,18 @@ The view command SHALL handle errors gracefully.
- **WHEN** specs or changes have invalid format
- **THEN** system skips invalid items and continues rendering
### Requirement: Draft Changes Display
The dashboard SHALL display changes without tasks in a separate "Draft" section.
#### Scenario: Draft changes listing
- **WHEN** there are changes with no tasks.md or zero tasks defined
- **THEN** system shows them in a "Draft Changes" section
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
#### Scenario: Draft section ordering
- **WHEN** multiple draft changes exist
- **THEN** system sorts them alphabetically by name
+1 -1
View File
@@ -8,7 +8,7 @@ artifacts:
template: proposal.md
requires: []
- id: specs
generates: "specs/*.md"
generates: "specs/**/*.md"
description: Detailed specifications for the change
template: spec.md
requires:
+4
View File
@@ -14,6 +14,7 @@ import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
const program = new Command();
const require = createRequire(import.meta.url);
@@ -316,4 +317,7 @@ program
}
});
// Register artifact workflow commands (experimental)
registerArtifactWorkflowCommands(program);
program.parse();
+540
View File
@@ -0,0 +1,540 @@
/**
* Artifact Workflow CLI Commands (Experimental)
*
* This file contains all artifact workflow commands in isolation for easy removal.
* Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents.
*
* To remove this feature:
* 1. Delete this file
* 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts
*/
import type { Command } from 'commander';
import ora from 'ora';
import chalk from 'chalk';
import path from 'path';
import * as fs from 'fs';
import {
loadChangeContext,
formatChangeStatus,
generateInstructions,
listSchemas,
getSchemaDir,
resolveSchema,
ArtifactGraph,
type ChangeStatus,
type ArtifactInstructions,
} from '../core/artifact-graph/index.js';
import { createChange, validateChangeName } from '../utils/change-utils.js';
const DEFAULT_SCHEMA = 'spec-driven';
/**
* Checks if color output is disabled via NO_COLOR env or --no-color flag.
*/
function isColorDisabled(): boolean {
return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true';
}
/**
* Gets the color function based on status.
*/
function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string {
if (isColorDisabled()) {
return (text: string) => text;
}
switch (status) {
case 'done':
return chalk.green;
case 'ready':
return chalk.yellow;
case 'blocked':
return chalk.red;
}
}
/**
* Gets the status indicator for an artifact.
*/
function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string {
const color = getStatusColor(status);
switch (status) {
case 'done':
return color('[x]');
case 'ready':
return color('[ ]');
case 'blocked':
return color('[-]');
}
}
/**
* Validates that a change exists and returns available changes if not.
* Checks directory existence directly to support scaffolded changes (without proposal.md).
*/
async function validateChangeExists(
changeName: string | undefined,
projectRoot: string
): Promise<string> {
const changesPath = path.join(projectRoot, 'openspec', 'changes');
// Get all change directories (not just those with proposal.md)
const getAvailableChanges = async (): Promise<string[]> => {
try {
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
.map((e) => e.name);
} catch {
return [];
}
};
if (!changeName) {
const available = await getAvailableChanges();
if (available.length === 0) {
throw new Error('No changes found. Create one with: openspec new change <name>');
}
throw new Error(
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
);
}
// Validate change name format to prevent path traversal
const nameValidation = validateChangeName(changeName);
if (!nameValidation.valid) {
throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);
}
// Check directory existence directly
const changePath = path.join(changesPath, changeName);
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
if (!exists) {
const available = await getAvailableChanges();
if (available.length === 0) {
throw new Error(
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`
);
}
throw new Error(
`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`
);
}
return changeName;
}
/**
* Validates that a schema exists and returns available schemas if not.
*/
function validateSchemaExists(schemaName: string): string {
const schemaDir = getSchemaDir(schemaName);
if (!schemaDir) {
const availableSchemas = listSchemas();
throw new Error(
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
);
}
return schemaName;
}
// -----------------------------------------------------------------------------
// Status Command
// -----------------------------------------------------------------------------
interface StatusOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function statusCommand(options: StatusOptions): Promise<void> {
const spinner = ora('Loading change status...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const context = loadChangeContext(projectRoot, changeName, schemaName);
const status = formatChangeStatus(context);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(status, null, 2));
return;
}
printStatusText(status);
} catch (error) {
spinner.stop();
throw error;
}
}
function printStatusText(status: ChangeStatus): void {
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
const total = status.artifacts.length;
console.log(`Change: ${status.changeName}`);
console.log(`Schema: ${status.schemaName}`);
console.log(`Progress: ${doneCount}/${total} artifacts complete`);
console.log();
for (const artifact of status.artifacts) {
const indicator = getStatusIndicator(artifact.status);
const color = getStatusColor(artifact.status);
let line = `${indicator} ${artifact.id}`;
if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) {
line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`);
}
console.log(line);
}
if (status.isComplete) {
console.log();
console.log(chalk.green('All artifacts complete!'));
}
}
// -----------------------------------------------------------------------------
// Next Command
// -----------------------------------------------------------------------------
interface NextOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function nextCommand(options: NextOptions): Promise<void> {
const spinner = ora('Finding next artifacts...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const context = loadChangeContext(projectRoot, changeName, schemaName);
const ready = context.graph.getNextArtifacts(context.completed);
const isComplete = context.graph.isComplete(context.completed);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(ready, null, 2));
return;
}
if (isComplete) {
console.log(chalk.green('All artifacts are complete!'));
return;
}
if (ready.length === 0) {
console.log('No artifacts are ready. All remaining artifacts are blocked.');
console.log('Run `openspec status --change ' + changeName + '` to see blocked dependencies.');
return;
}
console.log('Artifacts ready to create:');
for (const artifactId of ready) {
const color = getStatusColor('ready');
console.log(color(` ${artifactId}`));
}
} catch (error) {
spinner.stop();
throw error;
}
}
// -----------------------------------------------------------------------------
// Instructions Command
// -----------------------------------------------------------------------------
interface InstructionsOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function instructionsCommand(
artifactId: string | undefined,
options: InstructionsOptions
): Promise<void> {
const spinner = ora('Generating instructions...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
if (!artifactId) {
spinner.stop();
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const validIds = graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const context = loadChangeContext(projectRoot, changeName, schemaName);
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const instructions = generateInstructions(context, artifactId);
const isBlocked = instructions.dependencies.some((d) => !d.done);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
return;
}
printInstructionsText(instructions, isBlocked);
} catch (error) {
spinner.stop();
throw error;
}
}
function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {
if (isBlocked) {
console.log(chalk.yellow('Warning: This artifact has unmet dependencies.'));
console.log();
}
console.log(`Artifact: ${instructions.artifactId}`);
console.log(`Output: ${instructions.outputPath}`);
console.log(`Description: ${instructions.description}`);
console.log();
console.log('Dependencies:');
if (instructions.dependencies.length === 0) {
console.log(' (none)');
} else {
for (const dep of instructions.dependencies) {
const status = dep.done ? chalk.green('[done]') : chalk.red('[missing]');
console.log(` ${status} ${dep.id}`);
}
}
console.log();
if (instructions.unlocks.length > 0) {
console.log('Unlocks:');
for (const unlocked of instructions.unlocks) {
console.log(` ${unlocked}`);
}
console.log();
}
console.log('Template:');
console.log('─'.repeat(40));
console.log(instructions.template);
}
// -----------------------------------------------------------------------------
// Templates Command
// -----------------------------------------------------------------------------
interface TemplatesOptions {
schema?: string;
json?: boolean;
}
interface TemplateInfo {
artifactId: string;
templatePath: string;
source: 'user' | 'package';
}
async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();
try {
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const schemaDir = getSchemaDir(schemaName)!;
// Determine if this is a user override or package built-in
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
const userSchemasDir = getUserSchemasDir();
const isUserOverride = schemaDir.startsWith(userSchemasDir);
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: path.join(schemaDir, 'templates', artifact.template),
source: isUserOverride ? 'user' : 'package',
}));
spinner.stop();
if (options.json) {
const output: Record<string, { path: string; source: string }> = {};
for (const t of templates) {
output[t.artifactId] = { path: t.templatePath, source: t.source };
}
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(`Schema: ${schemaName}`);
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
console.log();
for (const t of templates) {
console.log(`${t.artifactId}:`);
console.log(` ${t.templatePath}`);
}
} catch (error) {
spinner.stop();
throw error;
}
}
// -----------------------------------------------------------------------------
// New Change Command
// -----------------------------------------------------------------------------
interface NewChangeOptions {
description?: string;
}
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
if (!name) {
throw new Error('Missing required argument <name>');
}
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
const spinner = ora(`Creating change '${name}'...`).start();
try {
const projectRoot = process.cwd();
await createChange(projectRoot, name);
// If description provided, create README.md with description
if (options.description) {
const { promises: fs } = await import('fs');
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
const readmePath = path.join(changeDir, 'README.md');
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
}
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
}
}
// -----------------------------------------------------------------------------
// Command Registration
// -----------------------------------------------------------------------------
/**
* Registers all artifact workflow commands on the given program.
* All commands are marked as experimental in their help text.
*/
export function registerArtifactWorkflowCommands(program: Command): void {
// Status command
program
.command('status')
.description('[Experimental] Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON')
.action(async (options: StatusOptions) => {
try {
await statusCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Next command
program
.command('next')
.description('[Experimental] Show artifacts ready to be created')
.option('--change <id>', 'Change name to check')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON array of ready artifact IDs')
.action(async (options: NextOptions) => {
try {
await nextCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Instructions command
program
.command('instructions [artifact]')
.description('[Experimental] Output enriched instructions for creating an artifact')
.option('--change <id>', 'Change name')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON')
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
try {
await instructionsCommand(artifactId, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Templates command
program
.command('templates')
.description('[Experimental] Show resolved template paths for all artifacts in a schema')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
.action(async (options: TemplatesOptions) => {
try {
await templatesCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// New command group with change subcommand
const newCmd = program.command('new').description('[Experimental] Create new items');
newCmd
.command('change <name>')
.description('[Experimental] Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.action(async (name: string, options: NewChangeOptions) => {
try {
await newChangeCommand(name, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
}
+53 -23
View File
@@ -23,16 +23,26 @@ export class ViewCommand {
// Display summary metrics
this.displaySummary(changesData, specsData);
// Display draft changes
if (changesData.draft.length > 0) {
console.log(chalk.bold.gray('\nDraft Changes'));
console.log('─'.repeat(60));
changesData.draft.forEach((change) => {
console.log(` ${chalk.gray('○')} ${change.name}`);
});
}
// Display active changes
if (changesData.active.length > 0) {
console.log(chalk.bold.cyan('\nActive Changes'));
console.log('─'.repeat(60));
changesData.active.forEach(change => {
changesData.active.forEach((change) => {
const progressBar = this.createProgressBar(change.progress.completed, change.progress.total);
const percentage = change.progress.total > 0
? Math.round((change.progress.completed / change.progress.total) * 100)
: 0;
const percentage =
change.progress.total > 0
? Math.round((change.progress.completed / change.progress.total) * 100)
: 0;
console.log(
` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`
);
@@ -43,7 +53,7 @@ export class ViewCommand {
if (changesData.completed.length > 0) {
console.log(chalk.bold.green('\nCompleted Changes'));
console.log('─'.repeat(60));
changesData.completed.forEach(change => {
changesData.completed.forEach((change) => {
console.log(` ${chalk.green('✓')} ${change.name}`);
});
}
@@ -69,33 +79,43 @@ export class ViewCommand {
}
private async getChangesData(openspecDir: string): Promise<{
draft: Array<{ name: string }>;
active: Array<{ name: string; progress: { total: number; completed: number } }>;
completed: Array<{ name: string }>;
}> {
const changesDir = path.join(openspecDir, 'changes');
if (!fs.existsSync(changesDir)) {
return { active: [], completed: [] };
return { draft: [], active: [], completed: [] };
}
const draft: Array<{ name: string }> = [];
const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];
const completed: Array<{ name: string }> = [];
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'archive') {
const progress = await getTaskProgressForChange(changesDir, entry.name);
if (progress.total === 0 || progress.completed === progress.total) {
if (progress.total === 0) {
// No tasks defined yet - still in planning/draft phase
draft.push({ name: entry.name });
} else if (progress.completed === progress.total) {
// All tasks complete
completed.push({ name: entry.name });
} else {
// Has tasks but not all complete
active.push({ name: entry.name, progress });
}
}
}
// Sort active changes by completion percentage (ascending) and then by name for deterministic ordering
// Sort all categories by name for deterministic ordering
draft.sort((a, b) => a.name.localeCompare(b.name));
// Sort active changes by completion percentage (ascending) and then by name
active.sort((a, b) => {
const percentageA = a.progress.total > 0 ? a.progress.completed / a.progress.total : 0;
const percentageB = b.progress.total > 0 ? b.progress.completed / b.progress.total : 0;
@@ -106,7 +126,7 @@ export class ViewCommand {
});
completed.sort((a, b) => a.name.localeCompare(b.name));
return { active, completed };
return { draft, active, completed };
}
private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
@@ -142,35 +162,45 @@ export class ViewCommand {
}
private displaySummary(
changesData: { active: any[]; completed: any[] },
changesData: { draft: any[]; active: any[]; completed: any[] },
specsData: any[]
): void {
const totalChanges = changesData.active.length + changesData.completed.length;
const totalChanges =
changesData.draft.length + changesData.active.length + changesData.completed.length;
const totalSpecs = specsData.length;
const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);
// Calculate total task progress
let totalTasks = 0;
let completedTasks = 0;
changesData.active.forEach(change => {
changesData.active.forEach((change) => {
totalTasks += change.progress.total;
completedTasks += change.progress.completed;
});
changesData.completed.forEach(() => {
// Completed changes count as 100% done (we don't know exact task count)
// This is a simplification
});
console.log(chalk.bold('Summary:'));
console.log(` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`);
console.log(` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`);
console.log(
` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`
);
if (changesData.draft.length > 0) {
console.log(` ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`);
}
console.log(
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
);
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
if (totalTasks > 0) {
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
console.log(` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`);
console.log(
` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`
);
}
}
+444
View File
@@ -0,0 +1,444 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { runCLI } from '../helpers/run-cli.js';
describe('artifact-workflow CLI commands', () => {
let tempDir: string;
let changesDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
});
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
/**
* Gets combined output from CLI result (ora outputs to stdout).
*/
function getOutput(result: { stdout: string; stderr: string }): string {
return result.stdout + result.stderr;
}
/**
* Creates a test change with the specified artifacts completed.
* Note: An "active" change requires at least a proposal.md file to be detected.
* If no artifacts are specified, we create an empty proposal to make it detectable.
*/
async function createTestChange(
changeName: string,
artifacts: ('proposal' | 'design' | 'specs' | 'tasks')[] = []
): Promise<string> {
const changeDir = path.join(changesDir, changeName);
await fs.mkdir(changeDir, { recursive: true });
// Always create proposal.md for the change to be detected as active
// Content varies based on whether 'proposal' is in artifacts list
const proposalContent = artifacts.includes('proposal')
? '## Why\nTest proposal content that is long enough.\n\n## What Changes\n- **test:** Something'
: '## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder';
await fs.writeFile(path.join(changeDir, 'proposal.md'), proposalContent);
if (artifacts.includes('design')) {
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\n\nTechnical design.');
}
if (artifacts.includes('specs')) {
// specs artifact uses glob pattern "specs/*.md" - files directly in specs/ directory
const specsDir = path.join(changeDir, 'specs');
await fs.mkdir(specsDir, { recursive: true });
await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.');
}
if (artifacts.includes('tasks')) {
await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\n- [ ] Task 1');
}
return changeDir;
}
describe('status command', () => {
it('shows status for scaffolded change without proposal.md', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['status', '--change', 'scaffolded-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('scaffolded-change');
expect(result.stdout).toContain('0/4 artifacts complete');
});
it('shows status for a change with proposal only', async () => {
// createTestChange always creates proposal.md, so this has 1 artifact complete
await createTestChange('minimal-change');
const result = await runCLI(['status', '--change', 'minimal-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('minimal-change');
expect(result.stdout).toContain('spec-driven');
expect(result.stdout).toContain('1/4 artifacts complete');
});
it('shows status for a change with proposal and design', async () => {
await createTestChange('partial-change', ['proposal', 'design']);
const result = await runCLI(['status', '--change', 'partial-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('2/4 artifacts complete');
expect(result.stdout).toContain('[x]');
});
it('outputs JSON when --json flag is used', async () => {
await createTestChange('json-change', ['proposal', 'design']);
const result = await runCLI(['status', '--change', 'json-change', '--json'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.changeName).toBe('json-change');
expect(json.schemaName).toBe('spec-driven');
expect(json.isComplete).toBe(false);
expect(Array.isArray(json.artifacts)).toBe(true);
expect(json.artifacts).toHaveLength(4);
const proposalArtifact = json.artifacts.find((a: any) => a.id === 'proposal');
expect(proposalArtifact.status).toBe('done');
});
it('shows complete status when all artifacts are done', async () => {
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['status', '--change', 'complete-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('4/4 artifacts complete');
expect(result.stdout).toContain('All artifacts complete!');
});
it('errors when --change is missing and lists available changes', async () => {
await createTestChange('some-change');
const result = await runCLI(['status'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required option --change');
expect(output).toContain('some-change');
});
it('errors for unknown change name and lists available changes', async () => {
await createTestChange('existing-change');
const result = await runCLI(['status', '--change', 'nonexistent'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Change 'nonexistent' not found");
expect(output).toContain('existing-change');
});
it('supports --schema option', async () => {
await createTestChange('tdd-change');
const result = await runCLI(['status', '--change', 'tdd-change', '--schema', 'tdd'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tdd');
});
it('errors for unknown schema', async () => {
await createTestChange('test-change');
const result = await runCLI(['status', '--change', 'test-change', '--schema', 'unknown'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Schema 'unknown' not found");
});
it('rejects path traversal in change name', async () => {
const result = await runCLI(['status', '--change', '../foo'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Invalid change name');
});
it('rejects absolute path in change name', async () => {
const result = await runCLI(['status', '--change', '/etc/passwd'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Invalid change name');
});
it('rejects slashes in change name', async () => {
const result = await runCLI(['status', '--change', 'foo/bar'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Invalid change name');
});
});
describe('next command', () => {
it('shows proposal as next for scaffolded change', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['next', '--change', 'scaffolded-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifacts ready to create');
expect(result.stdout).toContain('proposal');
});
it('shows design and specs as next when proposal exists', async () => {
// createTestChange always creates proposal.md, so design and specs are ready
await createTestChange('minimal-change');
const result = await runCLI(['next', '--change', 'minimal-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifacts ready to create');
expect(result.stdout).toContain('design');
expect(result.stdout).toContain('specs');
});
it('shows tasks as next after proposal, design, and specs', async () => {
await createTestChange('after-specs', ['proposal', 'design', 'specs']);
const result = await runCLI(['next', '--change', 'after-specs'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tasks');
});
it('shows complete message when all done', async () => {
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['next', '--change', 'complete-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('All artifacts are complete!');
});
it('outputs JSON array of ready artifacts', async () => {
await createTestChange('json-next', ['proposal']);
const result = await runCLI(['next', '--change', 'json-next', '--json'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(Array.isArray(json)).toBe(true);
expect(json).toContain('design');
expect(json).toContain('specs');
});
it('errors when --change is missing and lists available changes', async () => {
await createTestChange('some-change');
const result = await runCLI(['next'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required option --change');
expect(output).toContain('some-change');
});
});
describe('instructions command', () => {
it('shows instructions for proposal on scaffolded change', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['instructions', 'proposal', '--change', 'scaffolded-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifact: proposal');
expect(result.stdout).toContain('proposal.md');
expect(result.stdout).toContain('Template:');
});
it('shows instructions for design artifact', async () => {
await createTestChange('instr-change');
const result = await runCLI(['instructions', 'design', '--change', 'instr-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifact: design');
expect(result.stdout).toContain('design.md');
expect(result.stdout).toContain('Template:');
});
it('shows blocked warning for artifact with unmet dependencies', async () => {
// tasks depends on design and specs, which are not done yet
await createTestChange('blocked-change');
const result = await runCLI(['instructions', 'tasks', '--change', 'blocked-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Warning: This artifact has unmet dependencies');
expect(result.stdout).toContain('[missing]');
});
it('outputs JSON for instructions', async () => {
await createTestChange('json-instr', ['proposal']);
const result = await runCLI(['instructions', 'design', '--change', 'json-instr', '--json'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.artifactId).toBe('design');
expect(json.outputPath).toContain('design.md');
expect(typeof json.template).toBe('string');
expect(Array.isArray(json.dependencies)).toBe(true);
});
it('errors when artifact argument is missing', async () => {
await createTestChange('test-change');
const result = await runCLI(['instructions', '--change', 'test-change'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required argument <artifact>');
expect(output).toContain('Valid artifacts');
});
it('errors for unknown artifact', async () => {
await createTestChange('test-change');
const result = await runCLI(['instructions', 'unknown-artifact', '--change', 'test-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Artifact 'unknown-artifact' not found");
expect(output).toContain('Valid artifacts');
});
});
describe('templates command', () => {
it('shows template paths for default schema', async () => {
const result = await runCLI(['templates'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: spec-driven');
expect(result.stdout).toContain('proposal:');
expect(result.stdout).toContain('design:');
expect(result.stdout).toContain('specs:');
expect(result.stdout).toContain('tasks:');
});
it('shows template paths for custom schema', async () => {
const result = await runCLI(['templates', '--schema', 'tdd'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: tdd');
expect(result.stdout).toContain('spec:');
expect(result.stdout).toContain('tests:');
});
it('outputs JSON mapping of templates', async () => {
const result = await runCLI(['templates', '--json'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.proposal).toBeDefined();
expect(json.proposal.path).toContain('proposal.md');
expect(json.proposal.source).toBe('package');
});
it('errors for unknown schema', async () => {
const result = await runCLI(['templates', '--schema', 'nonexistent'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain("Schema 'nonexistent' not found");
});
});
describe('new change command', () => {
it('creates a new change directory', async () => {
const result = await runCLI(['new', 'change', 'my-new-feature'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const output = getOutput(result);
expect(output).toContain("Created change 'my-new-feature'");
const changeDir = path.join(changesDir, 'my-new-feature');
const stat = await fs.stat(changeDir);
expect(stat.isDirectory()).toBe(true);
});
it('creates README.md when --description is provided', async () => {
const result = await runCLI(
['new', 'change', 'described-feature', '--description', 'This is a test feature'],
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
const readmePath = path.join(changesDir, 'described-feature', 'README.md');
const content = await fs.readFile(readmePath, 'utf-8');
expect(content).toContain('described-feature');
expect(content).toContain('This is a test feature');
});
it('errors for invalid change name with spaces', async () => {
const result = await runCLI(['new', 'change', 'invalid name'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Error');
});
it('errors for duplicate change name', async () => {
await createTestChange('existing-change');
const result = await runCLI(['new', 'change', 'existing-change'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('exists');
});
it('errors when name argument is missing', async () => {
const result = await runCLI(['new', 'change'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
});
});
describe('help text', () => {
it('marks status command as experimental in help', async () => {
const result = await runCLI(['status', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks next command as experimental in help', async () => {
const result = await runCLI(['next', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks instructions command as experimental in help', async () => {
const result = await runCLI(['instructions', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks templates command as experimental in help', async () => {
const result = await runCLI(['templates', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks new command as experimental in help', async () => {
const result = await runCLI(['new', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
});
});
@@ -215,7 +215,7 @@ describe('instruction-loader', () => {
expect(proposal?.outputPath).toBe('proposal.md');
const specs = status.artifacts.find(a => a.id === 'specs');
expect(specs?.outputPath).toBe('specs/*.md');
expect(specs?.outputPath).toBe('specs/**/*.md');
});
it('should report isComplete true when all done', () => {
+50
View File
@@ -28,6 +28,56 @@ describe('ViewCommand', () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('shows changes with no tasks in Draft section, not Completed', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
// Empty change (no tasks.md) - should show in Draft
await fs.mkdir(path.join(changesDir, 'empty-change'), { recursive: true });
// Change with tasks.md but no tasks - should show in Draft
await fs.mkdir(path.join(changesDir, 'no-tasks-change'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'no-tasks-change', 'tasks.md'), '# Tasks\n\nNo tasks yet.');
// Change with all tasks complete - should show in Completed
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Done task\n'
);
const viewCommand = new ViewCommand();
await viewCommand.execute(tempDir);
const output = logOutput.map(stripAnsi).join('\n');
// Draft section should contain empty and no-tasks changes
expect(output).toContain('Draft Changes');
expect(output).toContain('empty-change');
expect(output).toContain('no-tasks-change');
// Completed section should only contain changes with all tasks done
expect(output).toContain('Completed Changes');
expect(output).toContain('completed-change');
// Verify empty-change and no-tasks-change are in Draft section (marked with ○)
const draftLines = logOutput
.map(stripAnsi)
.filter((line) => line.includes('○'));
const draftNames = draftLines.map((line) => line.trim().replace('○ ', ''));
expect(draftNames).toContain('empty-change');
expect(draftNames).toContain('no-tasks-change');
// Verify completed-change is in Completed section (marked with ✓)
const completedLines = logOutput
.map(stripAnsi)
.filter((line) => line.includes('✓'));
const completedNames = completedLines.map((line) => line.trim().replace('✓ ', ''));
expect(completedNames).toContain('completed-change');
expect(completedNames).not.toContain('empty-change');
expect(completedNames).not.toContain('no-tasks-change');
});
it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });