From 39645d928484fa14a1a97bf723171952372c7266 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Sun, 28 Dec 2025 20:07:20 +1100 Subject: [PATCH 1/9] proposal: add artifact workflow CLI commands (Slice 4) Add CLI commands for artifact workflow operations: - `openspec status --change ` - Show artifact completion state - `openspec next --change ` - Show ready artifacts - `openspec instructions --change ` - Get enriched template - `openspec templates --change ` - Show template paths - `openspec new change ` - Create new change Commands are top-level for fluid UX and implemented in isolation for easy removal (experimental feature). --- .../add-artifact-workflow-cli/design.md | 112 +++++++++++++ .../add-artifact-workflow-cli/proposal.md | 33 ++++ .../specs/cli-workflow/spec.md | 149 ++++++++++++++++++ .../add-artifact-workflow-cli/tasks.md | 48 ++++++ 4 files changed, 342 insertions(+) create mode 100644 openspec/changes/add-artifact-workflow-cli/design.md create mode 100644 openspec/changes/add-artifact-workflow-cli/proposal.md create mode 100644 openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md create mode 100644 openspec/changes/add-artifact-workflow-cli/tasks.md diff --git a/openspec/changes/add-artifact-workflow-cli/design.md b/openspec/changes/add-artifact-workflow-cli/design.md new file mode 100644 index 00000000..38ef03b1 --- /dev/null +++ b/openspec/changes/add-artifact-workflow-cli/design.md @@ -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 +openspec next --change +openspec instructions --change +openspec templates --change +openspec new change +``` + +**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 `: + +```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 ` is ambiguous (new what?) +- `openspec new change ` is clear and extensible +- Can add `openspec new spec ` 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" diff --git a/openspec/changes/add-artifact-workflow-cli/proposal.md b/openspec/changes/add-artifact-workflow-cli/proposal.md new file mode 100644 index 00000000..f31e2e82 --- /dev/null +++ b/openspec/changes/add-artifact-workflow-cli/proposal.md @@ -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 ` shows artifact completion state +- **NEW**: `openspec next --change ` shows artifacts ready to create +- **NEW**: `openspec instructions --change ` outputs enriched template +- **NEW**: `openspec templates --change ` shows resolved template paths +- **NEW**: `openspec new change ` 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-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 diff --git a/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md b/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md new file mode 100644 index 00000000..37ba0530 --- /dev/null +++ b/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md @@ -0,0 +1,149 @@ +# cli-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 ` +- **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 ` +- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete") + +#### Scenario: Status JSON output +- **WHEN** user runs `openspec status --change --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 ` +- **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 --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 --change ` +- **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 --change --json` +- **THEN** the system outputs JSON matching ArtifactInstructions interface + +#### Scenario: Unknown artifact +- **WHEN** user runs `openspec instructions unknown-artifact --change ` +- **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. + +#### Scenario: List template paths +- **WHEN** user runs `openspec templates --change ` +- **THEN** the system displays each artifact with its resolved template path + +#### Scenario: Templates JSON output +- **WHEN** user runs `openspec templates --change --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 --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 diff --git a/openspec/changes/add-artifact-workflow-cli/tasks.md b/openspec/changes/add-artifact-workflow-cli/tasks.md new file mode 100644 index 00000000..4b150460 --- /dev/null +++ b/openspec/changes/add-artifact-workflow-cli/tasks.md @@ -0,0 +1,48 @@ +## 1. Core Command Implementation + +- [ ] 1.1 Create `src/commands/artifact-workflow.ts` with all commands +- [ ] 1.2 Implement `status` command with text output +- [ ] 1.3 Implement `next` command with text output +- [ ] 1.4 Implement `instructions` command with text output +- [ ] 1.5 Implement `templates` command with text output +- [ ] 1.6 Implement `new change` subcommand using createChange() + +## 2. CLI Registration + +- [ ] 2.1 Register `status` command in `src/cli/index.ts` +- [ ] 2.2 Register `next` command in `src/cli/index.ts` +- [ ] 2.3 Register `instructions` command in `src/cli/index.ts` +- [ ] 2.4 Register `templates` command in `src/cli/index.ts` +- [ ] 2.5 Register `new` command group with `change` subcommand + +## 3. Output Formatting + +- [ ] 3.1 Add `--json` flag support to all commands +- [ ] 3.2 Add color-coded status indicators (done/ready/blocked) +- [ ] 3.3 Add progress spinner for loading operations +- [ ] 3.4 Support `--no-color` flag + +## 4. Error Handling + +- [ ] 4.1 Handle missing `--change` parameter with helpful error +- [ ] 4.2 Handle unknown change names with list of available changes +- [ ] 4.3 Handle unknown artifact names with valid options +- [ ] 4.4 Handle schema resolution errors + +## 5. Options and Flags + +- [ ] 5.1 Add `--schema` option for custom schema selection +- [ ] 5.2 Add `--description` option to `new change` command +- [ ] 5.3 Ensure options follow existing CLI patterns + +## 6. Testing + +- [ ] 6.1 Add smoke tests for each command +- [ ] 6.2 Test error cases (missing change, unknown artifact) +- [ ] 6.3 Test JSON output format +- [ ] 6.4 Test with different schemas + +## 7. Documentation + +- [ ] 7.1 Add help text for all commands marked as "Experimental" +- [ ] 7.2 Update AGENTS.md with new commands (post-archive) From 44ca42fbe19d801c08e5fde3de313bb5d1259236 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Sun, 28 Dec 2025 20:21:30 +1100 Subject: [PATCH 2/9] fix: remove --change from templates command Templates are schema-level, not change-level. The command now uses --schema instead of --change for consistency with how templates are actually resolved. --- .../changes/add-artifact-workflow-cli/design.md | 2 +- .../changes/add-artifact-workflow-cli/proposal.md | 2 +- .../specs/cli-workflow/spec.md | 14 +++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/openspec/changes/add-artifact-workflow-cli/design.md b/openspec/changes/add-artifact-workflow-cli/design.md index 38ef03b1..ae7616fd 100644 --- a/openspec/changes/add-artifact-workflow-cli/design.md +++ b/openspec/changes/add-artifact-workflow-cli/design.md @@ -28,7 +28,7 @@ Commands are top-level for maximum fluidity: openspec status --change openspec next --change openspec instructions --change -openspec templates --change +openspec templates [--schema ] openspec new change ``` diff --git a/openspec/changes/add-artifact-workflow-cli/proposal.md b/openspec/changes/add-artifact-workflow-cli/proposal.md index f31e2e82..58bd56db 100644 --- a/openspec/changes/add-artifact-workflow-cli/proposal.md +++ b/openspec/changes/add-artifact-workflow-cli/proposal.md @@ -13,7 +13,7 @@ This proposal adds CLI commands that expose the artifact workflow functionality - **NEW**: `openspec status --change ` shows artifact completion state - **NEW**: `openspec next --change ` shows artifacts ready to create - **NEW**: `openspec instructions --change ` outputs enriched template -- **NEW**: `openspec templates --change ` shows resolved template paths +- **NEW**: `openspec templates [--schema ]` shows resolved template paths - **NEW**: `openspec new change ` creates a new change directory All commands are top-level for fluid UX. They integrate with existing core modules: diff --git a/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md b/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md index 37ba0530..fe9d8f75 100644 --- a/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md +++ b/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md @@ -74,14 +74,18 @@ The system SHALL output enriched instructions for creating an 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. +The system SHALL show resolved template paths for all artifacts in a schema. -#### Scenario: List template paths -- **WHEN** user runs `openspec templates --change ` -- **THEN** the system displays each artifact with its resolved template path +#### 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 --change --json` +- **WHEN** user runs `openspec templates --json` - **THEN** the system outputs JSON mapping artifact IDs to template paths #### Scenario: Template resolution source From ac8f6d281cbf46ac63c63a0380e9e1e8924edb99 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Sun, 28 Dec 2025 23:22:01 +1100 Subject: [PATCH 3/9] rename: cli-workflow -> cli-artifact-workflow More specific capability name that clarifies which workflow the CLI commands are for. --- openspec/changes/add-artifact-workflow-cli/proposal.md | 2 +- .../specs/{cli-workflow => cli-artifact-workflow}/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename openspec/changes/add-artifact-workflow-cli/specs/{cli-workflow => cli-artifact-workflow}/spec.md (99%) diff --git a/openspec/changes/add-artifact-workflow-cli/proposal.md b/openspec/changes/add-artifact-workflow-cli/proposal.md index 58bd56db..93e54038 100644 --- a/openspec/changes/add-artifact-workflow-cli/proposal.md +++ b/openspec/changes/add-artifact-workflow-cli/proposal.md @@ -25,7 +25,7 @@ All commands are top-level for fluid UX. They integrate with existing core modul ## Impact -- Affected specs: NEW `cli-workflow` capability +- Affected specs: NEW `cli-artifact-workflow` capability - Affected code: - `src/cli/index.ts` - register new commands - `src/commands/artifact-workflow.ts` - new command implementations diff --git a/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md b/openspec/changes/add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md similarity index 99% rename from openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md rename to openspec/changes/add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md index fe9d8f75..dbf5d3d2 100644 --- a/openspec/changes/add-artifact-workflow-cli/specs/cli-workflow/spec.md +++ b/openspec/changes/add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md @@ -1,4 +1,4 @@ -# cli-workflow Specification +# 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. From a91adbdbd7c5d688e9c576a1e11e2ac0f509933d Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 29 Dec 2025 00:04:27 +1100 Subject: [PATCH 4/9] feat: implement artifact workflow CLI commands (Slice 4) Add experimental CLI commands for artifact-based workflow management: - `openspec status --change ` - display artifact completion status - `openspec next --change ` - show artifacts ready to create - `openspec instructions --change ` - output enriched template - `openspec templates [--schema ]` - show resolved template paths - `openspec new change ` - create new change directory Features: - JSON output support (--json flag) for all commands - Color-coded status indicators (green/yellow/red) - Progress spinners during loading - --no-color and NO_COLOR env support - --schema option for custom schema selection - Comprehensive error handling with helpful messages All commands are isolated in src/commands/artifact-workflow.ts for easy removal if the feature doesn't work out. Help text marks them as experimental. --- .../add-artifact-workflow-cli/tasks.md | 54 +- src/cli/index.ts | 4 + src/commands/artifact-workflow.ts | 515 ++++++++++++++++++ 3 files changed, 546 insertions(+), 27 deletions(-) create mode 100644 src/commands/artifact-workflow.ts diff --git a/openspec/changes/add-artifact-workflow-cli/tasks.md b/openspec/changes/add-artifact-workflow-cli/tasks.md index 4b150460..f1a3d4a1 100644 --- a/openspec/changes/add-artifact-workflow-cli/tasks.md +++ b/openspec/changes/add-artifact-workflow-cli/tasks.md @@ -1,48 +1,48 @@ ## 1. Core Command Implementation -- [ ] 1.1 Create `src/commands/artifact-workflow.ts` with all commands -- [ ] 1.2 Implement `status` command with text output -- [ ] 1.3 Implement `next` command with text output -- [ ] 1.4 Implement `instructions` command with text output -- [ ] 1.5 Implement `templates` command with text output -- [ ] 1.6 Implement `new change` subcommand using createChange() +- [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 -- [ ] 2.1 Register `status` command in `src/cli/index.ts` -- [ ] 2.2 Register `next` command in `src/cli/index.ts` -- [ ] 2.3 Register `instructions` command in `src/cli/index.ts` -- [ ] 2.4 Register `templates` command in `src/cli/index.ts` -- [ ] 2.5 Register `new` command group with `change` subcommand +- [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 -- [ ] 3.1 Add `--json` flag support to all commands -- [ ] 3.2 Add color-coded status indicators (done/ready/blocked) -- [ ] 3.3 Add progress spinner for loading operations -- [ ] 3.4 Support `--no-color` flag +- [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 -- [ ] 4.1 Handle missing `--change` parameter with helpful error -- [ ] 4.2 Handle unknown change names with list of available changes -- [ ] 4.3 Handle unknown artifact names with valid options -- [ ] 4.4 Handle schema resolution errors +- [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 -- [ ] 5.1 Add `--schema` option for custom schema selection -- [ ] 5.2 Add `--description` option to `new change` command -- [ ] 5.3 Ensure options follow existing CLI patterns +- [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 -- [ ] 6.1 Add smoke tests for each command -- [ ] 6.2 Test error cases (missing change, unknown artifact) -- [ ] 6.3 Test JSON output format -- [ ] 6.4 Test with different schemas +- [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 -- [ ] 7.1 Add help text for all commands marked as "Experimental" +- [x] 7.1 Add help text for all commands marked as "Experimental" - [ ] 7.2 Update AGENTS.md with new commands (post-archive) diff --git a/src/cli/index.ts b/src/cli/index.ts index e8cb2f53..0fcff0e6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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(); diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts new file mode 100644 index 00000000..0bb6b8d8 --- /dev/null +++ b/src/commands/artifact-workflow.ts @@ -0,0 +1,515 @@ +/** + * 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 { + loadChangeContext, + formatChangeStatus, + generateInstructions, + listSchemas, + getSchemaDir, + resolveSchema, + ArtifactGraph, + type ChangeStatus, + type ArtifactInstructions, +} from '../core/artifact-graph/index.js'; +import { getActiveChangeIds } from '../utils/item-discovery.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. + */ +async function validateChangeExists( + changeName: string | undefined, + projectRoot: string +): Promise { + const activeChanges = await getActiveChangeIds(projectRoot); + + if (!changeName) { + if (activeChanges.length === 0) { + throw new Error('No active changes found. Create one with: openspec new change '); + } + throw new Error( + `Missing required option --change. Available changes:\n ${activeChanges.join('\n ')}` + ); + } + + if (!activeChanges.includes(changeName)) { + if (activeChanges.length === 0) { + throw new Error( + `Change '${changeName}' not found. No active changes exist. Create one with: openspec new change ` + ); + } + throw new Error( + `Change '${changeName}' not found. Available changes:\n ${activeChanges.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 { + 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 { + 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 { + 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 . 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 { + 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 = {}; + 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 { + if (!name) { + throw new Error('Missing required argument '); + } + + 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 ', 'Change name to show status for') + .option('--schema ', `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 ', 'Change name to check') + .option('--schema ', `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 ', 'Change name') + .option('--schema ', `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 ', `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 ') + .description('[Experimental] Create a new change directory') + .option('--description ', '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); + } + }); +} From 17016d5eaafe80bdb7bf6d4a33fa94b72e2c3cc6 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 29 Dec 2025 00:43:55 +1100 Subject: [PATCH 5/9] fix: update specs glob to match nested directory structure The schema used specs/*.md but specs are stored as specs//spec.md. Updated to specs/**/*.md so openspec status/next correctly detect spec completion. --- schemas/spec-driven/schema.yaml | 2 +- test/commands/artifact-workflow.test.ts | 387 ++++++++++++++++++++++++ 2 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 test/commands/artifact-workflow.test.ts diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml index 17514220..d7cea150 100644 --- a/schemas/spec-driven/schema.yaml +++ b/schemas/spec-driven/schema.yaml @@ -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: diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts new file mode 100644 index 00000000..1f59f265 --- /dev/null +++ b/test/commands/artifact-workflow.test.ts @@ -0,0 +1,387 @@ +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 { + 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 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"); + }); + }); + + describe('next command', () => { + 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 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 '); + 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]'); + }); + }); +}); From f8747203a70fac80ecc0a396e891850801e7c88f Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 29 Dec 2025 01:05:17 +1100 Subject: [PATCH 6/9] test: update test to match new specs glob pattern --- test/core/artifact-graph/instruction-loader.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index 77ec1b47..007b1b4f 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -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', () => { From 9eb22410a901cc9ee856df493c4bc46b32a3efef Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 29 Dec 2025 01:30:57 +1100 Subject: [PATCH 7/9] chore: archive add-artifact-workflow-cli change - Move change to archive/2025-12-28-add-artifact-workflow-cli - Create cli-artifact-workflow spec --- .../design.md | 0 .../proposal.md | 0 .../specs/cli-artifact-workflow/spec.md | 0 .../tasks.md | 0 openspec/specs/cli-artifact-workflow/spec.md | 152 ++++++++++++++++++ 5 files changed, 152 insertions(+) rename openspec/changes/{add-artifact-workflow-cli => archive/2025-12-28-add-artifact-workflow-cli}/design.md (100%) rename openspec/changes/{add-artifact-workflow-cli => archive/2025-12-28-add-artifact-workflow-cli}/proposal.md (100%) rename openspec/changes/{add-artifact-workflow-cli => archive/2025-12-28-add-artifact-workflow-cli}/specs/cli-artifact-workflow/spec.md (100%) rename openspec/changes/{add-artifact-workflow-cli => archive/2025-12-28-add-artifact-workflow-cli}/tasks.md (100%) create mode 100644 openspec/specs/cli-artifact-workflow/spec.md diff --git a/openspec/changes/add-artifact-workflow-cli/design.md b/openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/design.md similarity index 100% rename from openspec/changes/add-artifact-workflow-cli/design.md rename to openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/design.md diff --git a/openspec/changes/add-artifact-workflow-cli/proposal.md b/openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/proposal.md similarity index 100% rename from openspec/changes/add-artifact-workflow-cli/proposal.md rename to openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/proposal.md diff --git a/openspec/changes/add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md b/openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md similarity index 100% rename from openspec/changes/add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md rename to openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md diff --git a/openspec/changes/add-artifact-workflow-cli/tasks.md b/openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/tasks.md similarity index 100% rename from openspec/changes/add-artifact-workflow-cli/tasks.md rename to openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/tasks.md diff --git a/openspec/specs/cli-artifact-workflow/spec.md b/openspec/specs/cli-artifact-workflow/spec.md new file mode 100644 index 00000000..34eea4d8 --- /dev/null +++ b/openspec/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,152 @@ +# 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. + +#### Scenario: Show status with all states +- **WHEN** user runs `openspec status --change ` +- **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 ` +- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete") + +#### Scenario: Status JSON output +- **WHEN** user runs `openspec status --change --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 ` +- **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 --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 --change ` +- **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 --change --json` +- **THEN** the system outputs JSON matching ArtifactInstructions interface + +#### Scenario: Unknown artifact +- **WHEN** user runs `openspec instructions unknown-artifact --change ` +- **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 --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 + From 4f47bd2f37d7ecb89e2d11dc7f120d099030100e Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 29 Dec 2025 16:29:47 +1100 Subject: [PATCH 8/9] feat: unify change state model for scaffolded changes - Update artifact workflow commands to work with scaffolded changes - Add draft changes section to dashboard view - Fix completed changes to require tasks.total > 0 - Archive unify-change-state-model change --- .../design.md | 151 ++++++++++++++++++ .../proposal.md | 101 ++++++++++++ .../specs/cli-artifact-workflow/spec.md | 109 +++++++++++++ .../specs/cli-view/spec.md | 60 +++++++ .../tasks.md | 25 +++ openspec/specs/cli-artifact-workflow/spec.md | 46 +++++- openspec/specs/cli-view/spec.md | 30 +++- src/commands/artifact-workflow.ts | 37 +++-- src/core/view.ts | 76 ++++++--- test/commands/artifact-workflow.test.ts | 36 +++++ test/core/view.test.ts | 50 ++++++ 11 files changed, 682 insertions(+), 39 deletions(-) create mode 100644 openspec/changes/archive/2025-12-29-unify-change-state-model/design.md create mode 100644 openspec/changes/archive/2025-12-29-unify-change-state-model/proposal.md create mode 100644 openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-artifact-workflow/spec.md create mode 100644 openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-view/spec.md create mode 100644 openspec/changes/archive/2025-12-29-unify-change-state-model/tasks.md diff --git a/openspec/changes/archive/2025-12-29-unify-change-state-model/design.md b/openspec/changes/archive/2025-12-29-unify-change-state-model/design.md new file mode 100644 index 00000000..1ffe3b36 --- /dev/null +++ b/openspec/changes/archive/2025-12-29-unify-change-state-model/design.md @@ -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 { + 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 '); + } + 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` diff --git a/openspec/changes/archive/2025-12-29-unify-change-state-model/proposal.md b/openspec/changes/archive/2025-12-29-unify-change-state-model/proposal.md new file mode 100644 index 00000000..37753adb --- /dev/null +++ b/openspec/changes/archive/2025-12-29-unify-change-state-model/proposal.md @@ -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 diff --git a/openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-artifact-workflow/spec.md b/openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-artifact-workflow/spec.md new file mode 100644 index 00000000..72270a5a --- /dev/null +++ b/openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-artifact-workflow/spec.md @@ -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 ` +- **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 ` +- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete") + +#### Scenario: Status JSON output + +- **WHEN** user runs `openspec status --change --json` +- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array + +#### Scenario: Status on scaffolded change + +- **WHEN** user runs `openspec status --change ` 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 ` +- **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 --json` +- **THEN** the system outputs JSON array of ready artifact IDs + +#### Scenario: Next on scaffolded change + +- **WHEN** user runs `openspec next --change ` 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 --change ` +- **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 --change --json` +- **THEN** the system outputs JSON matching ArtifactInstructions interface + +#### Scenario: Unknown artifact + +- **WHEN** user runs `openspec instructions unknown-artifact --change ` +- **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 ` on a scaffolded change +- **THEN** system outputs template and metadata for creating the proposal +- **AND** does not require any artifacts to already exist diff --git a/openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-view/spec.md b/openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-view/spec.md new file mode 100644 index 00000000..40f6f7b4 --- /dev/null +++ b/openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-view/spec.md @@ -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 diff --git a/openspec/changes/archive/2025-12-29-unify-change-state-model/tasks.md b/openspec/changes/archive/2025-12-29-unify-change-state-model/tasks.md new file mode 100644 index 00000000..d956b98e --- /dev/null +++ b/openspec/changes/archive/2025-12-29-unify-change-state-model/tasks.md @@ -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 ` +- [x] Add test for `openspec next --change ` +- [x] Add test for `openspec instructions proposal --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` diff --git a/openspec/specs/cli-artifact-workflow/spec.md b/openspec/specs/cli-artifact-workflow/spec.md index 34eea4d8..5eb376bd 100644 --- a/openspec/specs/cli-artifact-workflow/spec.md +++ b/openspec/specs/cli-artifact-workflow/spec.md @@ -4,9 +4,13 @@ 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. + +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 ` - **THEN** the system displays each artifact with status indicator: - `[x]` for completed artifacts @@ -14,44 +18,69 @@ The system SHALL display artifact completion status for a change. - `[-]` for blocked artifacts (with missing dependencies listed) #### Scenario: Status shows completion summary + - **WHEN** user runs `openspec status --change ` - **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete") #### Scenario: Status JSON output + - **WHEN** user runs `openspec status --change --json` - **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array +#### Scenario: Status on scaffolded change + +- **WHEN** user runs `openspec status --change ` 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` -- **THEN** the system displays an error indicating the change does not exist +- **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. + +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 ` - **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 --json` - **THEN** the system outputs JSON array of ready artifact IDs +#### Scenario: Next on scaffolded change + +- **WHEN** user runs `openspec next --change ` 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. + +The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes. #### Scenario: Show enriched instructions + - **WHEN** user runs `openspec instructions --change ` - **THEN** the system outputs: - Artifact metadata (ID, output path, description) @@ -60,17 +89,26 @@ The system SHALL output enriched instructions for creating an artifact. - Unlocked artifacts (what becomes available after completion) #### Scenario: Instructions JSON output + - **WHEN** user runs `openspec instructions --change --json` - **THEN** the system outputs JSON matching ArtifactInstructions interface #### Scenario: Unknown artifact + - **WHEN** user runs `openspec instructions unknown-artifact --change ` - **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 ` 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. diff --git a/openspec/specs/cli-view/spec.md b/openspec/specs/cli-view/spec.md index b761b367..c77c4c79 100644 --- a/openspec/specs/cli-view/spec.md +++ b/openspec/specs/cli-view/spec.md @@ -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 + diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index 0bb6b8d8..d497f132 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -13,6 +13,7 @@ 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, @@ -24,7 +25,6 @@ import { type ChangeStatus, type ArtifactInstructions, } from '../core/artifact-graph/index.js'; -import { getActiveChangeIds } from '../utils/item-discovery.js'; import { createChange, validateChangeName } from '../utils/change-utils.js'; const DEFAULT_SCHEMA = 'spec-driven'; @@ -70,30 +70,49 @@ function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string { /** * 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 { - const activeChanges = await getActiveChangeIds(projectRoot); + const changesPath = path.join(projectRoot, 'openspec', 'changes'); + + // Get all change directories (not just those with proposal.md) + const getAvailableChanges = async (): Promise => { + 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) { - if (activeChanges.length === 0) { - throw new Error('No active changes found. Create one with: openspec new change '); + const available = await getAvailableChanges(); + if (available.length === 0) { + throw new Error('No changes found. Create one with: openspec new change '); } throw new Error( - `Missing required option --change. Available changes:\n ${activeChanges.join('\n ')}` + `Missing required option --change. Available changes:\n ${available.join('\n ')}` ); } - if (!activeChanges.includes(changeName)) { - if (activeChanges.length === 0) { + // 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 active changes exist. Create one with: openspec new change ` + `Change '${changeName}' not found. No changes exist. Create one with: openspec new change ` ); } throw new Error( - `Change '${changeName}' not found. Available changes:\n ${activeChanges.join('\n ')}` + `Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}` ); } diff --git a/src/core/view.ts b/src/core/view.ts index 0a80e619..e67c3526 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -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> { @@ -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)` + ); } } diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 1f59f265..2d3d81ea 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -65,6 +65,17 @@ describe('artifact-workflow CLI commands', () => { } 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'); @@ -156,6 +167,17 @@ describe('artifact-workflow CLI commands', () => { }); 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'); @@ -207,6 +229,20 @@ describe('artifact-workflow CLI commands', () => { }); 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'); diff --git a/test/core/view.test.ts b/test/core/view.test.ts index 7b68f2eb..b8b56df1 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -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 }); From acdce5b2ba765846ef46219fb411ad5443305f6f Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 29 Dec 2025 16:49:09 +1100 Subject: [PATCH 9/9] fix: validate change name format to prevent path traversal Add validation in validateChangeExists() to ensure --change parameter is a valid kebab-case ID before constructing file paths. This prevents path traversal attacks like --change "../foo" or --change "/etc/passwd". - Reuses existing validateChangeName() from change-utils.ts - Adds 3 tests for path traversal, absolute paths, and slashes --- src/commands/artifact-workflow.ts | 6 ++++++ test/commands/artifact-workflow.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index d497f132..001ce4e6 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -100,6 +100,12 @@ async function validateChangeExists( ); } + // 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(); diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 2d3d81ea..38a68556 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -164,6 +164,27 @@ describe('artifact-workflow CLI commands', () => { 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', () => {