Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 4772d71b4e Merge main into fix-windsurf-workrules
Resolve conflict in docs/supported-tools.md: keep both the new Trae
entry from main and the updated Windsurf workflows path from this branch.
2026-01-29 17:57:26 -08:00
Haven-SandTabish Bidiwale ddbfa529f4 docs: modernize opsx.md (#616)
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-29 17:38:23 -08:00
Costica Puntaru [Tica]andTabish Bidiwale d3c3d66e67 fix(archive): fall back to copy+remove on EPERM/EXDEV (fixes #197) (#605)
On Windows, fs.rename() often fails with EPERM when moving non-empty
directories. Fall back to recursive copy then rm when rename throws
EPERM or EXDEV so 'openspec archive' succeeds where Move-Item works.

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-29 17:26:58 -08:00
moe1214andTabish Bidiwale 277be194ef docs: support Trae AI (#601)
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-29 17:23:30 -08:00
Tabish Bidiwale f45ba73a5f feat: add openspec dashboard command for web-based project browsing (#615)
Implements a new `openspec dashboard` command that serves a local HTTP server with a web-based dashboard for exploring changes, specs, and archive. Features include:
- Three-tab navigation for Changes, Specifications, and Archive
- Click artifacts to view rendered markdown in a detail panel
- Domain-grouped specs with requirement counts
- Task progress tracking for active changes
- Artifact status indicators (proposal, specs, design, tasks)
- Archive pagination with reverse chronological sorting
- Zero external dependencies (Node.js built-in http module)
- Port auto-increment (3000-3010) with --port override
- Cross-platform browser opening (macOS, Linux, Windows)
- Path traversal prevention on artifact API

Includes comprehensive tests for markdown renderer, data gathering, and API security (43 tests, all passing).
2026-01-29 16:45:07 -08:00
yangjun d59a0b347a fix a missing update 2026-01-28 22:35:59 +08:00
yangjun 04dc6deb5d fix windsurf workrules 2026-01-28 21:56:58 +08:00
Tabish Bidiwale 41305753b4 Fix schema listing command in migration guide (#608) 2026-01-27 19:59:47 -08:00
Jérôme BenoitandTabish Bidiwale 86d2e04cae chore(nix): improve flake with dynamic version and build optimization (#550)
* chore(nix): improve flake with dynamic version and source filtering

- Read version dynamically from package.json instead of hardcoding
- Add lib.fileset source filtering to exclude node_modules and build artifacts
- Update update-flake.sh to support dynamic version pattern
- Add hash change detection to skip unnecessary rebuilds
- Improve error handling with automatic rollback on failure
- Update specs to reflect dynamic version behavior

* chore(ci): bump Nix actions to latest versions

- nix-installer-action: v13 → v21
- magic-nix-cache-action: v8 → v13
- Update validation message for unchanged flake.nix

* chore: add changeset for Nix improvements

* fix(nix): make update-flake.sh portable to macOS

- Fix grep pattern on line 37 to include opening parenthesis
- Replace GNU grep -oP with portable sed alternatives (lines 53, 68, 70)
- Ensures script works on both Linux and macOS (BSD sed/grep)

* fix(nix): properly check build verification exit status

Fix logic bug where build failures were incorrectly reported as success.
The script now:
- Captures build exit code and output separately
- Fails fast if build returns non-zero exit code
- Only checks for 'dirty tree' warning if build succeeded

This addresses CodeRabbit review feedback on line 101-107.

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-27 14:23:11 -08:00
openspec-release-bot[bot]andgithub-actions[bot] f6b415cb9b Version Packages (#597)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-26 19:33:25 -08:00
Tabish Bidiwale e91568deb9 Add changeset for spec naming clarification (#596) 2026-01-26 19:12:01 -08:00
Tabish Bidiwale fc0d798f93 fix: clarify spec naming convention and task checkbox format (#595)
* fix: clarify spec naming convention and task checkbox format

- Update docs, schema, and templates to clarify that specs should be
  named after capabilities (specs/<capability>/spec.md), not changes
- Emphasize that tasks MUST use checkbox format for apply phase tracking

* fix: clarify delta spec location for modified capabilities

Address review feedback: explicitly state that the delta spec is created
at specs/<capability>/spec.md, not in openspec/specs/<capability>/.
2026-01-26 18:12:56 -08:00
openspec-release-bot[bot]andgithub-actions[bot] d155126235 Version Packages (#588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-26 02:51:24 -08:00
Tabish Bidiwale 943e0d4102 Add changeset for archive path fix (#587) 2026-01-26 02:47:28 -08:00
Tabish Bidiwale 12a7224dc6 chore: remove TDD schema and all references (#586)
* chore: remove TDD schema and all references

TDD was an internal test example that should not be in user-facing docs.
This removes:
- The schemas/tdd directory and all its templates
- All TDD references from documentation
- TDD examples from skill templates and source code comments

* test: update tests to remove TDD schema references

All tests that referenced the removed TDD schema have been updated
to use spec-driven or custom-schema names instead.

* chore: remove accidentally committed files
2026-01-26 02:31:48 -08:00
Tabish Bidiwale c773ef6feb fix: correct archive path in onboarding template (#585)
The onboarding template incorrectly documented the archive path as
`openspec/archive/YYYY-MM-DD--<name>/` when the actual implementation
uses `openspec/changes/archive/YYYY-MM-DD-<name>/`.

This fixes two issues:
- Missing `changes/` directory in the path
- Double dash `--` instead of single dash `-`

Fixes discussion #583
2026-01-26 02:04:46 -08:00
Tabish Bidiwale 0bfe1d4426 docs: rewrite customization guide to document schema commands (#582)
* docs: rewrite customization guide to document schema commands

The old guide described a painful manual process for schema customization
(mkdir, npm list, cp commands) and even listed "No scaffolding" as a
limitation. But the `openspec schema` commands have existed for a while:

- `schema fork` - copy existing schema to customize
- `schema init` - create new schema from scratch
- `schema validate` - check schema structure
- `schema which` - debug resolution precedence

Rewrote the guide to:
- Lead with the actual CLI commands instead of manual steps
- Remove the misleading "Current Limitations" section
- Add practical examples (TDD workflow, adding review artifact)
- Structure progressively: config → custom schemas → global overrides

* docs: add language tags to code blocks in customization guide

Address review feedback from CodeRabbit:
- Add 'text' language tag to directory tree code blocks
- Satisfies MD040 markdown lint rule
2026-01-25 23:37:46 -08:00
49 changed files with 2302 additions and 641 deletions
+13
View File
@@ -0,0 +1,13 @@
---
"@fission-ai/openspec": patch
---
### Improvements
- **Nix flake maintenance** — Version now read dynamically from package.json, reducing manual sync issues
- **Nix build optimization** — Source filtering excludes node_modules and artifacts, improving build times
- **update-flake.sh script** — Detects when hash is already correct, skipping unnecessary rebuilds
### Other
- Updated Nix CI actions to latest versions (nix-installer v21, magic-nix-cache v13)
+3 -3
View File
@@ -190,10 +190,10 @@ jobs:
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v13
uses: DeterminateSystems/nix-installer-action@v21
- name: Setup Nix cache
uses: DeterminateSystems/magic-nix-cache-action@v8
uses: DeterminateSystems/magic-nix-cache-action@v13
- name: Build with Nix
run: nix build
@@ -229,7 +229,7 @@ jobs:
- name: Check flake.nix modifications
run: |
if git diff --quiet flake.nix; then
echo "⚠️ Warning: flake.nix was not modified by update script"
echo "ℹ️ flake.nix unchanged (hash already up-to-date)"
else
echo "✅ flake.nix was updated by script"
git diff flake.nix
+17
View File
@@ -1,5 +1,22 @@
# @fission-ai/openspec
## 1.0.2
### Patch Changes
- [#596](https://github.com/Fission-AI/OpenSpec/pull/596) [`e91568d`](https://github.com/Fission-AI/OpenSpec/commit/e91568deb948073f3e9d9bb2d2ab5bf8080d6cf4) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes
- Clarified spec naming convention — Specs should be named after capabilities (`specs/<capability>/spec.md`), not changes
- Fixed task checkbox format guidance — Tasks now clearly require `- [ ]` checkbox format for apply phase tracking
## 1.0.1
### Patch Changes
- [#587](https://github.com/Fission-AI/OpenSpec/pull/587) [`943e0d4`](https://github.com/Fission-AI/OpenSpec/commit/943e0d41026d034de66b9442d1276c01b293eb2b) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes
- Fixed incorrect archive path in onboarding documentation — the template now shows the correct path `openspec/changes/archive/YYYY-MM-DD-<name>/` instead of the incorrect `openspec/archive/YYYY-MM-DD--<name>/`
## 1.0.0
### Major Changes
+5 -9
View File
@@ -527,7 +527,7 @@ openspec templates [options]
openspec templates
# Show templates for custom schema
openspec templates --schema tdd
openspec templates --schema my-workflow
# JSON for programmatic use
openspec templates --json
@@ -576,10 +576,6 @@ Available schemas:
The default spec-driven development workflow
Flow: proposal → specs → design → tasks
tdd (package)
Test-driven development workflow
Flow: spec → tests → implementation → docs
my-custom (project)
Custom workflow for this project
Flow: research → proposal → tasks
@@ -623,9 +619,9 @@ openspec schema init <name> [options]
openspec schema init research-first
# Non-interactive with specific artifacts
openspec schema init tdd-lite \
--description "Lightweight TDD workflow" \
--artifacts "spec,tests,implementation" \
openspec schema init rapid \
--description "Rapid iteration workflow" \
--artifacts "proposal,tasks" \
--default
```
@@ -745,7 +741,7 @@ spec-driven resolves from: package
**Schema precedence:**
1. Project: `openspec/schemas/<name>/`
2. User: `~/.openspec/schemas/<name>/`
2. User: `~/.local/share/openspec/schemas/<name>/`
3. Package: Built-in schemas
---
+1 -10
View File
@@ -115,16 +115,6 @@ AI: Created openspec/changes/add-dark-mode/
Use /opsx:continue to create it, or /opsx:ff to create all artifacts.
```
**Example with schema:**
```
You: /opsx:new write-tests --schema tdd
AI: Created openspec/changes/write-tests/
Schema: tdd
Ready to create: spec
```
**Tips:**
- Use descriptive names: `add-feature`, `fix-bug`, `refactor-module`
- Avoid generic names like `update`, `changes`, `wip`
@@ -579,6 +569,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
| Cursor | `/opsx-new`, `/opsx-apply` |
| Windsurf | `/opsx-new`, `/opsx-apply` |
| Copilot | `/opsx-new`, `/opsx-apply` |
| Trae | `/openspec-new-change`, `/openspec-apply-change` |
The functionality is identical regardless of syntax.
-10
View File
@@ -420,16 +420,6 @@ proposal → specs → design → tasks → implement
Best for: Most feature work where you want to agree on specs before implementation.
**tdd**
Test-driven development workflow:
```
spec → tests → implementation → docs
```
Best for: Teams practicing TDD who write tests before implementation.
### Custom Schemas
Create custom schemas for your team's workflow:
+248 -90
View File
@@ -1,29 +1,30 @@
# Customization
OpenSpec provides two levels of customization:
OpenSpec provides three levels of customization:
1. **Project Config** (`openspec/config.yaml`) - Lightweight per-project customization for default schemas, shared context, and per-artifact rules
2. **Schema Overrides** - Full schema and template customization via XDG directories
| Level | What it does | Best for |
|-------|--------------|----------|
| **Project Config** | Set defaults, inject context/rules | Most teams |
| **Custom Schemas** | Define your own workflow artifacts | Teams with unique processes |
| **Global Overrides** | Share schemas across all projects | Power users |
---
## Project Configuration
The `openspec/config.yaml` file provides a lightweight customization layer that lets teams:
The `openspec/config.yaml` file is the easiest way to customize OpenSpec for your team. It lets you:
- **Set a default schema** - New changes automatically use this schema instead of specifying `--schema` every time
- **Inject project context** - Shared context (tech stack, conventions) shown to AI when creating any artifact
- **Add per-artifact rules** - Custom rules that only apply to specific artifacts (e.g., proposal, specs)
- **Set a default schema** - Skip `--schema` on every command
- **Inject project context** - AI sees your tech stack, conventions, etc.
- **Add per-artifact rules** - Custom rules for specific artifacts
### Creating a Config
You can create the config through the interactive setup:
### Quick Setup
```bash
openspec init
```
Or create it manually:
This walks you through creating a config interactively. Or create one manually:
```yaml
# openspec/config.yaml
@@ -44,26 +45,25 @@ rules:
- Reference existing patterns before inventing new ones
```
### How Config Affects Workflows
### How It Works
**Default schema selection:**
**Default schema:**
```bash
# Without config: must specify schema
# Without config
openspec new change my-feature --schema spec-driven
# With config: schema is automatic
# With config - schema is automatic
openspec new change my-feature
```
**Context and rules injection:**
When generating instructions for an artifact, context and rules are injected:
When generating any artifact, your context and rules are injected into the AI prompt:
```xml
<context>
Tech stack: TypeScript, React, Node.js, PostgreSQL
API style: RESTful, documented in docs/api.md
...
</context>
@@ -80,105 +80,263 @@ API style: RESTful, documented in docs/api.md
- **Context** appears in ALL artifacts
- **Rules** ONLY appear for the matching artifact
### Schema Resolution Precedence
### Schema Resolution Order
1. CLI flag wins: `openspec new change feature --schema tdd`
2. Change metadata (if `.openspec.yaml` specifies schema)
When OpenSpec needs a schema, it checks in this order:
1. CLI flag: `--schema <name>`
2. Change metadata (`.openspec.yaml` in the change folder)
3. Project config (`openspec/config.yaml`)
4. Default schema (`spec-driven`)
4. Default (`spec-driven`)
### Error Handling
---
The config provides graceful error handling:
## Custom Schemas
When project config isn't enough, create your own schema with a completely custom workflow. Custom schemas live in your project's `openspec/schemas/` directory and are version-controlled with your code.
```text
your-project/
├── openspec/
│ ├── config.yaml # Project config
│ ├── schemas/ # Custom schemas live here
│ │ └── my-workflow/
│ │ ├── schema.yaml
│ │ └── templates/
│ └── changes/ # Your changes
└── src/
```
### Fork an Existing Schema
The fastest way to customize is to fork a built-in schema:
```bash
# Typo in schema name - shows suggestions
# Schema 'spec-drivne' not found
# Did you mean: spec-driven (built-in)
openspec schema fork spec-driven my-workflow
```
# Unknown artifact ID in rules - warns but continues
# ⚠️ Unknown artifact ID in rules: "testplan". Valid IDs for schema "spec-driven": ...
This copies the entire `spec-driven` schema to `openspec/schemas/my-workflow/` where you can edit it freely.
**What you get:**
```text
openspec/schemas/my-workflow/
├── schema.yaml # Workflow definition
└── templates/
├── proposal.md # Template for proposal artifact
├── spec.md # Template for specs
├── design.md # Template for design
└── tasks.md # Template for tasks
```
Now edit `schema.yaml` to change the workflow, or edit templates to change what AI generates.
### Create a Schema from Scratch
For a completely fresh workflow:
```bash
# Interactive
openspec schema init research-first
# Non-interactive
openspec schema init rapid \
--description "Rapid iteration workflow" \
--artifacts "proposal,tasks" \
--default
```
### Schema Structure
A schema defines the artifacts in your workflow and how they depend on each other:
```yaml
# openspec/schemas/my-workflow/schema.yaml
name: my-workflow
version: 1
description: My team's custom workflow
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal document
template: proposal.md
instruction: |
Create a proposal that explains WHY this change is needed.
Focus on the problem, not the solution.
requires: []
- id: design
generates: design.md
description: Technical design
template: design.md
instruction: |
Create a design document explaining HOW to implement.
requires:
- proposal # Can't create design until proposal exists
- id: tasks
generates: tasks.md
description: Implementation checklist
template: tasks.md
requires:
- design
apply:
requires: [tasks]
tracks: tasks.md
```
**Key fields:**
| Field | Purpose |
|-------|---------|
| `id` | Unique identifier, used in commands and rules |
| `generates` | Output filename (supports globs like `specs/**/*.md`) |
| `template` | Template file in `templates/` directory |
| `instruction` | AI instructions for creating this artifact |
| `requires` | Dependencies - which artifacts must exist first |
### Templates
Templates are markdown files that guide the AI. They're injected into the prompt when creating that artifact.
```markdown
<!-- templates/proposal.md -->
## Why
<!-- Explain the motivation for this change. What problem does this solve? -->
## What Changes
<!-- Describe what will change. Be specific about new capabilities or modifications. -->
## Impact
<!-- Affected code, APIs, dependencies, systems -->
```
Templates can include:
- Section headers the AI should fill in
- HTML comments with guidance for the AI
- Example formats showing expected structure
### Validate Your Schema
Before using a custom schema, validate it:
```bash
openspec schema validate my-workflow
```
This checks:
- `schema.yaml` syntax is correct
- All referenced templates exist
- No circular dependencies
- Artifact IDs are valid
### Use Your Custom Schema
Once created, use your schema with:
```bash
# Specify on command
openspec new change feature --schema my-workflow
# Or set as default in config.yaml
schema: my-workflow
```
### Debug Schema Resolution
Not sure which schema is being used? Check with:
```bash
# See where a specific schema resolves from
openspec schema which my-workflow
# List all available schemas
openspec schema which --all
```
Output shows whether it's from your project, user directory, or the package:
```text
Schema: my-workflow
Source: project
Path: /path/to/project/openspec/schemas/my-workflow
```
---
## Schema Overrides
> **Note:** OpenSpec also supports user-level schemas at `~/.local/share/openspec/schemas/` for sharing across projects, but project-level schemas in `openspec/schemas/` are recommended since they're version-controlled with your code.
For deeper customization, you can override entire schemas or templates.
---
### How Schema Resolution Works
## Examples
OpenSpec uses a 2-level schema resolution system following the XDG Base Directory Specification:
### Rapid Iteration Workflow
1. **User override**: `${XDG_DATA_HOME}/openspec/schemas/<name>/`
2. **Package built-in**: `<npm-package>/schemas/<name>/`
When a schema is requested, the resolver checks the user directory first. If found, that entire schema directory is used. Otherwise, it falls back to the package's built-in schema.
### Override Directories
| Platform | Path |
|----------|------|
| macOS/Linux | `~/.local/share/openspec/schemas/` |
| Windows | `%LOCALAPPDATA%\openspec\schemas\` |
| All (if set) | `$XDG_DATA_HOME/openspec/schemas/` |
### Manual Schema Override
To override the default `spec-driven` schema:
**1. Create the directory structure:**
```bash
# macOS/Linux
mkdir -p ~/.local/share/openspec/schemas/spec-driven/templates
```
**2. Find and copy the default schema files:**
```bash
# Find the package location
npm list -g openspec --parseable
# Copy files from the package's schemas/ directory
cp <package-path>/schemas/spec-driven/schema.yaml ~/.local/share/openspec/schemas/spec-driven/
cp <package-path>/schemas/spec-driven/templates/*.md ~/.local/share/openspec/schemas/spec-driven/templates/
```
**3. Modify the copied files:**
Edit `schema.yaml` to change the workflow structure:
A minimal workflow for quick iterations:
```yaml
name: spec-driven
# openspec/schemas/rapid/schema.yaml
name: rapid
version: 1
description: My custom workflow
description: Fast iteration with minimal overhead
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal
description: Quick proposal
template: proposal.md
instruction: |
Create a brief proposal for this change.
Focus on what and why, skip detailed specs.
requires: []
# Add, remove, or modify artifacts...
- id: tasks
generates: tasks.md
description: Implementation checklist
template: tasks.md
requires: [proposal]
apply:
requires: [tasks]
tracks: tasks.md
```
Edit templates in `templates/` to customize the content guidance.
### Adding a Review Artifact
### Current Limitations
Fork the default and add a review step:
| Issue | Impact |
|-------|--------|
| **Path discovery** | Users must know XDG conventions and platform-specific paths |
| **Package location** | Finding the npm package path varies by install method |
| **No scaffolding** | Users must manually create directories and copy files |
| **No verification** | No way to confirm which schema is actually being resolved |
| **Full copy required** | Must copy entire schema even to change one template |
```bash
openspec schema fork spec-driven with-review
```
### Related Files
Then edit `schema.yaml` to add:
| File | Purpose |
|------|---------|
| `src/core/artifact-graph/resolver.ts` | Schema resolution logic |
| `src/core/artifact-graph/instruction-loader.ts` | Template loading |
| `src/core/global-config.ts` | XDG path helpers |
| `schemas/spec-driven/` | Default schema and templates |
```yaml
- id: review
generates: review.md
description: Pre-implementation review checklist
template: review.md
instruction: |
Create a review checklist based on the design.
Include security, performance, and testing considerations.
requires:
- design
- id: tasks
# ... existing tasks config ...
requires:
- specs
- design
- review # Now tasks require review too
```
---
## See Also
- [CLI Reference: Schema Commands](cli.md#schema-commands) - Full command documentation
+3 -5
View File
@@ -452,7 +452,7 @@ rules:
When determining which schema to use, OPSX checks in order:
1. **CLI flag**: `--schema tdd` (highest priority)
1. **CLI flag**: `--schema <name>` (highest priority)
2. **Change metadata**: `.openspec.yaml` in the change directory
3. **Project config**: `openspec/config.yaml`
4. **Default**: `spec-driven`
@@ -462,12 +462,11 @@ When determining which schema to use, OPSX checks in order:
| Schema | Artifacts | Best For |
|--------|-----------|----------|
| `spec-driven` | proposal → specs → design → tasks | Most projects |
| `tdd` | spec → tests → implementation → docs | Test-first development |
List all available schemas:
```bash
openspec workflow schemas
openspec schemas
```
### Custom Schemas
@@ -507,12 +506,11 @@ Restart your IDE. Skills are detected at startup.
Check that your `rules:` keys match your schema's artifact IDs:
- **spec-driven**: `proposal`, `specs`, `design`, `tasks`
- **tdd**: `spec`, `tests`, `implementation`, `docs`
Run this to see valid artifact IDs:
```bash
openspec workflow schemas --json
openspec schemas --json
```
### Config not being applied
+20 -28
View File
@@ -1,14 +1,16 @@
# OPSX Workflow
> **Compatibility:** Claude Code only (for now). Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).
> Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).
## What Is It?
OPSX is a **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.
OPSX is now the standard workflow for OpenSpec.
It's a **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.
## Why This Exists
The standard OpenSpec workflow works, but it's **locked down**:
The legacy OpenSpec workflow works, but it's **locked down**:
- **Instructions are hardcoded** — buried in TypeScript, you can't change them
- **All-or-nothing** — one big command creates everything, can't test individual pieces
@@ -23,7 +25,7 @@ The standard OpenSpec workflow works, but it's **locked down**:
4. **Iterate quickly** — change a template, test immediately, no rebuild
```
Standard workflow: OPSX:
Legacy workflow: OPSX:
┌────────────────────────┐ ┌────────────────────────┐
│ Hardcoded in package │ │ schema.yaml │◄── You edit this
│ (can't change) │ │ templates/*.md │◄── Or this
@@ -57,14 +59,11 @@ You're "in planning phase", then "in implementation phase", then "done". But rea
## Setup
```bash
# 1. Make sure you have openspec installed and initialized
# Make sure you have openspec installed — skills are automatically generated
openspec init
# 2. Generate the experimental skills
openspec experimental
```
This creates skills in `.claude/skills/` that Claude Code auto-detects.
This creates skills in `.claude/skills/` (or equivalent) that AI coding assistants auto-detect.
During setup, you'll be prompted to create a **project config** (`openspec/config.yaml`). This is optional but recommended.
@@ -74,7 +73,7 @@ Project config lets you set defaults and inject project-specific context into al
### Creating Config
Config is created during `experimental`, or manually:
Config is created during `openspec init`, or manually:
```yaml
# openspec/config.yaml
@@ -100,14 +99,14 @@ rules:
| Field | Type | Description |
|-------|------|-------------|
| `schema` | string | Default schema for new changes (e.g., `spec-driven`, `tdd`) |
| `schema` | string | Default schema for new changes (e.g., `spec-driven`) |
| `context` | string | Project context injected into all artifact instructions |
| `rules` | object | Per-artifact rules, keyed by artifact ID |
### How It Works
**Schema precedence** (highest to lowest):
1. CLI flag (`--schema tdd`)
1. CLI flag (`--schema <name>`)
2. Change metadata (`.openspec.yaml` in change directory)
3. Project config (`openspec/config.yaml`)
4. Default (`spec-driven`)
@@ -130,12 +129,6 @@ rules:
- `design` — Technical design
- `tasks` — Implementation tasks
**tdd**:
- `spec` — Feature specification
- `tests` — Test file
- `implementation` — Implementation code
- `docs` — Documentation
### Config Validation
- Unknown artifact IDs in `rules` generate warnings
@@ -294,7 +287,7 @@ Think of it like git branches:
## What's Different?
| | Standard (`/openspec:proposal`) | Experimental (`/opsx:*`) |
| | Legacy (`/openspec:proposal`) | OPSX (`/opsx:*`) |
|---|---|---|
| **Structure** | One big proposal document | Discrete artifacts with dependencies |
| **Workflow** | Linear phases: plan → implement → archive | Fluid actions — do anything anytime |
@@ -305,13 +298,13 @@ Think of it like git branches:
## Architecture Deep Dive
This section explains how OPSX works under the hood and how it compares to the standard workflow.
This section explains how OPSX works under the hood and how it compares to the legacy workflow.
### Philosophy: Phases vs Actions
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STANDARD WORKFLOW │
│ LEGACY WORKFLOW │
│ (Phase-Locked, All-or-Nothing) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
@@ -353,11 +346,11 @@ This section explains how OPSX works under the hood and how it compares to the s
### Component Architecture
**Standard workflow** uses hardcoded templates in TypeScript:
**Legacy workflow** uses hardcoded templates in TypeScript:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STANDARD WORKFLOW COMPONENTS │
│ LEGACY WORKFLOW COMPONENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Hardcoded Templates (TypeScript strings) │
@@ -452,7 +445,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
### Information Flow
**Standard workflow** — agent receives static instructions:
**Legacy workflow** — agent receives static instructions:
```
User: "/openspec:proposal"
@@ -463,7 +456,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
│ • Create proposal.md │
│ • Create tasks.md │
│ • Create design.md │
│ • Create specs/*.md │
│ • Create specs/<capability>/spec.md │
│ │
│ No awareness of what exists or │
│ dependencies between artifacts │
@@ -511,7 +504,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
### Iteration Model
**Standard workflow** — awkward to iterate:
**Legacy workflow** — awkward to iterate:
```
┌─────────┐ ┌─────────┐ ┌─────────┐
@@ -604,7 +597,7 @@ artifacts:
### Summary
| Aspect | Standard | OPSX |
| Aspect | Legacy | OPSX |
|--------|----------|------|
| **Templates** | Hardcoded TypeScript | External YAML + Markdown |
| **Dependencies** | None (all at once) | DAG with topological sort |
@@ -618,7 +611,6 @@ artifacts:
Schemas define what artifacts exist and their dependencies. Currently available:
- **spec-driven** (default): proposal → specs → design → tasks
- **tdd**: tests → implementation → docs
```bash
# List available schemas
+3 -2
View File
@@ -33,7 +33,8 @@ For each tool you select, OpenSpec installs:
| Qoder | `.qoder/skills/` | `.qoder/commands/opsx/` |
| Qwen Code | `.qwen/skills/` | `.qwen/commands/` |
| RooCode | `.roo/skills/` | `.roo/commands/` |
| Windsurf | `.windsurf/skills/` | `.windsurf/commands/opsx/` |
| Trae | `.trae/skills/` | `.trae/skills/` (via `/openspec-*`) |
| Windsurf | `.windsurf/skills/` | `.windsurf/workflows/` |
## Non-Interactive Setup
@@ -50,7 +51,7 @@ openspec init --tools all
openspec init --tools none
```
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `opencode`, `qoder`, `qwen`, `roocode`, `windsurf`
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `opencode`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
## What Gets Installed
+35 -8
View File
@@ -5,23 +5,47 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
outputs =
{ self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
in
{
packages = forAllSystems (system:
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
in
{
default = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "openspec";
version = "0.23.0";
version = (builtins.fromJSON (builtins.readFile ./package.json)).version;
src = ./.;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./src
./bin
./schemas
./scripts
./test
./package.json
./pnpm-lock.yaml
./tsconfig.json
./build.js
./vitest.config.ts
./vitest.setup.ts
./eslint.config.js
];
};
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
@@ -55,7 +79,8 @@
mainProgram = "openspec";
};
});
});
}
);
apps = forAllSystems (system: {
default = {
@@ -64,7 +89,8 @@
};
});
devShells = forAllSystems (system:
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
@@ -82,6 +108,7 @@
echo "Run 'pnpm install' to install dependencies"
'';
};
});
}
);
};
}
@@ -0,0 +1,60 @@
## 1. Project Setup and Command Registration
- [x] 1.1 Create directory structure: `src/core/dashboard/` with `index.ts`, `server.ts`, `data.ts`, `markdown.ts`
- [x] 1.2 Register `openspec dashboard` command in `src/cli/index.ts` with `--port` and `--no-open` options
- [x] 1.3 Create `DashboardCommand` class in `src/core/dashboard/index.ts` that validates openspec directory exists
## 2. Data Gathering Module
- [x] 2.1 Implement `getChangesData()` in `data.ts` that returns draft/active/completed changes with artifact existence status (proposal.md, specs/, design.md, tasks.md)
- [x] 2.2 Implement `getSpecsData()` in `data.ts` that returns specs grouped by domain prefix with requirement counts
- [x] 2.3 Implement `getArchiveData()` in `data.ts` that returns archived changes with parsed dates, sorted reverse chronologically
- [x] 2.4 Implement `getSummary()` in `data.ts` that aggregates counts for dashboard summary section
- [x] 2.5 Implement `getArtifactContent()` in `data.ts` that reads a markdown file by relative path with path traversal protection
## 3. Markdown Renderer
- [x] 3.1 Implement markdown-to-HTML converter in `markdown.ts` handling headings, paragraphs, bold, italic, and line breaks
- [x] 3.2 Add support for fenced code blocks and inline code
- [x] 3.3 Add support for unordered lists, ordered lists, and checkboxes
- [x] 3.4 Add support for blockquotes, horizontal rules, and links
## 4. HTTP Server and API
- [x] 4.1 Implement HTTP server in `server.ts` using Node.js built-in `http` module
- [x] 4.2 Add route `GET /` that serves the embedded single-page HTML dashboard
- [x] 4.3 Add route `GET /api/summary` returning aggregated project data
- [x] 4.4 Add route `GET /api/changes` returning changes with artifact status
- [x] 4.5 Add route `GET /api/specs` returning specs grouped by domain
- [x] 4.6 Add route `GET /api/archive` returning archive entries with pagination (default 50)
- [x] 4.7 Add route `GET /api/artifact?path=<relative>` returning rendered markdown HTML with path traversal guard
- [x] 4.8 Implement port selection with auto-increment from default 3000 to 3010
- [x] 4.9 Implement cross-platform browser opening (open/xdg-open/start)
- [x] 4.10 Add graceful shutdown on SIGINT/SIGTERM
## 5. Dashboard HTML/CSS/JS
- [x] 5.1 Create embedded HTML template with navigation tabs for Changes, Specs, and Archive sections
- [x] 5.2 Implement Changes view with status grouping (draft/active/completed), artifact indicators, and progress bars
- [x] 5.3 Implement Specs view with domain-prefix grouping and requirement count display
- [x] 5.4 Implement Archive view with date-based listing and "load more" pagination
- [x] 5.5 Implement artifact detail panel that fetches and displays rendered markdown on click
- [x] 5.6 Add basic responsive CSS styling with monospace fonts matching terminal aesthetic
## 6. Testing
- [x] 6.1 Add unit tests for markdown renderer in `test/core/dashboard/markdown.test.ts`
- [x] 6.2 Add unit tests for data gathering functions in `test/core/dashboard/data.test.ts`
- [x] 6.3 Add unit tests for path traversal prevention in artifact endpoint
- [x] 6.4 Add unit tests for port selection logic
- [x] 6.5 Add unit tests for domain grouping logic
- [x] 6.6 Add unit tests for archive date parsing
## 7. Polish and Integration
- [x] 7.1 Add CLI help text for the dashboard command
- [x] 7.2 Add shell completion entries for dashboard command and its options
- [x] 7.3 Handle edge cases: empty project, missing directories, unparseable specs
- [x] 7.4 Ensure cross-platform path handling with `path.join()` throughout
- [x] 7.5 Run linting and fix any issues (`pnpm run lint`)
- [x] 7.6 Run full test suite (`pnpm test`)
@@ -1,17 +1,18 @@
## ADDED Requirements
### Requirement: Automatic Version Update
The script SHALL automatically update the version in flake.nix to match package.json.
### Requirement: Dynamic Version Support
The script SHALL support flake.nix configurations that read version dynamically from package.json.
#### Scenario: Version extraction from package.json
#### Scenario: Version validation
- **WHEN** script runs
- **THEN** version is read from package.json using Node.js
- **AND** version field in flake.nix is updated to match
- **AND** script verifies flake.nix uses dynamic version pattern
- **AND** warns if hardcoded version is detected
#### Scenario: Version already up-to-date
- **WHEN** script runs and flake.nix version already matches package.json
- **THEN** script reports version is up-to-date
- **AND** continues to hash update
#### Scenario: Version display
- **WHEN** script runs
- **THEN** script displays current package version
- **AND** indicates version is read dynamically by flake.nix
### Requirement: Automatic Hash Determination
The script SHALL automatically determine and update the correct pnpm dependency hash.
@@ -29,7 +30,8 @@ The script SHALL automatically determine and update the correct pnpm dependency
#### Scenario: Hash update failure
- **WHEN** script cannot extract hash from build output
- **THEN** script exits with error
- **THEN** script restores original hash to flake.nix
- **AND** exits with error code 1
- **AND** displays build output for debugging
### Requirement: Build Verification
@@ -55,8 +57,13 @@ The script SHALL provide clear progress information and next steps.
#### Scenario: Success summary
- **WHEN** script completes successfully
- **THEN** summary shows updated version and hash
- **AND** next steps are displayed (test, commit, etc.)
- **THEN** summary shows version and hash changes
- **AND** next steps are displayed (test, verify, commit)
#### Scenario: No changes needed
- **WHEN** hash is already up-to-date
- **THEN** script reports no changes needed
- **AND** exits with success code 0
### Requirement: Script Safety
The script SHALL fail fast on errors and use safe defaults.
@@ -49,7 +49,7 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting
- **WHEN** formatting a command for Windsurf
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
- **AND** file path SHALL follow pattern `.windsurf/commands/opsx/<id>.md`
- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`
### Requirement: Command generator function
+2 -2
View File
@@ -33,8 +33,8 @@ The CI system SHALL validate that the update-flake.sh script executes successful
- **WHEN** the CI runs the update script validation
- **THEN** the script SHALL execute without errors
- **AND** the script SHALL correctly extract the version from package.json
- **AND** the script SHALL update flake.nix with the correct version
- **AND** the script SHALL correctly read the version from package.json
- **AND** the script SHALL validate that flake.nix uses dynamic version from package.json
#### Scenario: Update script with mock hash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.0.0",
"version": "1.0.2",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+8 -3
View File
@@ -34,7 +34,9 @@ artifacts:
instruction: |
Create specification files that define WHAT the system should do.
Create one spec file per capability/feature area in specs/<name>/spec.md.
Create one spec file per capability listed in the proposal's Capabilities section.
- New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md).
- Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md.
Delta operations (use ## headers):
- **ADDED Requirements**: New capabilities
@@ -110,14 +112,17 @@ artifacts:
- id: tasks
generates: tasks.md
description: Implementation tasks derived from specs and design
description: Implementation checklist with trackable tasks
template: tasks.md
instruction: |
Create the task list that breaks down the implementation work.
**IMPORTANT: Follow the template below exactly.** The apply phase parses
checkbox format to track progress. Tasks not using `- [ ]` won't be tracked.
Guidelines:
- Group related tasks under ## numbered headings
- Each task is a checkbox: - [ ] X.Y Task description
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
- Tasks should be small enough to complete in one session
- Order tasks by dependency (what must be done first?)
-213
View File
@@ -1,213 +0,0 @@
name: tdd
version: 1
description: Test-driven development workflow - tests → implementation → docs
artifacts:
- id: spec
generates: spec.md
description: Feature specification defining requirements
template: spec.md
instruction: |
Create the feature specification that defines WHAT to build.
Sections:
- **Feature**: Name and high-level description of the feature's purpose and user value
- **Requirements**: List of specific requirements. Use SHALL/MUST for normative language.
- **Acceptance Criteria**: Testable criteria in WHEN/THEN format
Format requirements:
- Each requirement should be specific and testable
- Use `#### Scenario: <name>` with WHEN/THEN format for acceptance criteria
- Define edge cases and error scenarios explicitly
- Every requirement MUST have at least one scenario
Example:
```
## Feature: User Authentication
Users can securely log into the application.
## Requirements
### Requirement: Password validation
The system SHALL validate passwords meet minimum security requirements.
#### Scenario: Valid password accepted
- **WHEN** password has 8+ chars, uppercase, lowercase, and number
- **THEN** password is accepted
#### Scenario: Weak password rejected
- **WHEN** password is less than 8 characters
- **THEN** system displays "Password too short" error
```
This spec drives test creation - each scenario becomes a test case.
requires: []
- id: tests
generates: "tests/*.test.ts"
description: Test files written before implementation
template: test.md
instruction: |
Write tests BEFORE implementation (TDD red phase).
File naming:
- Create test files as `tests/<feature>.test.ts`
- One test file per feature/capability
- Use descriptive names matching the spec
Test structure:
- Use Given/When/Then format matching spec scenarios
- Group related tests with `describe()` blocks
- Each scenario from spec becomes at least one `it()` test
Coverage requirements:
- Cover each requirement from the spec
- Include happy path (success cases)
- Include edge cases (boundary conditions)
- Include error scenarios (invalid input, failures)
- Tests should fail initially (no implementation yet)
Example:
```typescript
describe('Password validation', () => {
it('accepts valid password with all requirements', () => {
// GIVEN a password meeting all requirements
const password = 'SecurePass1';
// WHEN validating
const result = validatePassword(password);
// THEN it should be accepted
expect(result.valid).toBe(true);
});
it('rejects password shorter than 8 characters', () => {
// GIVEN a short password
const password = 'Short1';
// WHEN validating
const result = validatePassword(password);
// THEN it should be rejected with message
expect(result.valid).toBe(false);
expect(result.error).toBe('Password too short');
});
});
```
Follow the spec requirements exactly - tests verify the spec.
requires:
- spec
- id: implementation
generates: "src/*.ts"
description: Implementation code to pass the tests
template: implementation.md
instruction: |
Implement the feature to make tests pass (TDD green phase).
TDD workflow:
1. Run tests - confirm they fail (red)
2. Write minimal code to pass ONE test
3. Run tests - confirm that test passes (green)
4. Refactor if needed while keeping tests green
5. Repeat for next failing test
Implementation guidelines:
- Write minimal code to pass each test - no more, no less
- Run tests frequently to verify progress
- Keep functions small and focused
- Use clear, descriptive names
Code organization:
- Create source files in `src/<feature>.ts`
- Export public API clearly
- Keep implementation details private
- Add JSDoc comments for public functions
Example structure:
```typescript
/**
* Validates a password meets security requirements.
* @param password - The password to validate
* @returns Validation result with valid flag and optional error
*/
export function validatePassword(password: string): ValidationResult {
if (password.length < 8) {
return { valid: false, error: 'Password too short' };
}
// ... additional checks
return { valid: true };
}
```
Don't over-engineer - implement only what tests require.
requires:
- tests
- id: docs
generates: "docs/*.md"
description: Documentation for the implemented feature
template: docs.md
instruction: |
Document the implemented feature.
Sections:
- **Overview**: What the feature does and why it exists (1-2 paragraphs)
- **Getting Started**: Quick start guide to use the feature immediately
- **Examples**: Code examples showing common use cases
- **Reference**: Detailed API documentation, configuration options
Guidelines:
- Write for the user, not the developer
- Start with the most common use case
- Include copy-pasteable code examples
- Document all configuration options with defaults
- Note any limitations, edge cases, or gotchas
- Link to related features or specs
Example structure:
```markdown
## Overview
Password validation ensures user passwords meet security requirements
before account creation or password changes.
## Getting Started
Import and use the validation function:
```typescript
import { validatePassword } from './password';
const result = validatePassword('MySecurePass1');
if (!result.valid) {
console.error(result.error);
}
```
## Examples
### Basic validation
...
### Custom error handling
...
## Reference
### validatePassword(password)
| Parameter | Type | Description |
|-----------|------|-------------|
| password | string | The password to validate |
**Returns**: `{ valid: boolean, error?: string }`
```
Reference the spec for requirements, implementation for details.
requires:
- implementation
apply:
requires: [tests]
tracks: null
instruction: |
Run tests to see failures. Implement minimal code to pass each test.
Refactor while keeping tests green.
-15
View File
@@ -1,15 +0,0 @@
## Overview
<!-- Feature overview -->
## Getting Started
<!-- Quick start guide -->
## Examples
<!-- Code examples -->
## Reference
<!-- API reference or additional details -->
-11
View File
@@ -1,11 +0,0 @@
## Implementation Notes
<!-- Technical implementation details -->
## API
<!-- Public API documentation -->
## Usage
<!-- Usage examples -->
-11
View File
@@ -1,11 +0,0 @@
## Feature: <!-- feature name -->
<!-- Feature description -->
## Requirements
<!-- List of requirements -->
## Acceptance Criteria
<!-- List of acceptance criteria -->
-11
View File
@@ -1,11 +0,0 @@
## Test Plan
<!-- Describe the testing strategy -->
## Test Cases
### <!-- Test case name -->
- **Given:** <!-- preconditions -->
- **When:** <!-- action -->
- **Then:** <!-- expected result -->
+8 -9
View File
@@ -4,9 +4,9 @@ Utility scripts for OpenSpec maintenance and development.
## update-flake.sh
Updates `flake.nix` version and dependency hash automatically.
Updates `flake.nix` pnpm dependency hash automatically.
**When to use**: After updating dependencies or releasing a new version.
**When to use**: After updating dependencies (`pnpm install`, `pnpm update`).
**Usage**:
```bash
@@ -14,19 +14,18 @@ Updates `flake.nix` version and dependency hash automatically.
```
**What it does**:
1. Extracts version from `package.json`
2. Updates version in `flake.nix`
3. Automatically determines the correct pnpm dependency hash
4. Updates the hash in `flake.nix`
5. Verifies the build succeeds
1. Reads version from `package.json` (dynamically used by `flake.nix`)
2. Automatically determines the correct pnpm dependency hash
3. Updates the hash in `flake.nix`
4. Verifies the build succeeds
**Example workflow**:
```bash
# After version bump and dependency updates
# After dependency updates
pnpm install
./scripts/update-flake.sh
git add flake.nix
git commit -m "chore: update flake.nix for v0.18.0"
git commit -m "chore: update flake.nix dependency hash"
```
## postinstall.js
+84 -31
View File
@@ -1,14 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
# Script to update flake.nix version and dependency hash
# Run this after updating package.json version
# Updates pnpm dependency hash in flake.nix after pnpm-lock.yaml changes.
# Version is read dynamically from package.json.
# Usage: ./scripts/update-flake.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FLAKE_FILE="$PROJECT_ROOT/flake.nix"
PACKAGE_JSON="$PROJECT_ROOT/package.json"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Detect OS and set sed in-place flag
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS (BSD sed) requires empty string argument for -i
@@ -18,58 +26,103 @@ else
SED_INPLACE=(-i)
fi
echo "==> Updating flake.nix..."
echo -e "${BLUE}==> Updating flake.nix pnpm dependency hash...${NC}"
echo ""
# Extract version from package.json
VERSION=$(node -p "require('$PACKAGE_JSON').version")
echo " Detected version: $VERSION"
echo -e "${BLUE}📦 Detected package version:${NC} $VERSION"
# Update version in flake.nix
if ! grep -q "version = \"$VERSION\"" "$FLAKE_FILE"; then
echo " Updating version in flake.nix..."
sed "${SED_INPLACE[@]}" "s|version = \"[^\"]*\"|version = \"$VERSION\"|" "$FLAKE_FILE"
else
echo " Version already up-to-date in flake.nix"
# Verify flake.nix uses dynamic version
if ! grep -q "(builtins.fromJSON (builtins.readFile ./package.json)).version" "$FLAKE_FILE"; then
echo -e "${YELLOW}⚠️ Warning: flake.nix doesn't use dynamic version from package.json${NC}"
echo -e " Expected pattern: version = (builtins.fromJSON (builtins.readFile ./package.json)).version;"
echo ""
fi
# Check if pnpm-lock.yaml exists
if [ ! -f "$PROJECT_ROOT/pnpm-lock.yaml" ]; then
echo -e "${RED}❌ Error: pnpm-lock.yaml not found${NC}"
exit 1
fi
echo -e "${BLUE}🔧 Current pnpm-lock.yaml:${NC} $(stat -c%y "$PROJECT_ROOT/pnpm-lock.yaml" 2>/dev/null || stat -f%Sm "$PROJECT_ROOT/pnpm-lock.yaml")"
echo ""
# Get current hash from flake.nix
CURRENT_HASH=$(sed -nE 's/.*hash = "(sha256-[^"]+)".*/\1/p' "$FLAKE_FILE" | head -1)
echo -e "${BLUE}📌 Current hash:${NC} $CURRENT_HASH"
echo ""
# Set placeholder hash to trigger error
echo " Setting placeholder hash..."
echo -e "${YELLOW}⏳ Setting placeholder hash to calculate correct value...${NC}"
PLACEHOLDER="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$FLAKE_FILE"
# Try to build and capture the correct hash
echo " Building to get correct hash (this will fail)..."
BUILD_OUTPUT=$(nix build 2>&1 || true)
echo -e "${BLUE}🔨 Building to determine correct hash (expected to fail)...${NC}"
BUILD_OUTPUT=$(nix build --no-link 2>&1 || true)
# Extract the correct hash from error output (portable - works on macOS and Linux)
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -o 'got:[[:space:]]*sha256-[A-Za-z0-9+/=]*' | head -1 | sed 's/got:[[:space:]]*//')
# Extract the correct hash from error output
# Try multiple patterns for compatibility with different Nix versions
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | sed -nE 's/.*got:[[:space:]]*(sha256-[A-Za-z0-9+/=]+).*/\1/p' | head -1)
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | sed -nE 's/.*got:.*(sha256-[A-Za-z0-9+/=]+).*/\1/p' | head -1)
fi
if [ -z "$CORRECT_HASH" ]; then
echo "❌ Error: Could not extract hash from build output"
echo "Build output:"
echo -e "${RED}❌ Error: Could not extract hash from build output${NC}"
echo ""
echo -e "${YELLOW}Build output:${NC}"
echo "$BUILD_OUTPUT"
echo ""
echo -e "${YELLOW}Restoring original hash...${NC}"
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CURRENT_HASH\"|" "$FLAKE_FILE"
exit 1
fi
echo " Detected hash: $CORRECT_HASH"
echo -e "${GREEN}✓ Calculated hash:${NC} $CORRECT_HASH"
echo ""
# Update flake.nix with correct hash
# Check if hash changed
if [ "$CURRENT_HASH" = "$CORRECT_HASH" ]; then
echo -e "${GREEN}✓ Hash is already up-to-date!${NC}"
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
echo ""
echo -e "${BLUE}ℹ️ No changes needed. Your flake is in sync with pnpm-lock.yaml${NC}"
exit 0
fi
echo -e "${YELLOW}🔄 Updating hash in flake.nix...${NC}"
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
# Verify the build works
echo " Verifying build..."
if nix build 2>&1 | grep -q "warning: Git tree.*is dirty"; then
echo "⚠️ Warning: Git tree is dirty, but build succeeded"
else
echo "✅ Build successful"
echo -e "${BLUE}🔍 Verifying build with new hash...${NC}"
BUILD_OUTPUT=$(nix build --no-link 2>&1) && BUILD_SUCCESS=true || BUILD_SUCCESS=false
if [ "$BUILD_SUCCESS" = false ]; then
echo -e "${RED}❌ Build verification failed!${NC}"
echo ""
echo "$BUILD_OUTPUT"
exit 1
fi
if echo "$BUILD_OUTPUT" | grep -q "warning: Git tree.*is dirty"; then
echo -e "${YELLOW}⚠️ Git tree is dirty, but build succeeded${NC}"
else
echo -e "${GREEN}✓ Build verification successful${NC}"
fi
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}✅ flake.nix updated successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "✅ flake.nix updated successfully!"
echo " Version: $VERSION"
echo " Hash: $CORRECT_HASH"
echo -e "${BLUE}📋 Summary:${NC}"
echo -e " Version: $VERSION ${YELLOW}(read dynamically from package.json)${NC}"
echo -e " Old hash: $CURRENT_HASH"
echo -e " New hash: $CORRECT_HASH"
echo ""
echo -e "${BLUE}📝 Next steps:${NC}"
echo -e " 1. Test: ${GREEN}nix run . -- --version${NC}"
echo -e " 2. Verify: ${GREEN}nix flake check${NC}"
echo -e " 3. Commit: ${GREEN}git add flake.nix${NC}"
echo ""
echo "Next steps:"
echo " 1. Test: nix run . -- --version"
echo " 2. Commit: git add flake.nix"
echo " 3. Include in version bump commit"
+17
View File
@@ -201,6 +201,23 @@ program
}
});
program
.command('dashboard')
.description('Start a local web-based dashboard for browsing changes, specs, and archive')
.option('--port <number>', 'Server port (default: 3000)')
.option('--no-open', 'Do not open browser automatically')
.action(async (options?: { port?: string; open?: boolean }) => {
try {
const { DashboardCommand } = await import('../core/dashboard/index.js');
const cmd = new DashboardCommand();
await cmd.execute('.', options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Change command with subcommands
const changeCmd = program
.command('change')
+1 -1
View File
@@ -378,7 +378,7 @@ export async function generateApplyInstructions(
state = 'all_done';
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
} else if (!tracksFile) {
// No tracking file (e.g., TDD schema) - ready to apply
// No tracking file configured in schema - ready to apply
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.';
} else {
+40 -3
View File
@@ -10,6 +10,43 @@ import {
type SpecUpdate,
} from './specs-apply.js';
/**
* Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows).
*/
async function copyDirRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Move a directory from src to dest. On Windows, fs.rename() often fails with
* EPERM when the directory is non-empty or another process has it open (IDE,
* file watcher, antivirus). Fall back to copy-then-remove when rename fails
* with EPERM or EXDEV.
*/
async function moveDirectory(src: string, dest: string): Promise<void> {
try {
await fs.rename(src, dest);
} catch (err: any) {
const code = err?.code;
if (code === 'EPERM' || code === 'EXDEV') {
await copyDirRecursive(src, dest);
await fs.rm(src, { recursive: true, force: true });
} else {
throw err;
}
}
}
export class ArchiveCommand {
async execute(
changeName?: string,
@@ -244,9 +281,9 @@ export class ArchiveCommand {
// Create archive directory if needed
await fs.mkdir(archiveDir, { recursive: true });
// Move change to archive
await fs.rename(changeDir, archivePath);
// Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)
await moveDirectory(changeDir, archivePath);
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
}
@@ -33,14 +33,14 @@ function formatTagsArray(tags: string[]): string {
/**
* Windsurf adapter for command generation.
* File path: .windsurf/commands/opsx/<id>.md
* File path: .windsurf/workflows/opsx-<id>.md
* Frontmatter: name, description, category, tags
*/
export const windsurfAdapter: ToolCommandAdapter = {
toolId: 'windsurf',
getFilePath(commandId: string): string {
return path.join('.windsurf', 'commands', 'opsx', `${commandId}.md`);
return path.join('.windsurf', 'workflows', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
+1
View File
@@ -38,6 +38,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
+250
View File
@@ -0,0 +1,250 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, type TaskProgress } from '../../utils/task-progress.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { renderMarkdown } from './markdown.js';
export interface ChangeArtifacts {
proposal: boolean;
specs: boolean;
design: boolean;
tasks: boolean;
}
export interface ChangeEntry {
name: string;
status: 'draft' | 'active' | 'completed';
artifacts: ChangeArtifacts;
progress: TaskProgress;
}
export interface SpecEntry {
name: string;
requirementCount: number;
}
export interface SpecGroup {
domain: string;
specs: SpecEntry[];
}
export interface ArchiveEntry {
name: string;
date: string;
changeName: string;
}
export interface DashboardSummary {
changes: { draft: number; active: number; completed: number; total: number };
specs: { total: number; totalRequirements: number };
archive: { total: number };
}
async function dirExists(dirPath: string): Promise<boolean> {
try {
const stat = await fs.stat(dirPath);
return stat.isDirectory();
} catch {
return false;
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function getArtifacts(changeDir: string): Promise<ChangeArtifacts> {
const [proposal, specs, design, tasks] = await Promise.all([
fileExists(path.join(changeDir, 'proposal.md')),
dirExists(path.join(changeDir, 'specs')),
fileExists(path.join(changeDir, 'design.md')),
fileExists(path.join(changeDir, 'tasks.md')),
]);
return { proposal, specs, design, tasks };
}
export async function getChangesData(openspecDir: string): Promise<ChangeEntry[]> {
const changesDir = path.join(openspecDir, 'changes');
if (!(await dirExists(changesDir))) {
return [];
}
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changes: ChangeEntry[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'archive' || entry.name.startsWith('.')) continue;
const changeDir = path.join(changesDir, entry.name);
const [progress, artifacts] = await Promise.all([
getTaskProgressForChange(changesDir, entry.name),
getArtifacts(changeDir),
]);
let status: ChangeEntry['status'];
if (progress.total === 0) {
status = 'draft';
} else if (progress.completed === progress.total) {
status = 'completed';
} else {
status = 'active';
}
changes.push({ name: entry.name, status, artifacts, progress });
}
changes.sort((a, b) => a.name.localeCompare(b.name));
return changes;
}
export async function getSpecsData(openspecDir: string): Promise<SpecGroup[]> {
const specsDir = path.join(openspecDir, 'specs');
if (!(await dirExists(specsDir))) {
return [];
}
const entries = await fs.readdir(specsDir, { withFileTypes: true });
const specs: SpecEntry[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const specFile = path.join(specsDir, entry.name, 'spec.md');
if (!(await fileExists(specFile))) continue;
let requirementCount = 0;
try {
const content = await fs.readFile(specFile, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(entry.name);
requirementCount = spec.requirements.length;
} catch {
// If spec can't be parsed, include with 0 count
}
specs.push({ name: entry.name, requirementCount });
}
specs.sort((a, b) => a.name.localeCompare(b.name));
// Group by domain prefix (text before first hyphen)
const groupMap = new Map<string, SpecEntry[]>();
for (const spec of specs) {
const hyphenIdx = spec.name.indexOf('-');
const domain = hyphenIdx > 0 ? spec.name.substring(0, hyphenIdx) : spec.name;
const group = groupMap.get(domain);
if (group) {
group.push(spec);
} else {
groupMap.set(domain, [spec]);
}
}
const groups: SpecGroup[] = [];
for (const [domain, domainSpecs] of groupMap) {
groups.push({ domain, specs: domainSpecs });
}
groups.sort((a, b) => a.domain.localeCompare(b.domain));
return groups;
}
export async function getArchiveData(
openspecDir: string,
limit = 50,
offset = 0
): Promise<{ entries: ArchiveEntry[]; total: number }> {
const archiveDir = path.join(openspecDir, 'changes', 'archive');
if (!(await dirExists(archiveDir))) {
return { entries: [], total: 0 };
}
const dirEntries = await fs.readdir(archiveDir, { withFileTypes: true });
const allEntries: ArchiveEntry[] = [];
for (const entry of dirEntries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
// Parse date from YYYY-MM-DD-<name> format
const dateMatch = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
if (dateMatch) {
allEntries.push({
name: entry.name,
date: dateMatch[1],
changeName: dateMatch[2],
});
} else {
allEntries.push({
name: entry.name,
date: '',
changeName: entry.name,
});
}
}
// Sort reverse chronologically (most recent first)
allEntries.sort((a, b) => b.name.localeCompare(a.name));
const total = allEntries.length;
const entries = allEntries.slice(offset, offset + limit);
return { entries, total };
}
export async function getSummary(openspecDir: string): Promise<DashboardSummary> {
const changes = await getChangesData(openspecDir);
const specGroups = await getSpecsData(openspecDir);
const archive = await getArchiveData(openspecDir, 1, 0);
const draft = changes.filter((c) => c.status === 'draft').length;
const active = changes.filter((c) => c.status === 'active').length;
const completed = changes.filter((c) => c.status === 'completed').length;
let totalSpecs = 0;
let totalRequirements = 0;
for (const group of specGroups) {
totalSpecs += group.specs.length;
totalRequirements += group.specs.reduce((sum, s) => sum + s.requirementCount, 0);
}
return {
changes: { draft, active, completed, total: draft + active + completed },
specs: { total: totalSpecs, totalRequirements },
archive: { total: archive.total },
};
}
export async function getArtifactContent(
openspecDir: string,
relativePath: string
): Promise<{ html: string } | { error: string; status: number }> {
// Resolve and verify path stays within openspec directory
const resolvedOpenspec = path.resolve(openspecDir);
const resolvedPath = path.resolve(openspecDir, relativePath);
if (!resolvedPath.startsWith(resolvedOpenspec + path.sep) && resolvedPath !== resolvedOpenspec) {
return { error: 'Forbidden: path outside openspec directory', status: 403 };
}
// Only allow .md and .yaml files
const ext = path.extname(resolvedPath).toLowerCase();
if (ext !== '.md' && ext !== '.yaml' && ext !== '.yml') {
return { error: 'Forbidden: only .md and .yaml files are allowed', status: 403 };
}
try {
const content = await fs.readFile(resolvedPath, 'utf-8');
if (ext === '.md') {
return { html: renderMarkdown(content) };
}
// YAML files: render as code block
return { html: `<pre><code>${content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>` };
} catch {
return { error: 'File not found', status: 404 };
}
}
+63
View File
@@ -0,0 +1,63 @@
import path from 'path';
import { existsSync } from 'fs';
import { startServer, findAvailablePort, openBrowser } from './server.js';
export interface DashboardOptions {
port?: string;
open?: boolean;
}
export class DashboardCommand {
async execute(targetPath: string = '.', options?: DashboardOptions): Promise<void> {
const openspecDir = path.resolve(targetPath, 'openspec');
if (!existsSync(openspecDir)) {
throw new Error('No OpenSpec project found. Run "openspec init" first.');
}
const requestedPort = parseInt(options?.port || '3000', 10);
const shouldOpen = options?.open !== false;
let port: number;
if (options?.port) {
// Explicit port: use it directly, fail if unavailable
port = requestedPort;
} else {
// Default: auto-increment from 3000 to 3010
port = await findAvailablePort(3000, 3010);
}
const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });
const url = `http://127.0.0.1:${port}`;
server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');
if (shouldOpen) {
openBrowser(url);
}
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Use --port to specify a different port.`
);
}
throw err;
});
// Graceful shutdown
const shutdown = () => {
console.log('\nShutting down dashboard server...');
server.close(() => {
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Lightweight markdown-to-HTML renderer for OpenSpec artifacts.
* Handles the subset of markdown used in OpenSpec: headings, paragraphs,
* bold, italic, code, lists, checkboxes, blockquotes, horizontal rules, links.
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderInline(text: string): string {
let result = escapeHtml(text);
// Inline code (must come before bold/italic to avoid conflicts)
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold + italic
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Links
result = result.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
return result;
}
export function renderMarkdown(markdown: string): string {
const lines = markdown.split('\n');
const html: string[] = [];
let i = 0;
let inCodeBlock = false;
let codeLines: string[] = [];
let inList = false;
let listType: 'ul' | 'ol' = 'ul';
let inBlockquote = false;
let blockquoteLines: string[] = [];
let paragraphLines: string[] = [];
function flushParagraph() {
if (paragraphLines.length > 0) {
html.push(`<p>${renderInline(paragraphLines.join(' '))}</p>`);
paragraphLines = [];
}
}
function flushList() {
if (inList) {
html.push(`</${listType}>`);
inList = false;
}
}
function flushBlockquote() {
if (inBlockquote) {
html.push(`<blockquote>${renderMarkdown(blockquoteLines.join('\n'))}</blockquote>`);
blockquoteLines = [];
inBlockquote = false;
}
}
while (i < lines.length) {
const line = lines[i];
// Fenced code blocks
if (line.trimStart().startsWith('```')) {
if (!inCodeBlock) {
flushParagraph();
flushList();
flushBlockquote();
inCodeBlock = true;
codeLines = [];
i++;
continue;
} else {
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
codeLines = [];
inCodeBlock = false;
i++;
continue;
}
}
if (inCodeBlock) {
codeLines.push(line);
i++;
continue;
}
// Empty line
if (line.trim() === '') {
flushParagraph();
flushList();
flushBlockquote();
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line.trim())) {
flushParagraph();
flushList();
flushBlockquote();
html.push('<hr>');
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushParagraph();
flushList();
flushBlockquote();
const level = headingMatch[1].length;
html.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`);
i++;
continue;
}
// Blockquote
if (line.startsWith('> ') || line === '>') {
flushParagraph();
flushList();
inBlockquote = true;
blockquoteLines.push(line.replace(/^>\s?/, ''));
i++;
continue;
}
if (inBlockquote) {
flushBlockquote();
}
// Checkbox list items
const checkboxMatch = line.match(/^[-*]\s+\[([ x])\]\s+(.*)$/i);
if (checkboxMatch) {
flushParagraph();
flushBlockquote();
if (!inList || listType !== 'ul') {
flushList();
html.push('<ul class="checklist">');
inList = true;
listType = 'ul';
}
const checked = checkboxMatch[1].toLowerCase() === 'x';
const checkedAttr = checked ? ' checked disabled' : ' disabled';
html.push(
`<li><input type="checkbox"${checkedAttr}> ${renderInline(checkboxMatch[2])}</li>`
);
i++;
continue;
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
flushParagraph();
flushBlockquote();
if (!inList || listType !== 'ul') {
flushList();
html.push('<ul>');
inList = true;
listType = 'ul';
}
html.push(`<li>${renderInline(ulMatch[1])}</li>`);
i++;
continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
flushParagraph();
flushBlockquote();
if (!inList || listType !== 'ol') {
flushList();
html.push('<ol>');
inList = true;
listType = 'ol';
}
html.push(`<li>${renderInline(olMatch[1])}</li>`);
i++;
continue;
}
// Regular paragraph line
flushList();
flushBlockquote();
paragraphLines.push(line);
i++;
}
// Flush remaining state
if (inCodeBlock) {
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
}
flushParagraph();
flushList();
flushBlockquote();
return html.join('\n');
}
+591
View File
@@ -0,0 +1,591 @@
import http from 'http';
import { exec } from 'child_process';
import { URL } from 'url';
import {
getChangesData,
getSpecsData,
getArchiveData,
getSummary,
getArtifactContent,
} from './data.js';
function sendJson(res: http.ServerResponse, data: unknown, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function sendHtml(res: http.ServerResponse, html: string, status = 200) {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
function getDashboardHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenSpec Dashboard</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--text: #e0e0e0;
--text-dim: #8888a0;
--accent: #00b4d8;
--green: #06d6a0;
--yellow: #ffd166;
--red: #ef476f;
--border: #2a2a4a;
--mono: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--mono);
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 {
font-size: 18px;
font-weight: 600;
}
header h1 span { color: var(--accent); }
.summary {
display: flex;
gap: 24px;
font-size: 13px;
color: var(--text-dim);
}
.summary .val { color: var(--text); font-weight: 600; }
nav {
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
gap: 0;
}
nav button {
background: none;
border: none;
color: var(--text-dim);
padding: 12px 24px;
font-family: var(--mono);
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
nav button:hover { color: var(--text); }
nav button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.container {
display: flex;
height: calc(100vh - 97px);
}
.panel {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.detail-panel {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
border-left: 1px solid var(--border);
background: var(--surface);
display: none;
}
.detail-panel.visible { display: block; }
.detail-panel .close-btn {
float: right;
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 4px 10px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
border-radius: 3px;
}
.detail-panel .close-btn:hover { color: var(--text); border-color: var(--text-dim); }
.detail-panel h2 { font-size: 16px; margin-bottom: 16px; color: var(--accent); }
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
margin: 20px 0 10px;
}
.section-title:first-child { margin-top: 0; }
.empty-state {
color: var(--text-dim);
font-style: italic;
padding: 16px 0;
font-size: 13px;
}
.change-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 8px;
cursor: default;
}
.change-card .name {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.change-card .artifacts {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.artifact-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
transition: background 0.15s;
}
.artifact-badge.present {
background: var(--surface2);
color: var(--accent);
border: 1px solid var(--accent);
}
.artifact-badge.missing {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
cursor: default;
}
.artifact-badge.present:hover {
background: var(--accent);
color: var(--bg);
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-dim);
}
.progress-bar {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.progress-fill.low { background: var(--red); }
.progress-fill.mid { background: var(--yellow); }
.progress-fill.high { background: var(--green); }
.domain-group {
margin-bottom: 20px;
}
.domain-label {
font-size: 13px;
font-weight: 600;
color: var(--accent);
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.spec-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.spec-item:hover { background: var(--surface2); }
.spec-item .req-count { color: var(--text-dim); font-size: 12px; }
.archive-item {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
font-size: 13px;
border-radius: 4px;
transition: background 0.15s;
cursor: pointer;
}
.archive-item:hover { background: var(--surface2); }
.archive-date { color: var(--text-dim); font-size: 12px; min-width: 90px; }
.load-more {
display: block;
margin: 16px auto;
padding: 8px 24px;
background: var(--surface2);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
}
.load-more:hover { background: var(--accent); color: var(--bg); }
/* Markdown content styles */
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
margin: 20px 0 10px;
color: var(--text);
}
.md-content h1 { font-size: 22px; }
.md-content h2 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.md-content h3 { font-size: 15px; }
.md-content p { margin: 10px 0; line-height: 1.6; font-size: 13px; }
.md-content ul, .md-content ol { margin: 10px 0; padding-left: 24px; font-size: 13px; }
.md-content li { margin: 4px 0; line-height: 1.5; }
.md-content ul.checklist { list-style: none; padding-left: 4px; }
.md-content ul.checklist li { display: flex; align-items: baseline; gap: 6px; }
.md-content code {
background: var(--bg);
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.md-content pre {
background: var(--bg);
padding: 14px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
font-size: 12px;
line-height: 1.5;
}
.md-content pre code { padding: 0; background: none; }
.md-content blockquote {
border-left: 3px solid var(--accent);
padding: 8px 16px;
margin: 12px 0;
color: var(--text-dim);
background: rgba(0,180,216,0.05);
}
.md-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.md-content a { color: var(--accent); }
.md-content strong { color: var(--text); }
</style>
</head>
<body>
<header>
<h1><span>OpenSpec</span> Dashboard</h1>
<div class="summary" id="summary"></div>
</header>
<nav>
<button class="active" data-tab="changes">Changes</button>
<button data-tab="specs">Specifications</button>
<button data-tab="archive">Archive</button>
</nav>
<div class="container">
<div class="panel" id="main-panel"></div>
<div class="detail-panel" id="detail-panel">
<button class="close-btn" onclick="closeDetail()">close</button>
<h2 id="detail-title"></h2>
<div class="md-content" id="detail-content"></div>
</div>
</div>
<script>
let currentTab = 'changes';
let archiveOffset = 0;
let archiveTotal = 0;
let archiveEntries = [];
document.querySelectorAll('nav button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
closeDetail();
loadTab(currentTab);
});
});
async function api(path) {
const res = await fetch(path);
return res.json();
}
async function loadSummary() {
const s = await api('/api/summary');
document.getElementById('summary').innerHTML =
'<div>Changes: <span class="val">' + s.changes.total + '</span></div>' +
'<div>Specs: <span class="val">' + s.specs.total + '</span></div>' +
'<div>Requirements: <span class="val">' + s.specs.totalRequirements + '</span></div>' +
'<div>Archived: <span class="val">' + s.archive.total + '</span></div>';
}
async function loadTab(tab) {
if (tab === 'changes') await loadChanges();
else if (tab === 'specs') await loadSpecs();
else if (tab === 'archive') { archiveOffset = 0; archiveEntries = []; await loadArchive(); }
}
async function loadChanges() {
const changes = await api('/api/changes');
const panel = document.getElementById('main-panel');
const draft = changes.filter(c => c.status === 'draft');
const active = changes.filter(c => c.status === 'active');
const completed = changes.filter(c => c.status === 'completed');
let html = '';
if (changes.length === 0) {
html = '<div class="empty-state">No changes found.</div>';
panel.innerHTML = html;
return;
}
if (active.length > 0) {
html += '<div class="section-title">Active</div>';
active.forEach(c => { html += changeCard(c); });
}
if (draft.length > 0) {
html += '<div class="section-title">Draft</div>';
draft.forEach(c => { html += changeCard(c); });
}
if (completed.length > 0) {
html += '<div class="section-title">Completed</div>';
completed.forEach(c => { html += changeCard(c); });
}
panel.innerHTML = html;
}
function changeCard(c) {
const pct = c.progress.total > 0 ? Math.round((c.progress.completed / c.progress.total) * 100) : 0;
const cls = pct < 33 ? 'low' : pct < 66 ? 'mid' : 'high';
const artifacts = [
badge('proposal', c.artifacts.proposal, 'changes/' + c.name + '/proposal.md'),
badge('specs', c.artifacts.specs, 'changes/' + c.name + '/specs'),
badge('design', c.artifacts.design, 'changes/' + c.name + '/design.md'),
badge('tasks', c.artifacts.tasks, 'changes/' + c.name + '/tasks.md'),
].join('');
let progress = '';
if (c.progress.total > 0) {
progress = '<div class="progress-container">' +
'<div class="progress-bar"><div class="progress-fill ' + cls + '" style="width:' + pct + '%"></div></div>' +
'<span>' + c.progress.completed + '/' + c.progress.total + ' (' + pct + '%)</span></div>';
}
return '<div class="change-card"><div class="name">' + esc(c.name) + '</div>' +
'<div class="artifacts">' + artifacts + '</div>' + progress + '</div>';
}
function badge(label, present, path) {
if (present) {
return '<span class="artifact-badge present" onclick="viewArtifact(\\'' + path + '\\',\\'' + label + '\\')">' + label + '</span>';
}
return '<span class="artifact-badge missing">' + label + '</span>';
}
async function loadSpecs() {
const groups = await api('/api/specs');
const panel = document.getElementById('main-panel');
if (groups.length === 0) {
panel.innerHTML = '<div class="empty-state">No specifications found.</div>';
return;
}
let html = '';
groups.forEach(g => {
html += '<div class="domain-group"><div class="domain-label">' + esc(g.domain) + '</div>';
g.specs.forEach(s => {
const reqLabel = s.requirementCount === 1 ? 'requirement' : 'requirements';
html += '<div class="spec-item" onclick="viewArtifact(\\'specs/' + s.name + '/spec.md\\',\\'' + esc(s.name) + '\\')">' +
'<span>' + esc(s.name) + '</span>' +
'<span class="req-count">' + s.requirementCount + ' ' + reqLabel + '</span></div>';
});
html += '</div>';
});
panel.innerHTML = html;
}
async function loadArchive() {
const data = await api('/api/archive?limit=50&offset=' + archiveOffset);
archiveTotal = data.total;
archiveEntries = archiveEntries.concat(data.entries);
archiveOffset += data.entries.length;
const panel = document.getElementById('main-panel');
if (archiveEntries.length === 0) {
panel.innerHTML = '<div class="empty-state">No archived changes found.</div>';
return;
}
let html = '<div class="section-title">Archive (' + archiveTotal + ' total)</div>';
archiveEntries.forEach(e => {
html += '<div class="archive-item" onclick="viewArtifact(\\'changes/archive/' + e.name + '/proposal.md\\',\\'' + esc(e.changeName) + '\\')">' +
'<span class="archive-date">' + esc(e.date || 'N/A') + '</span>' +
'<span>' + esc(e.changeName) + '</span></div>';
});
if (archiveOffset < archiveTotal) {
html += '<button class="load-more" onclick="loadArchive()">Load more</button>';
}
panel.innerHTML = html;
}
async function viewArtifact(path, title) {
const dp = document.getElementById('detail-panel');
const dt = document.getElementById('detail-title');
const dc = document.getElementById('detail-content');
dp.classList.add('visible');
dt.textContent = title;
dc.innerHTML = '<div class="empty-state">Loading...</div>';
try {
const res = await fetch('/api/artifact?path=' + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
dc.innerHTML = '<div class="empty-state">' + esc(data.error) + '</div>';
} else {
dc.innerHTML = data.html;
}
} catch (err) {
dc.innerHTML = '<div class="empty-state">Failed to load artifact.</div>';
}
}
function closeDetail() {
document.getElementById('detail-panel').classList.remove('visible');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
loadSummary();
loadTab('changes');
</script>
</body>
</html>`;
}
export interface ServerOptions {
port: number;
openspecDir: string;
noOpen: boolean;
}
export async function startServer(options: ServerOptions): Promise<http.Server> {
const { port, openspecDir } = options;
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://localhost:${port}`);
const pathname = url.pathname;
try {
if (pathname === '/') {
sendHtml(res, getDashboardHtml());
return;
}
if (pathname === '/api/summary') {
const data = await getSummary(openspecDir);
sendJson(res, data);
return;
}
if (pathname === '/api/changes') {
const data = await getChangesData(openspecDir);
sendJson(res, data);
return;
}
if (pathname === '/api/specs') {
const data = await getSpecsData(openspecDir);
sendJson(res, data);
return;
}
if (pathname === '/api/archive') {
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const data = await getArchiveData(openspecDir, limit, offset);
sendJson(res, data);
return;
}
if (pathname === '/api/artifact') {
const artifactPath = url.searchParams.get('path');
if (!artifactPath) {
sendJson(res, { error: 'Missing path parameter' }, 400);
return;
}
const result = await getArtifactContent(openspecDir, artifactPath);
if ('error' in result) {
sendJson(res, { error: result.error }, result.status);
} else {
sendJson(res, result);
}
return;
}
// 404 for everything else
sendJson(res, { error: 'Not found' }, 404);
} catch (error) {
sendJson(res, { error: 'Internal server error' }, 500);
}
});
return server;
}
export async function findAvailablePort(startPort: number, maxPort: number): Promise<number> {
for (let port = startPort; port <= maxPort; port++) {
const available = await isPortAvailable(port);
if (available) return port;
}
throw new Error(
`No available port found between ${startPort} and ${maxPort}. Use --port to specify a different port.`
);
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = http.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
export function openBrowser(url: string): void {
const platform = process.platform;
let command: string;
if (platform === 'darwin') {
command = `open "${url}"`;
} else if (platform === 'win32') {
command = `start "" "${url}"`;
} else {
command = `xdg-open "${url}"`;
}
exec(command, (error) => {
if (error) {
console.log(`Could not open browser automatically. Visit: ${url}`);
}
});
}
+2 -2
View File
@@ -17,11 +17,11 @@ import { z } from 'zod';
* - Consistent with other OpenSpec schemas
*/
export const ProjectConfigSchema = z.object({
// Required: which schema to use (e.g., "spec-driven", "tdd", or project-local schema name)
// Required: which schema to use (e.g., "spec-driven", or project-local schema name)
schema: z
.string()
.min(1)
.describe('The workflow schema to use (e.g., "spec-driven", "tdd")'),
.describe('The workflow schema to use (e.g., "spec-driven")'),
// Optional: project context (injected into all artifact instructions)
// Max size: 50KB (enforced during parsing)
+11 -27
View File
@@ -338,7 +338,6 @@ export function getNewChangeSkillTemplate(): SkillTemplate {
Use the default schema (omit \`--schema\`) unless the user explicitly requests a different workflow.
**Use a different schema only if the user mentions:**
- "tdd" or "test-driven" → use \`--schema tdd\`
- A specific schema name → use \`--schema <name>\`
- "show workflows" or "what workflows" → run \`openspec schemas --json\` and let them choose
@@ -358,7 +357,7 @@ export function getNewChangeSkillTemplate(): SkillTemplate {
This shows which artifacts need to be created and which are ready (dependencies satisfied).
5. **Get instructions for the first artifact**
The first artifact depends on the schema (e.g., \`proposal\` for spec-driven, \`spec\` for tdd).
The first artifact depends on the schema (e.g., \`proposal\` for spec-driven).
Check the status output to find the first artifact with status "ready".
\`\`\`bash
openspec instructions <first-artifact-id> --change "<name>"
@@ -421,7 +420,7 @@ export function getContinueChangeSkillTemplate(): SkillTemplate {
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand current state. The response includes:
- \`schemaName\`: The workflow schema being used (e.g., "spec-driven", "tdd")
- \`schemaName\`: The workflow schema being used (e.g., "spec-driven")
- \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked")
- \`isComplete\`: Boolean indicating if all artifacts are complete
@@ -487,16 +486,10 @@ Common artifact patterns:
**spec-driven schema** (proposal → specs → design → tasks):
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
- The Capabilities section is critical - each capability listed will need a spec file.
- **specs/*.md**: Create one spec per capability listed in the proposal.
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
- **design.md**: Document technical decisions, architecture, and implementation approach.
- **tasks.md**: Break down implementation into checkboxed tasks.
**tdd schema** (spec → tests → implementation → docs):
- **spec.md**: Feature specification defining what to build.
- **tests/*.test.ts**: Write tests BEFORE implementation (TDD red phase).
- **src/*.ts**: Implement to make tests pass (TDD green phase).
- **docs/*.md**: Document the implemented feature.
For other schemas, follow the \`instruction\` field from the CLI output.
**Guardrails**
@@ -543,7 +536,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand:
- \`schemaName\`: The workflow being used (e.g., "spec-driven", "tdd")
- \`schemaName\`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@@ -568,7 +561,6 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
Read the files listed in \`contextFiles\` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- **tdd**: spec, tests, implementation, docs
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
@@ -1346,7 +1338,7 @@ The change is implemented! One more step—let's archive it.
\`\`\`
## Archiving
When a change is complete, we archive it. This moves it from \`openspec/changes/\` to \`openspec/archive/YYYY-MM-DD--<name>/\`.
When a change is complete, we archive it. This moves it from \`openspec/changes/\` to \`openspec/changes/archive/YYYY-MM-DD-<name>/\`.
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
\`\`\`
@@ -1358,7 +1350,7 @@ openspec archive "<name>"
**SHOW:**
\`\`\`
Archived to: \`openspec/archive/YYYY-MM-DD--<name>/\`
Archived to: \`openspec/changes/archive/YYYY-MM-DD-<name>/\`
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
\`\`\`
@@ -1680,7 +1672,6 @@ export function getOpsxNewCommandTemplate(): CommandTemplate {
Use the default schema (omit \`--schema\`) unless the user explicitly requests a different workflow.
**Use a different schema only if the user mentions:**
- "tdd" or "test-driven" → use \`--schema tdd\`
- A specific schema name → use \`--schema <name>\`
- "show workflows" or "what workflows" → run \`openspec schemas --json\` and let them choose
@@ -1760,7 +1751,7 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate {
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand current state. The response includes:
- \`schemaName\`: The workflow schema being used (e.g., "spec-driven", "tdd")
- \`schemaName\`: The workflow schema being used (e.g., "spec-driven")
- \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked")
- \`isComplete\`: Boolean indicating if all artifacts are complete
@@ -1826,16 +1817,10 @@ Common artifact patterns:
**spec-driven schema** (proposal → specs → design → tasks):
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
- The Capabilities section is critical - each capability listed will need a spec file.
- **specs/*.md**: Create one spec per capability listed in the proposal.
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
- **design.md**: Document technical decisions, architecture, and implementation approach.
- **tasks.md**: Break down implementation into checkboxed tasks.
**tdd schema** (spec → tests → implementation → docs):
- **spec.md**: Feature specification defining what to build.
- **tests/*.test.ts**: Write tests BEFORE implementation (TDD red phase).
- **src/*.ts**: Implement to make tests pass (TDD green phase).
- **docs/*.md**: Document the implemented feature.
For other schemas, follow the \`instruction\` field from the CLI output.
**Guardrails**
@@ -1880,7 +1865,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand:
- \`schemaName\`: The workflow being used (e.g., "spec-driven", "tdd")
- \`schemaName\`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@@ -1905,7 +1890,6 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
Read the files listed in \`contextFiles\` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- **tdd**: spec, tests, implementation, docs
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
@@ -2643,7 +2627,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand:
- \`schemaName\`: The workflow being used (e.g., "spec-driven", "tdd")
- \`schemaName\`: The workflow being used (e.g., "spec-driven")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
@@ -3238,7 +3222,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand:
- \`schemaName\`: The workflow being used (e.g., "spec-driven", "tdd")
- \`schemaName\`: The workflow being used (e.g., "spec-driven")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
+3 -3
View File
@@ -105,9 +105,9 @@ export function validateChangeName(name: string): ValidationResult {
* console.log(result.schema) // 'spec-driven' or value from config
*
* @example
* // Creates openspec/changes/add-auth/ with TDD schema
* const result = await createChange('/path/to/project', 'add-auth', { schema: 'tdd' })
* console.log(result.schema) // 'tdd'
* // Creates openspec/changes/add-auth/ with custom schema
* const result = await createChange('/path/to/project', 'add-auth', { schema: 'my-workflow' })
* console.log(result.schema) // 'my-workflow'
*/
export async function createChange(
projectRoot: string,
+21 -28
View File
@@ -152,13 +152,13 @@ describe('artifact-workflow CLI commands', () => {
});
it('supports --schema option', async () => {
await createTestChange('tdd-change');
await createTestChange('schema-change');
const result = await runCLI(['status', '--change', 'tdd-change', '--schema', 'tdd'], {
const result = await runCLI(['status', '--change', 'schema-change', '--schema', 'spec-driven'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tdd');
expect(result.stdout).toContain('spec-driven');
});
it('errors for unknown schema', async () => {
@@ -282,12 +282,12 @@ describe('artifact-workflow CLI commands', () => {
expect(result.stdout).toContain('tasks:');
});
it('shows template paths for custom schema', async () => {
const result = await runCLI(['templates', '--schema', 'tdd'], { cwd: tempDir });
it('shows template paths for specified schema', async () => {
const result = await runCLI(['templates', '--schema', 'spec-driven'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: tdd');
expect(result.stdout).toContain('spec:');
expect(result.stdout).toContain('tests:');
expect(result.stdout).toContain('Schema: spec-driven');
expect(result.stdout).toContain('proposal:');
expect(result.stdout).toContain('design:');
});
it('outputs JSON mapping of templates', async () => {
@@ -430,23 +430,16 @@ describe('artifact-workflow CLI commands', () => {
expect(result.stdout).toContain('ready to be archived');
});
it('uses tdd schema apply configuration', async () => {
// Create a TDD-style change with spec and tests
const changeDir = path.join(changesDir, 'tdd-apply');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'spec.md'), '## Feature\nTest spec.');
const testsDir = path.join(changeDir, 'tests');
await fs.mkdir(testsDir, { recursive: true });
await fs.writeFile(path.join(testsDir, 'test.test.ts'), 'test("works", () => {})');
it('uses spec-driven schema apply configuration', async () => {
// Create a spec-driven style change with all artifacts
await createTestChange('apply-schema-test', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(
['instructions', 'apply', '--change', 'tdd-apply', '--schema', 'tdd'],
['instructions', 'apply', '--change', 'apply-schema-test', '--schema', 'spec-driven'],
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: tdd');
// TDD schema has no task tracking, so should show schema instruction
expect(result.stdout).toContain('Run tests to see failures');
expect(result.stdout).toContain('Schema: spec-driven');
});
it('spec-driven schema uses apply block configuration', async () => {
@@ -665,29 +658,29 @@ artifacts:
describe('project config integration', () => {
describe('new change uses config schema', () => {
it('creates change with schema from project config', async () => {
// Create project config with tdd schema
// Create project config with spec-driven schema
// Note: changesDir is already at tempDir/openspec/changes (created in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
'schema: tdd\n'
'schema: spec-driven\n'
);
// Create a new change without specifying schema
const result = await runCLI(['new', 'change', 'test-change'], { cwd: tempDir, timeoutMs: 30000 });
expect(result.exitCode).toBe(0);
// Verify the change was created with tdd schema
// Verify the change was created with spec-driven schema
const metadataPath = path.join(changesDir, 'test-change', '.openspec.yaml');
const metadata = await fs.readFile(metadataPath, 'utf-8');
expect(metadata).toContain('schema: tdd');
expect(metadata).toContain('schema: spec-driven');
}, 60000);
it('CLI schema overrides config schema', async () => {
// Create project config with tdd schema
// Create project config with spec-driven schema
// Note: openspec directory already exists (from changesDir creation in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
'schema: tdd\n'
'schema: spec-driven\n'
);
// Create change with explicit schema
@@ -795,7 +788,7 @@ rules:
const changeDir = await createTestChange('metadata-only-change');
await fs.writeFile(
path.join(changeDir, '.openspec.yaml'),
'schema: tdd\ncreated: "2025-01-05"\n'
'schema: spec-driven\ncreated: "2025-01-05"\n'
);
// Status should use schema from metadata
@@ -804,7 +797,7 @@ rules:
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tdd');
expect(result.stdout).toContain('spec-driven');
}, 60000);
});
@@ -63,11 +63,11 @@ describe('instruction-loader', () => {
expect(context.completed.size).toBe(0);
});
it('should load context with custom schema', () => {
const context = loadChangeContext(tempDir, 'my-change', 'tdd');
it('should load context with explicit schema', () => {
const context = loadChangeContext(tempDir, 'my-change', 'spec-driven');
expect(context.schemaName).toBe('tdd');
expect(context.graph.getName()).toBe('tdd');
expect(context.schemaName).toBe('spec-driven');
expect(context.graph.getName()).toBe('spec-driven');
});
it('should detect completed artifacts', () => {
@@ -91,20 +91,20 @@ describe('instruction-loader', () => {
// Create change directory with metadata file
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: tdd\ncreated: "2025-01-05"\n');
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\ncreated: "2025-01-05"\n');
// Load without explicit schema - should detect from metadata
const context = loadChangeContext(tempDir, 'my-change');
expect(context.schemaName).toBe('tdd');
expect(context.graph.getName()).toBe('tdd');
expect(context.schemaName).toBe('spec-driven');
expect(context.graph.getName()).toBe('spec-driven');
});
it('should use explicit schema over metadata schema', () => {
// Create change directory with metadata file using tdd
// Create change directory with metadata file using spec-driven
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: tdd\n');
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n');
// Load with explicit schema - should override metadata
const context = loadChangeContext(tempDir, 'my-change', 'spec-driven');
-10
View File
@@ -80,14 +80,6 @@ describe('artifact-graph/resolver', () => {
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should return built-in tdd schema', () => {
const schema = resolveSchema('tdd');
expect(schema.name).toBe('tdd');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should strip .yaml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yaml');
@@ -261,7 +253,6 @@ version: [[[invalid yaml
} catch (e) {
const error = e as Error;
expect(error.message).toContain('spec-driven');
expect(error.message).toContain('tdd');
}
});
});
@@ -271,7 +262,6 @@ version: [[[invalid yaml
const schemas = listSchemas();
expect(schemas).toContain('spec-driven');
expect(schemas).toContain('tdd');
});
it('should include user override schemas', () => {
@@ -131,46 +131,6 @@ describe('artifact-graph workflow integration', () => {
});
});
describe('tdd workflow', () => {
it('should progress through complete workflow', () => {
const schema = resolveSchema('tdd');
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('tdd');
expect(graph.getBuildOrder()).toEqual(['spec', 'tests', 'implementation', 'docs']);
// Initial state
let completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['spec']);
// Create spec
fs.writeFileSync(path.join(tempDir, 'spec.md'), '# Feature Spec');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['tests']);
// Create tests directory with test file
const testsDir = path.join(tempDir, 'tests');
fs.mkdirSync(testsDir, { recursive: true });
fs.writeFileSync(path.join(testsDir, 'feature.test.ts'), 'describe("feature", () => {});');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['implementation']);
// Create src directory with implementation
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'feature.ts'), 'export function feature() {}');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['docs']);
// Create docs
const docsDir = path.join(tempDir, 'docs');
fs.mkdirSync(docsDir, { recursive: true });
fs.writeFileSync(path.join(docsDir, 'feature.md'), '# Feature Documentation');
completed = detectCompleted(graph, tempDir);
expect(graph.isComplete(completed)).toBe(true);
});
});
describe('build order consistency', () => {
it('should return consistent build order across multiple calls', () => {
const schema = resolveSchema('spec-driven');
@@ -107,7 +107,7 @@ describe('command-generation/adapters', () => {
it('should generate correct file path', () => {
const filePath = windsurfAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.windsurf', 'commands', 'opsx', 'explore.md'));
expect(filePath).toBe(path.join('.windsurf', 'workflows', 'opsx-explore.md'));
});
it('should format file similar to Claude format', () => {
@@ -495,7 +495,7 @@ describe('command-generation/adapters', () => {
it('Windsurf adapter uses path.join for paths', () => {
const filePath = windsurfAdapter.getFilePath('test');
expect(filePath.split(path.sep)).toEqual(['.windsurf', 'commands', 'opsx', 'test.md']);
expect(filePath.split(path.sep)).toEqual(['.windsurf', 'workflows', 'opsx-test.md']);
});
it('All adapters use path.join for paths', () => {
+254
View File
@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import {
getChangesData,
getSpecsData,
getArchiveData,
getSummary,
getArtifactContent,
} from '../../../src/core/dashboard/data.js';
describe('Dashboard data module', () => {
let tempDir: string;
let openspecDir: string;
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `openspec-dashboard-data-test-${Date.now()}`);
openspecDir = path.join(tempDir, 'openspec');
await fs.mkdir(openspecDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('getChangesData', () => {
it('returns empty array when changes directory is missing', async () => {
const result = await getChangesData(openspecDir);
expect(result).toEqual([]);
});
it('categorizes changes by task status', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(changesDir, { recursive: true });
// Draft: no tasks.md
await fs.mkdir(path.join(changesDir, 'draft-change'));
// Active: incomplete tasks
await fs.mkdir(path.join(changesDir, 'active-change'));
await fs.writeFile(
path.join(changesDir, 'active-change', 'tasks.md'),
'- [x] Done\n- [ ] Not done\n'
);
// Completed: all tasks done
await fs.mkdir(path.join(changesDir, 'completed-change'));
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Done\n- [x] Also done\n'
);
const result = await getChangesData(openspecDir);
const draft = result.filter((c) => c.status === 'draft');
const active = result.filter((c) => c.status === 'active');
const completed = result.filter((c) => c.status === 'completed');
expect(draft).toHaveLength(1);
expect(draft[0].name).toBe('draft-change');
expect(active).toHaveLength(1);
expect(active[0].name).toBe('active-change');
expect(active[0].progress).toEqual({ total: 2, completed: 1 });
expect(completed).toHaveLength(1);
expect(completed[0].name).toBe('completed-change');
});
it('detects artifact existence', async () => {
const changesDir = path.join(openspecDir, 'changes');
const changeDir = path.join(changesDir, 'test-change');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Proposal');
await fs.mkdir(path.join(changeDir, 'specs'));
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design');
// No tasks.md
const result = await getChangesData(openspecDir);
expect(result[0].artifacts).toEqual({
proposal: true,
specs: true,
design: true,
tasks: false,
});
});
it('skips archive directory and hidden directories', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true });
await fs.mkdir(path.join(changesDir, '.hidden'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'real-change'), { recursive: true });
const result = await getChangesData(openspecDir);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('real-change');
});
});
describe('getSpecsData', () => {
it('returns empty array when specs directory is missing', async () => {
const result = await getSpecsData(openspecDir);
expect(result).toEqual([]);
});
it('groups specs by domain prefix', async () => {
const specsDir = path.join(openspecDir, 'specs');
// Create specs with domain prefixes
for (const name of ['cli-init', 'cli-view', 'docs-format', 'standalone']) {
const specDir = path.join(specsDir, name);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
'## Purpose\nTest spec\n\n## Requirements\n\n### Requirement: Test\nSome requirement\n'
);
}
const result = await getSpecsData(openspecDir);
expect(result).toHaveLength(3); // cli, docs, standalone
expect(result.map((g) => g.domain)).toEqual(['cli', 'docs', 'standalone']);
const cliGroup = result.find((g) => g.domain === 'cli')!;
expect(cliGroup.specs).toHaveLength(2);
expect(cliGroup.specs.map((s) => s.name)).toEqual(['cli-init', 'cli-view']);
});
it('sorts domains and specs alphabetically', async () => {
const specsDir = path.join(openspecDir, 'specs');
for (const name of ['z-spec', 'a-spec', 'a-other']) {
const specDir = path.join(specsDir, name);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
'## Purpose\nTest\n\n## Requirements\n\n### Requirement: R1\nReq\n'
);
}
const result = await getSpecsData(openspecDir);
expect(result.map((g) => g.domain)).toEqual(['a', 'z']);
const aGroup = result[0];
expect(aGroup.specs.map((s) => s.name)).toEqual(['a-other', 'a-spec']);
});
});
describe('getArchiveData', () => {
it('returns empty when archive directory is missing', async () => {
const result = await getArchiveData(openspecDir);
expect(result).toEqual({ entries: [], total: 0 });
});
it('parses dates from YYYY-MM-DD-name format', async () => {
const archiveDir = path.join(openspecDir, 'changes', 'archive');
await fs.mkdir(path.join(archiveDir, '2024-06-15-auth-refactor'), { recursive: true });
await fs.mkdir(path.join(archiveDir, '2024-07-01-new-feature'), { recursive: true });
const result = await getArchiveData(openspecDir);
expect(result.total).toBe(2);
// Reverse chronological order
expect(result.entries[0].date).toBe('2024-07-01');
expect(result.entries[0].changeName).toBe('new-feature');
expect(result.entries[1].date).toBe('2024-06-15');
expect(result.entries[1].changeName).toBe('auth-refactor');
});
it('supports pagination with limit and offset', async () => {
const archiveDir = path.join(openspecDir, 'changes', 'archive');
for (let i = 1; i <= 5; i++) {
await fs.mkdir(path.join(archiveDir, `2024-01-0${i}-change-${i}`), { recursive: true });
}
const page1 = await getArchiveData(openspecDir, 2, 0);
expect(page1.total).toBe(5);
expect(page1.entries).toHaveLength(2);
const page2 = await getArchiveData(openspecDir, 2, 2);
expect(page2.entries).toHaveLength(2);
const page3 = await getArchiveData(openspecDir, 2, 4);
expect(page3.entries).toHaveLength(1);
});
});
describe('getArtifactContent', () => {
it('returns rendered HTML for markdown files', async () => {
const changesDir = path.join(openspecDir, 'changes', 'test');
await fs.mkdir(changesDir, { recursive: true });
await fs.writeFile(path.join(changesDir, 'proposal.md'), '# Hello\n\nWorld');
const result = await getArtifactContent(openspecDir, 'changes/test/proposal.md');
expect('html' in result).toBe(true);
if ('html' in result) {
expect(result.html).toContain('<h1>Hello</h1>');
expect(result.html).toContain('<p>World</p>');
}
});
it('blocks path traversal attempts', async () => {
const result = await getArtifactContent(openspecDir, '../../../etc/passwd');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.status).toBe(403);
}
});
it('blocks non-markdown file requests', async () => {
const result = await getArtifactContent(openspecDir, 'changes/test/secret.json');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.status).toBe(403);
}
});
it('returns 404 for missing files', async () => {
const result = await getArtifactContent(openspecDir, 'changes/nonexistent/proposal.md');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.status).toBe(404);
}
});
it('allows .yaml files', async () => {
await fs.writeFile(path.join(openspecDir, 'config.yaml'), 'key: value');
const result = await getArtifactContent(openspecDir, 'config.yaml');
expect('html' in result).toBe(true);
if ('html' in result) {
expect(result.html).toContain('key: value');
}
});
});
describe('getSummary', () => {
it('aggregates counts correctly', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(changesDir, { recursive: true });
// 1 draft, 1 active, 1 completed
await fs.mkdir(path.join(changesDir, 'draft'));
await fs.mkdir(path.join(changesDir, 'active'));
await fs.writeFile(path.join(changesDir, 'active', 'tasks.md'), '- [ ] Todo\n');
await fs.mkdir(path.join(changesDir, 'done'));
await fs.writeFile(path.join(changesDir, 'done', 'tasks.md'), '- [x] Done\n');
const summary = await getSummary(openspecDir);
expect(summary.changes.draft).toBe(1);
expect(summary.changes.active).toBe(1);
expect(summary.changes.completed).toBe(1);
expect(summary.changes.total).toBe(3);
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../../../src/core/dashboard/markdown.js';
describe('renderMarkdown', () => {
it('renders headings h1-h6', () => {
expect(renderMarkdown('# Title')).toContain('<h1>Title</h1>');
expect(renderMarkdown('## Subtitle')).toContain('<h2>Subtitle</h2>');
expect(renderMarkdown('### H3')).toContain('<h3>H3</h3>');
expect(renderMarkdown('###### H6')).toContain('<h6>H6</h6>');
});
it('renders paragraphs', () => {
expect(renderMarkdown('Hello world')).toContain('<p>Hello world</p>');
});
it('renders bold text', () => {
const html = renderMarkdown('This is **bold** text');
expect(html).toContain('<strong>bold</strong>');
});
it('renders italic text', () => {
const html = renderMarkdown('This is *italic* text');
expect(html).toContain('<em>italic</em>');
});
it('renders bold+italic text', () => {
const html = renderMarkdown('This is ***both*** styled');
expect(html).toContain('<strong><em>both</em></strong>');
});
it('renders inline code', () => {
const html = renderMarkdown('Use `npm install` to install');
expect(html).toContain('<code>npm install</code>');
});
it('renders fenced code blocks', () => {
const md = '```\nconst x = 1;\nconst y = 2;\n```';
const html = renderMarkdown(md);
expect(html).toContain('<pre><code>');
expect(html).toContain('const x = 1;');
expect(html).toContain('const y = 2;');
});
it('renders unordered lists', () => {
const md = '- Item 1\n- Item 2\n- Item 3';
const html = renderMarkdown(md);
expect(html).toContain('<ul>');
expect(html).toContain('<li>Item 1</li>');
expect(html).toContain('<li>Item 2</li>');
expect(html).toContain('<li>Item 3</li>');
expect(html).toContain('</ul>');
});
it('renders ordered lists', () => {
const md = '1. First\n2. Second\n3. Third';
const html = renderMarkdown(md);
expect(html).toContain('<ol>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('</ol>');
});
it('renders checkboxes', () => {
const md = '- [x] Done task\n- [ ] Pending task';
const html = renderMarkdown(md);
expect(html).toContain('class="checklist"');
expect(html).toContain('checked disabled');
expect(html).toContain('Done task');
expect(html).toContain('Pending task');
});
it('renders blockquotes', () => {
const md = '> This is a quote';
const html = renderMarkdown(md);
expect(html).toContain('<blockquote>');
expect(html).toContain('This is a quote');
});
it('renders horizontal rules', () => {
expect(renderMarkdown('---')).toContain('<hr>');
expect(renderMarkdown('***')).toContain('<hr>');
expect(renderMarkdown('___')).toContain('<hr>');
});
it('renders links', () => {
const md = 'Visit [OpenSpec](https://example.com) for more';
const html = renderMarkdown(md);
expect(html).toContain('<a href="https://example.com"');
expect(html).toContain('>OpenSpec</a>');
});
it('escapes HTML entities', () => {
const md = 'Use <div> and &amp; in text';
const html = renderMarkdown(md);
expect(html).toContain('&lt;div&gt;');
expect(html).toContain('&amp;amp;');
});
it('handles mixed content', () => {
const md = [
'# Title',
'',
'A paragraph with **bold** and *italic*.',
'',
'- List item 1',
'- List item 2',
'',
'```',
'code block',
'```',
].join('\n');
const html = renderMarkdown(md);
expect(html).toContain('<h1>Title</h1>');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
expect(html).toContain('<ul>');
expect(html).toContain('<pre><code>');
});
it('handles empty input', () => {
expect(renderMarkdown('')).toBe('');
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
import { startServer, findAvailablePort } from '../../../src/core/dashboard/server.js';
function fetch(url: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () =>
resolve({ status: res.statusCode || 0, body, headers: res.headers })
);
}).on('error', reject);
});
}
describe('Dashboard server', () => {
let tempDir: string;
let openspecDir: string;
let server: http.Server;
let port: number;
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `openspec-dashboard-server-test-${Date.now()}`);
openspecDir = path.join(tempDir, 'openspec');
await fs.mkdir(openspecDir, { recursive: true });
port = await findAvailablePort(9100, 9200);
server = await startServer({ port, openspecDir, noOpen: true });
await new Promise<void>((resolve) => {
server.listen(port, '127.0.0.1', () => resolve());
});
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await fs.rm(tempDir, { recursive: true, force: true });
});
it('serves dashboard HTML at /', async () => {
const res = await fetch(`http://127.0.0.1:${port}/`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.body).toContain('OpenSpec Dashboard');
});
it('serves summary API', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/summary`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data).toHaveProperty('changes');
expect(data).toHaveProperty('specs');
expect(data).toHaveProperty('archive');
});
it('serves changes API', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true });
const res = await fetch(`http://127.0.0.1:${port}/api/changes`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data).toHaveLength(1);
expect(data[0].name).toBe('test-change');
});
it('serves specs API', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/specs`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(Array.isArray(data)).toBe(true);
});
it('serves archive API with pagination', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/archive?limit=10&offset=0`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data).toHaveProperty('entries');
expect(data).toHaveProperty('total');
});
it('serves artifact content', async () => {
const changesDir = path.join(openspecDir, 'changes', 'test');
await fs.mkdir(changesDir, { recursive: true });
await fs.writeFile(path.join(changesDir, 'proposal.md'), '# Hello');
const res = await fetch(
`http://127.0.0.1:${port}/api/artifact?path=${encodeURIComponent('changes/test/proposal.md')}`
);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data.html).toContain('<h1>Hello</h1>');
});
it('blocks path traversal in artifact API', async () => {
const res = await fetch(
`http://127.0.0.1:${port}/api/artifact?path=${encodeURIComponent('../../../etc/passwd')}`
);
expect(res.status).toBe(403);
});
it('returns 400 for missing artifact path', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/artifact`);
expect(res.status).toBe(400);
});
it('returns 404 for unknown routes', async () => {
const res = await fetch(`http://127.0.0.1:${port}/unknown`);
expect(res.status).toBe(404);
});
});
describe('findAvailablePort', () => {
it('returns first available port', async () => {
const port = await findAvailablePort(9300, 9310);
expect(port).toBeGreaterThanOrEqual(9300);
expect(port).toBeLessThanOrEqual(9310);
});
it('throws when no port is available', async () => {
// Create servers on a small range
const servers: http.Server[] = [];
const startPort = 9400;
const endPort = 9402;
for (let p = startPort; p <= endPort; p++) {
const s = http.createServer();
await new Promise<void>((resolve) => s.listen(p, '127.0.0.1', () => resolve()));
servers.push(s);
}
try {
await expect(findAvailablePort(startPort, endPort)).rejects.toThrow('No available port');
} finally {
await Promise.all(servers.map((s) => new Promise<void>((r) => s.close(() => r()))));
}
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ describe('InitCommand', () => {
await fs.mkdir(testDir, { recursive: true });
// Mock console.log to suppress output during tests
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => { });
});
afterEach(async () => {
@@ -375,7 +375,7 @@ describe('InitCommand', () => {
const initCommand = new InitCommand({ tools: 'windsurf', force: true });
await initCommand.execute(testDir);
const cmdFile = path.join(testDir, '.windsurf', 'commands', 'opsx', 'explore.md');
const cmdFile = path.join(testDir, '.windsurf', 'workflows', 'opsx-explore.md');
expect(await fileExists(cmdFile)).toBe(true);
});
+8 -10
View File
@@ -374,7 +374,7 @@ context: |
);
fs.writeFileSync(
path.join(configDir, 'config.yml'),
'schema: tdd\ncontext: from yml\n'
'schema: custom-schema\ncontext: from yml\n'
);
const config = readProjectConfig(tempDir);
@@ -388,12 +388,12 @@ context: |
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yml'),
'schema: tdd\ncontext: from yml\n'
'schema: custom-schema\ncontext: from yml\n'
);
const config = readProjectConfig(tempDir);
expect(config?.schema).toBe('tdd');
expect(config?.schema).toBe('custom-schema');
expect(config?.context).toBe('from yml');
});
@@ -538,7 +538,6 @@ rules:
describe('suggestSchemas', () => {
const availableSchemas = [
{ name: 'spec-driven', isBuiltIn: true },
{ name: 'tdd', isBuiltIn: true },
{ name: 'custom-workflow', isBuiltIn: false },
{ name: 'team-process', isBuiltIn: false },
];
@@ -551,29 +550,28 @@ rules:
expect(message).toContain('spec-driven (built-in)');
});
it('should suggest tdd for tdd typo', () => {
const message = suggestSchemas('td', availableSchemas);
it('should suggest custom-workflow for workflow typo', () => {
const message = suggestSchemas('custom-workflo', availableSchemas);
expect(message).toContain('Did you mean one of these?');
expect(message).toContain('tdd (built-in)');
expect(message).toContain('custom-workflow');
});
it('should list all available schemas', () => {
const message = suggestSchemas('nonexistent', availableSchemas);
expect(message).toContain('Available schemas:');
expect(message).toContain('Built-in: spec-driven, tdd');
expect(message).toContain('Built-in: spec-driven');
expect(message).toContain('Project-local: custom-workflow, team-process');
});
it('should handle case when no project-local schemas exist', () => {
const builtInOnly = [
{ name: 'spec-driven', isBuiltIn: true },
{ name: 'tdd', isBuiltIn: true },
];
const message = suggestSchemas('invalid', builtInOnly);
expect(message).toContain('Built-in: spec-driven, tdd');
expect(message).toContain('Built-in: spec-driven');
expect(message).toContain('Project-local: (none found)');
});
+2 -3
View File
@@ -301,9 +301,8 @@ Old instructions content
const windsurfCmd = path.join(
testDir,
'.windsurf',
'commands',
'opsx',
'explore.md'
'workflows',
'opsx-explore.md'
);
const exists = await FileSystemUtils.fileExists(windsurfCmd);
expect(exists).toBe(true);
+13 -13
View File
@@ -28,11 +28,11 @@ describe('ChangeMetadataSchema', () => {
it('should accept valid schema without created date', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'tdd',
schema: 'custom-schema',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema).toBe('tdd');
expect(result.data.schema).toBe('custom-schema');
expect(result.data.created).toBeUndefined();
}
});
@@ -183,8 +183,8 @@ describe('resolveSchemaForChange', () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
const result = resolveSchemaForChange(changeDir, 'tdd');
expect(result).toBe('tdd');
const result = resolveSchemaForChange(changeDir, 'custom-schema');
expect(result).toBe('custom-schema');
});
it('should return schema from metadata when no explicit schema', async () => {
@@ -216,12 +216,12 @@ describe('resolveSchemaForChange', () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'schema: custom-schema\n',
'utf-8'
);
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('tdd');
expect(result).toBe('custom-schema');
});
it('should prefer change metadata over project config', async () => {
@@ -230,7 +230,7 @@ describe('resolveSchemaForChange', () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'schema: custom-schema\n',
'utf-8'
);
@@ -248,7 +248,7 @@ describe('resolveSchemaForChange', () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'schema: custom-schema\n',
'utf-8'
);
@@ -257,8 +257,8 @@ describe('resolveSchemaForChange', () => {
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
// Explicit schema should win
const result = resolveSchemaForChange(changeDir, 'tdd');
expect(result).toBe('tdd');
const result = resolveSchemaForChange(changeDir, 'custom-schema');
expect(result).toBe('custom-schema');
});
it('should test full precedence order: CLI > metadata > config > default', async () => {
@@ -267,7 +267,7 @@ describe('resolveSchemaForChange', () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'schema: custom-schema\n',
'utf-8'
);
@@ -275,12 +275,12 @@ describe('resolveSchemaForChange', () => {
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
// Test each level
expect(resolveSchemaForChange(changeDir, 'tdd')).toBe('tdd'); // CLI wins
expect(resolveSchemaForChange(changeDir, 'custom-schema')).toBe('custom-schema'); // CLI wins
expect(resolveSchemaForChange(changeDir)).toBe('spec-driven'); // Metadata wins when no CLI
// Remove metadata, config should win
await fs.unlink(metaPath);
expect(resolveSchemaForChange(changeDir)).toBe('tdd'); // Config wins
expect(resolveSchemaForChange(changeDir)).toBe('custom-schema'); // Config wins
// Remove config, default should win
await fs.unlink(path.join(configDir, 'config.yaml'));
+2 -2
View File
@@ -139,11 +139,11 @@ describe('createChange', () => {
});
it('should create .openspec.yaml with custom schema', async () => {
await createChange(testDir, 'add-auth', { schema: 'tdd' });
await createChange(testDir, 'add-auth', { schema: 'spec-driven' });
const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('schema: tdd');
expect(content).toContain('schema: spec-driven');
});
});