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