Compare commits

..
15 changed files with 171 additions and 256 deletions
-76
View File
@@ -1,76 +0,0 @@
## openspec change vs spec: behavior differences and recommendations
This document compares how `openspec change` and `openspec spec` behave today (focused on `show` and `list`) and recommends a raw-first, minimal standard to keep behavior simple and predictable before adding smarter formatting later.
## Summary of key differences and recommendations
| Area | Current: change | Current: spec | Recommendation |
|---|---|---|---|
| Invocation (show) | `openspec change show [change-name]` auto-picks when only one active change | `openspec spec show <spec-id>` requires id | Require explicit IDs for both. If `change-name` is omitted, print available IDs and a short hint (e.g., use `openspec change list`) and exit non-zero. No auto-pick. No interactive picker. |
| Default text output (show) | Raw `proposal.md` content | Formatted summary | Default both to RAW: print the underlying Markdown file as-is. Provide a future `--pretty` flag (non-default) for formatted output. |
| Filtering flags (show) | `--requirements-only` affects text and JSON | `--requirements`, `--no-scenarios`, `-r/--requirement` | Raw-first: in TEXT mode, no filtering. All filtering applies only to JSON output. Deprecate text-mode filters. Keep minimal JSON filters only. |
| JSON shape (show) | Full change object; `--requirements-only` can return an array | Filtered object | Always return an OBJECT. Minimal, stable shape. Change: `{ id, title, deltaCount, deltas, taskStatus? }`. Spec: `{ id, title, overview, requirementCount, requirements, metadata }`. No top-level arrays. |
| Text output (list) | "Active Changes" with progress | "Available Specifications" with teaser | Default both to RAW/minimal: print IDs only by default. Add `--long` to show `id + title` and minimal details (counts). No teasers. |
| JSON shape (list) | `[{ name, title, deltas, taskStatus }]` | `[{ id, title, overview, requirementCount }]` | Unify minimal keys: `id`, `title`, counts only. Change: `deltaCount`, `taskStatus`. Spec: `requirementCount`. Drop `overview` from list JSON. Sort by `id`. |
| Error/exit policy | Spinner + `process.exit(1)` in some paths | `exitCode` in others | Raw-first: no spinners in errors. Use `console.error` + `process.exitCode = 1` consistently. |
| Empty states (list) | Graceful | Errors if specs missing | Raw-first: graceful empty state everywhere. Print "No items found" and exit 0. |
| Multi-selection (change show) | Auto-pick single; error when multiple | N/A | Keep auto-pick single. If multiple, print IDs inline and exit non-zero. No interactive prompts. |
| Colors/TTY | Mixed | Chalk only | Raw-first: minimal color; honor `NO_COLOR` and add `--no-color`. |
## Detailed guidance
### 1) Unify flags and semantics (raw-first)
- Text mode: no filters; just raw file content.
- JSON mode: allow minimal filters only.
- Specs: `--json` returns the structured spec. Optional: `-r/--requirement <n>` and `--requirements-only` apply to JSON only.
- Changes: `--json` returns the structured change. Optional: `--deltas-only` applies to JSON only.
- Deprecate text-mode filtering flags across both commands.
- Optional future: `--pretty` (text formatting) as a non-default enhancement.
### 2) Normalize JSON contracts (minimal and stable)
- Show (change): `{ id, title, deltaCount, deltas: [...], taskStatus?: { total, completed } }`
- Show (spec): `{ id, title, overview, requirementCount, requirements: [...], metadata: { format, version } }`
- List (change): `[{ id, title, deltaCount, taskStatus }]`
- List (spec): `[{ id, title, requirementCount }]`
- Notes:
- No top-level arrays for filtered show responses; always objects.
- Avoid derived/pretty fields (e.g., teasers, percentages). Counts only.
### 3) Standardize text UI (minimal)
- Show: print raw Markdown file contents.
- List (default): print IDs only, one per line.
- List (`--long`): print `id: title` plus minimal counts where relevant.
- Keep colors minimal; support `--no-color` and respect `NO_COLOR`.
### 4) Consistent error handling and empty states
- Use `console.error` + `process.exitCode = 1`. Avoid `process.exit(1)`.
- No spinners (`ora`) in raw-first mode.
- Empty states print a simple message and exit 0.
### 5) Discoverability and UX
- No `change-name` provided: print IDs inline and a short hint; exit non-zero. No auto-pick. No interactive prompts.
- Add `--no-color` for deterministic logs and pipelines.
### 6) Backwards compatibility and deprecation
- Keep legacy flags as aliases for one minor release.
- Print a clear deprecation warning when a legacy flag is used.
- Update CLI docs/README/specs to reflect raw-first behavior.
### 7) Test coverage updates
- Add tests asserting raw text outputs (file passthrough) for `show`.
- Add tests for minimal list outputs (IDs by default, `--long` for details).
- Add JSON contract tests asserting minimal, stable shapes and JSON-only filtering.
### 8) Library alignment with existing commands
- No new dependencies.
- Do not use `@inquirer/prompts` in `change`/`spec` show/list (keep non-interactive). Interaction remains limited to `init`, `diff`, and `archive` where already in use.
- Do not use `ora` in `change`/`spec` (including validate). Use `console.error` and `process.exitCode`.
- Use `chalk` minimally; support `--no-color` and respect `NO_COLOR`.
- Keep using the shared `Validator` where applicable.
- Do not introduce `jest-diff` into `change`/`spec` (remains specific to `diff`).
## Why this approach
- Keeps the system raw and predictable; easy to compose in scripts.
- Minimizes UI/formatting logic until real needs emerge.
- Stabilizes JSON for tooling and avoids top-level arrays.
- Simple to extend later with `--pretty` and richer filtering if needed.
@@ -164,4 +164,28 @@ The command SHALL handle various error conditions gracefully.
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**Non-blocking confirmation**: Declining spec updates doesn't cancel archiving - users can review specs and choose to update them separately if needed
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
**--skip-specs flag**: Enables archiving of changes that don't modify specs (like infrastructure, tooling, or documentation changes) without unnecessary spec update prompts or operations
**--skip-specs flag**: Enables archiving of changes that don't modify specs (like infrastructure, tooling, or documentation changes) without unnecessary spec update prompts or operations
## ADDED Requirements
### Requirement: Skip Specs Option
The archive command SHALL support a `--skip-specs` flag that skips all spec update operations and proceeds directly to archiving.
#### Scenario: Skipping spec updates with flag
- **WHEN** executing `openspec archive <change> --skip-specs`
- **THEN** skip spec discovery and update confirmation
- **AND** proceed directly to moving the change to archive
- **AND** display a message indicating specs were skipped
### Requirement: Non-blocking confirmation
The archive operation SHALL proceed when the user declines spec updates instead of cancelling the entire operation.
#### Scenario: User declines spec update confirmation
- **WHEN** the user declines spec update confirmation
- **THEN** skip spec updates
- **AND** continue with the archive operation
- **AND** display a success message indicating specs were not updated
@@ -16,4 +16,4 @@ Currently, OpenSpec specs can only be viewed as markdown files. This makes progr
- **Affected specs**: None (new capability)
- **Affected code**:
- src/cli/index.ts (register new command)
- package.json (add zod dependency)
- package.json (add zod dependency)
@@ -4,8 +4,16 @@
### Requirement: Display Format
The diff command SHALL display unified diff output in text format.
**Reason for removal**: The standard unified diff format is replaced by requirement-level side-by-side comparison that better shows semantic changes rather than line-by-line text differences.
#### Scenario: Unified diff output (deprecated)
- **WHEN** running `openspec diff <change>`
- **THEN** show a unified text diff of files
- **AND** include `+`/`-` prefixed lines representing additions and removals
## MODIFIED Requirements
### Requirement: Diff Output
@@ -104,6 +104,14 @@ The archive process SHALL programmatically apply delta changes to current specif
### Requirement: Future State Storage
The system SHALL no longer store complete future-state specifications in change proposals.
**Reason for removal**: Replaced by delta-based change storage which provides better review experience and clearer change tracking.
**Migration path**: All new changes must use delta format.
**Migration path**: All new changes must use delta format.
#### Scenario: Deprecate future state storage
- **WHEN** creating a new change proposal
- **THEN** do not include full future-state specs
- **AND** include only ADDED/MODIFIED/REMOVED/RENAMED requirements under the change's `specs/` directory
@@ -25,4 +25,16 @@ Modify the update command to:
- Update command only modifies existing AI tool configuration files
- No new AI tool files created during update
- Team members can use different AI tools without conflicts
- Existing projects continue to work (backward compatibility)
- Existing projects continue to work (backward compatibility)
## Why
Users need predictable, tool-agnostic behavior from `openspec update`. Creating or forcing updates for AI tool files that a project does not use causes confusion and merge conflicts. Restricting updates to existing files and always updating core OpenSpec files keeps the workflow consistent for mixed-tool teams.
## What Changes
- **cli-update:** Modify update behavior to update only existing AI tool configuration files and never create new ones; always update core OpenSpec files and display an ASCII-safe success message.
## ADDED Requirements
Removed from proposal to follow conventions. See `specs/cli-update/spec.md` for the delta requirements content.
@@ -1,113 +1,23 @@
# Update Command Specification
## Purpose
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
## Core Requirements
### Requirement: Update Behavior
The update command SHALL update OpenSpec instruction files to the latest templates.
#### Scenario: Running update command
- **WHEN** a user runs `openspec update`
- **THEN** the command SHALL:
- Check if the `openspec` directory exists
- Replace `openspec/README.md` with the latest template (complete replacement)
- For each supported AI tool configuration file:
- Check if the file exists (e.g., CLAUDE.md, COPILOT.md)
- If it exists, update it using appropriate markers
- If it doesn't exist, skip it (do NOT create)
- Preserve user content outside markers
- Display ASCII-safe success message: "Updated OpenSpec instructions"
### Requirement: Prerequisites
The command SHALL require an existing OpenSpec structure before allowing updates.
#### Scenario: Checking prerequisites
- **GIVEN** the command requires an existing `openspec` directory (created by `openspec init`)
- **WHEN** the `openspec` directory does not exist
- **THEN** display error: "No OpenSpec directory found. Run 'openspec init' first."
- **AND** exit with code 1
### Requirement: File Handling
The update command SHALL handle file updates in a predictable and safe manner.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/README.md` with the latest template
- **AND** update only the AI tool configuration files that already exist
- **AND** use the default directory name `openspec`
- **AND** be idempotent (repeated runs have no additional effect)
## ADDED Requirements
### Requirement: Tool-Agnostic Updates
The update command SHALL work for any team member regardless of their AI tool choice.
The update command SHALL update only existing AI tool configuration files and SHALL NOT create new ones.
#### Scenario: Team member using Claude
#### Scenario: Updating existing tool files
- **GIVEN** a team member has CLAUDE.md in their project
- **WHEN** running `openspec update`
- **THEN** update the CLAUDE.md file with the latest template
- **AND** preserve user content outside OpenSpec markers
- **AND** NOT create files for other tools
#### Scenario: Team member using different tool
- **GIVEN** a team member has COPILOT.md but no CLAUDE.md
- **WHEN** running `openspec update`
- **THEN** update the COPILOT.md file if implementation exists
- **AND** NOT create CLAUDE.md
- **WHEN** a user runs `openspec update`
- **THEN** update each AI tool configuration file that exists (e.g., CLAUDE.md, COPILOT.md)
- **AND** do not create missing tool configuration files
- **AND** preserve user content outside OpenSpec markers
#### Scenario: Mixed team environment
### Requirement: Core Files Always Updated
- **GIVEN** a repository with both CLAUDE.md and COPILOT.md (different team members)
- **WHEN** any team member runs `openspec update`
- **THEN** update all existing AI tool configuration files
- **AND** NOT create new AI tool configuration files
- **AND** each team member's preferred tool remains configured
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
## Edge Cases
#### Scenario: Successful update
### Requirement: Error Handling
The command SHALL handle edge cases gracefully.
#### Scenario: File permission errors
- **WHEN** file write fails
- **THEN** let the error bubble up naturally with file path
#### Scenario: No AI tool files exist
- **GIVEN** no AI tool configuration files exist
- **WHEN** running update
- **THEN** only update openspec/README.md
- **AND** display success message
#### Scenario: Custom directory names
- **WHEN** considering custom directory names
- **THEN** not supported in this change
- **AND** the default directory name `openspec` SHALL be used
## Success Criteria
Users SHALL be able to:
- Update OpenSpec instructions with a single command
- Get the latest AI agent instructions for their existing tools
- Work in teams where members use different AI tools
- NOT have unwanted AI tool configuration files created
The update process SHALL be:
- Simple and fast (no version checking)
- Predictable (same result every time)
- Self-contained (no network required)
- Team-friendly (respects individual tool choices)
- **WHEN** the update completes successfully
- **THEN** replace `openspec/README.md` with the latest template
- **AND** update existing AI tool configuration files within markers
- **AND** display the message: "Updated OpenSpec instructions"
@@ -33,4 +33,4 @@ OpenSpec specifications lack a consistent structure that makes sections visually
- Affected specs: openspec-conventions (enhancement to existing capability)
- Affected code: None initially - this is a documentation standard enhancement
- Migration: Gradual - existing specs migrate as they're modified
- Tooling: Enables future parsing tools but doesn't require them
- Tooling: Enables future parsing tools but doesn't require them
@@ -1,5 +1,17 @@
# OpenSpec Conventions Specification
## ADDED Requirements
### Requirement: Structured Format Adoption
Behavioral specifications SHALL adopt the structured format with `### Requirement:` and `#### Scenario:` headers as the default.
#### Scenario: Use structured headings for behavior
- **WHEN** documenting behavioral requirements
- **THEN** use `### Requirement:` for requirements
- **AND** use `#### Scenario:` for scenarios with bold WHEN/THEN/AND keywords
## Purpose
OpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.
+2 -1
View File
@@ -37,7 +37,8 @@
"build": "node build.js",
"dev": "tsc --watch",
"dev:cli": "pnpm build && node bin/openspec.js",
"test": "vitest",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"prepare": "npm run build"
+28 -26
View File
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
import {
@@ -138,10 +139,12 @@ export class ArchiveCommand {
console.log(chalk.yellow(`Affected files: ${changeDir}`));
}
// Check for incomplete tasks
const tasksPath = path.join(changeDir, 'tasks.md');
const incompleteTasks = await this.checkIncompleteTasks(tasksPath);
// Show progress and check for incomplete tasks
const progress = await getTaskProgressForChange(changesDir, changeName);
const status = formatTaskStatus(progress);
console.log(`Task status: ${status}`);
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
if (incompleteTasks > 0) {
if (!options.yes) {
const proceed = await confirm({
@@ -250,11 +253,24 @@ export class ArchiveCommand {
return null;
}
console.log('Available changes:');
const choices = changeDirs.map(name => ({
name: name,
value: name
}));
// Build choices with progress inline to avoid duplicate lists
let choices: Array<{ name: string; value: string }> = changeDirs.map(name => ({ name, value: name }));
try {
const progressList: Array<{ id: string; status: string }> = [];
for (const id of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, id);
const status = formatTaskStatus(progress);
progressList.push({ id, status });
}
const nameWidth = Math.max(...progressList.map(p => p.id.length));
choices = progressList.map(p => ({
name: `${p.id.padEnd(nameWidth)} ${p.status}`,
value: p.id
}));
} catch {
// If anything fails, fall back to simple names
choices = changeDirs.map(name => ({ name, value: name }));
}
try {
const answer = await select({
@@ -268,23 +284,9 @@ export class ArchiveCommand {
}
}
private async checkIncompleteTasks(tasksPath: string): Promise<number> {
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
let incompleteTasks = 0;
for (const line of lines) {
if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
return incompleteTasks;
} catch {
// No tasks.md file or error reading it
return 0;
}
// Deprecated: replaced by shared task-progress utilities
private async checkIncompleteTasks(_tasksPath: string): Promise<number> {
return 0;
}
private async findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {
+5 -35
View File
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
interface ChangeInfo {
name: string;
@@ -33,35 +34,11 @@ export class ListCommand {
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const tasksPath = path.join(changesDir, changeDir, 'tasks.md');
let completedTasks = 0;
let incompleteTasks = 0;
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
if (line.includes('- [x]')) {
completedTasks++;
} else if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
} catch {
// No tasks.md file
changes.push({
name: changeDir,
completedTasks: 0,
totalTasks: 0
});
continue;
}
const progress = await getTaskProgressForChange(changesDir, changeDir);
changes.push({
name: changeDir,
completedTasks,
totalTasks: completedTasks + incompleteTasks
completedTasks: progress.completed,
totalTasks: progress.total
});
}
@@ -75,14 +52,7 @@ export class ListCommand {
const nameWidth = Math.max(...changes.map(c => c.name.length));
const paddedName = change.name.padEnd(nameWidth);
let status: string;
if (change.totalTasks === 0) {
status = 'No tasks';
} else if (change.completedTasks === change.totalTasks) {
status = '✓ Complete';
} else {
status = `${change.completedTasks}/${change.totalTasks} tasks`;
}
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
console.log(`${padding}${paddedName} ${status}`);
}
+4 -3
View File
@@ -94,7 +94,8 @@ export class ChangeParser extends MarkdownParser {
spec: specName,
operation: 'ADDED' as DeltaOperation,
description: `Add requirement: ${req.text}`,
requirement: req,
// Use plural form to satisfy validators that expect an array
requirements: [req],
});
});
}
@@ -108,7 +109,7 @@ export class ChangeParser extends MarkdownParser {
spec: specName,
operation: 'MODIFIED' as DeltaOperation,
description: `Modify requirement: ${req.text}`,
requirement: req,
requirements: [req],
});
});
}
@@ -122,7 +123,7 @@ export class ChangeParser extends MarkdownParser {
spec: specName,
operation: 'REMOVED' as DeltaOperation,
description: `Remove requirement: ${req.text}`,
requirement: req,
requirements: [req],
});
});
}
+43
View File
@@ -0,0 +1,43 @@
import { promises as fs } from 'fs';
import path from 'path';
const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
export interface TaskProgress {
total: number;
completed: number;
}
export function countTasksFromContent(content: string): TaskProgress {
const lines = content.split('\n');
let total = 0;
let completed = 0;
for (const line of lines) {
if (line.match(TASK_PATTERN)) {
total++;
if (line.match(COMPLETED_TASK_PATTERN)) {
completed++;
}
}
}
return { total, completed };
}
export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress> {
const tasksPath = path.join(changesDir, changeName, 'tasks.md');
try {
const content = await fs.readFile(tasksPath, 'utf-8');
return countTasksFromContent(content);
} catch {
return { total: 0, completed: 0 };
}
}
export function formatTaskStatus(progress: TaskProgress): string {
if (progress.total === 0) return 'No tasks';
if (progress.completed === progress.total) return '✓ Complete';
return `${progress.completed}/${progress.total} tasks`;
}
+7 -7
View File
@@ -569,14 +569,14 @@ E1 updated`);
// Execute without change name
await archiveCommand.execute(undefined, { yes: true });
// Verify select was called with correct options
expect(mockSelect).toHaveBeenCalledWith({
// Verify select was called with correct options (values matter, names may include progress)
expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({
message: 'Select a change to archive',
choices: [
{ name: change1, value: change1 },
{ name: change2, value: change2 }
]
});
choices: expect.arrayContaining([
expect.objectContaining({ value: change1 }),
expect.objectContaining({ value: change2 })
])
}));
// Verify the selected change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');