mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-18 05:59:56 +08:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4772d71b4e | ||
|
|
ddbfa529f4 | ||
|
|
d3c3d66e67 | ||
|
|
277be194ef | ||
|
|
f45ba73a5f | ||
|
|
d59a0b347a | ||
|
|
04dc6deb5d | ||
|
|
41305753b4 | ||
|
|
86d2e04cae | ||
|
|
f6b415cb9b | ||
|
|
e91568deb9 | ||
|
|
fc0d798f93 | ||
|
|
d155126235 | ||
|
|
943e0d4102 | ||
|
|
12a7224dc6 | ||
|
|
c773ef6feb | ||
|
|
0bfe1d4426 |
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
+18
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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?)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,15 +0,0 @@
|
||||
## Overview
|
||||
|
||||
<!-- Feature overview -->
|
||||
|
||||
## Getting Started
|
||||
|
||||
<!-- Quick start guide -->
|
||||
|
||||
## Examples
|
||||
|
||||
<!-- Code examples -->
|
||||
|
||||
## Reference
|
||||
|
||||
<!-- API reference or additional details -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Implementation Notes
|
||||
|
||||
<!-- Technical implementation details -->
|
||||
|
||||
## API
|
||||
|
||||
<!-- Public API documentation -->
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- Usage examples -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Feature: <!-- feature name -->
|
||||
|
||||
<!-- Feature description -->
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- List of requirements -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- List of acceptance criteria -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Test Plan
|
||||
|
||||
<!-- Describe the testing strategy -->
|
||||
|
||||
## Test Cases
|
||||
|
||||
### <!-- Test case name -->
|
||||
|
||||
- **Given:** <!-- preconditions -->
|
||||
- **When:** <!-- action -->
|
||||
- **Then:** <!-- expected result -->
|
||||
+8
-9
@@ -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
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')}</code></pre>` };
|
||||
} catch {
|
||||
return { error: 'File not found', status: 404 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 & in text';
|
||||
const html = renderMarkdown(md);
|
||||
expect(html).toContain('<div>');
|
||||
expect(html).toContain('&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('');
|
||||
});
|
||||
});
|
||||
@@ -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()))));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user