mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c7a05c5dc | ||
|
|
d1f3861d9e | ||
|
|
7a39e887bb | ||
|
|
18c445a48d | ||
|
|
900174000b | ||
|
|
f529b25968 | ||
|
|
93f7b797cf | ||
|
|
7d07101363 | ||
|
|
c0f29044f9 | ||
|
|
7fe45ca330 | ||
|
|
c8e2072e3a | ||
|
|
cd5e49346f | ||
|
|
a18d992fa1 | ||
|
|
4df6a4889b | ||
|
|
9b5007dbc3 | ||
|
|
cce787ec40 | ||
|
|
94d651de8c | ||
|
|
040e382d64 | ||
|
|
caafd7c9bf | ||
|
|
144528257d | ||
|
|
af0b3418d0 | ||
|
|
7fd5417ed0 | ||
|
|
5ac1e12b83 | ||
|
|
fd7ad273c7 | ||
|
|
ea6f380fea | ||
|
|
765df47ad3 | ||
|
|
64d476f8b9 | ||
|
|
afdca0d5da | ||
|
|
61eb999f7c | ||
|
|
3d3bf96061 | ||
|
|
d199dfa407 | ||
|
|
d7d186088e | ||
|
|
6a3a1263fe | ||
|
|
1e94443a35 | ||
|
|
a0608d0bab |
@@ -3,6 +3,8 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -81,7 +83,7 @@ jobs:
|
||||
name: Test (${{ matrix.label }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name == 'push'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -242,7 +244,7 @@ jobs:
|
||||
validate-changesets:
|
||||
name: Validate Changesets
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -275,7 +277,7 @@ jobs:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_pr, lint, nix-flake-validate]
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group')
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
run: |
|
||||
@@ -301,7 +303,7 @@ jobs:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_matrix, lint, nix-flake-validate]
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
if: always() && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
run: |
|
||||
|
||||
@@ -153,3 +153,9 @@ result
|
||||
# OpenCode
|
||||
.opencode/
|
||||
opencode.json
|
||||
|
||||
# Codex
|
||||
.codex/
|
||||
|
||||
# Bob
|
||||
.bob/
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#995](https://github.com/Fission-AI/OpenSpec/pull/995) [`d1f3861`](https://github.com/Fission-AI/OpenSpec/commit/d1f3861d9ec694cc924b042b5da01963dcf93137) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes
|
||||
|
||||
- **Canonical artifact paths** — Workflow artifact paths are now resolved via the native `realpath`, so symlinks and case-insensitive filesystems no longer cause path mismatches during apply and archive.
|
||||
- **Glob apply instructions** — Apply instructions with glob artifact outputs now resolve correctly, and literal artifact outputs are enforced to be file paths.
|
||||
- **Hidden main spec requirements** — Requirements nested inside fenced code blocks or otherwise hidden in main specs are now detected during validation.
|
||||
- **Clean `--json` output** — Spinner progress text no longer leaks into stderr when `--json` is passed, so AI agents that combine stdout and stderr can parse the JSON reliably.
|
||||
- **Silent telemetry in firewalled environments** — PostHog network errors are now swallowed with a 1s timeout and retries/remote config disabled, so OpenSpec no longer surfaces `PostHogFetchNetworkError` in locked-down networks. Telemetry opt-out is documented earlier in the README, installation guide, and CLI reference.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#952](https://github.com/Fission-AI/OpenSpec/pull/952) [`cce787e`](https://github.com/Fission-AI/OpenSpec/commit/cce787ec4083da2b27781f6786f5ce0002909a7b) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
|
||||
|
||||
- **Junie support** — Added tool and command generation for JetBrains Junie
|
||||
- **Lingma IDE support** — Added configuration support for Lingma IDE
|
||||
- **ForgeCode support** — Added tool support for ForgeCode
|
||||
- **IBM Bob support** — Added support for IBM Bob coding assistant
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Shell completions opt-in** — Completion install is now opt-in, fixing PowerShell encoding corruption
|
||||
- **Copilot auto-detection** — Prevented false GitHub Copilot detection from a bare `.github/` directory
|
||||
- **pi.dev command generation** — Fixed command reference transforms and template argument passing
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#760](https://github.com/Fission-AI/OpenSpec/pull/760) [`61eb999`](https://github.com/Fission-AI/OpenSpec/commit/61eb999f7c6c0fc98d2e7f3678756fce6a3f4378) Thanks [@fsilvaortiz](https://github.com/fsilvaortiz)! - fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.
|
||||
|
||||
- [#759](https://github.com/Fission-AI/OpenSpec/pull/759) [`afdca0d`](https://github.com/Fission-AI/OpenSpec/commit/afdca0d5dab1aa109cfd8848b2512333ccad60c3) Thanks [@fsilvaortiz](https://github.com/fsilvaortiz)! - fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#747](https://github.com/Fission-AI/OpenSpec/pull/747) [`1e94443`](https://github.com/Fission-AI/OpenSpec/commit/1e94443a3551b228eecbc89e95d96d3b9600a192) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
|
||||
|
||||
- **Profile system** — Choose between `core` (4 essential workflows) and `custom` (pick any subset) profiles to control which skills get installed. Manage profiles with the new `openspec config profile` command
|
||||
- **Propose workflow** — New one-step workflow creates a complete change proposal with design, specs, and tasks from a single request — no need to run `new` then `ff` separately
|
||||
- **AI tool auto-detection** — `openspec init` now scans your project for existing tool directories (`.claude/`, `.cursor/`, etc.) and pre-selects detected tools
|
||||
- **Pi (pi.dev) support** — Pi coding agent is now a supported tool with prompt and skill generation
|
||||
- **Kiro support** — AWS Kiro IDE is now a supported tool with prompt and skill generation
|
||||
- **Sync prunes deselected workflows** — `openspec update` now removes command files and skill directories for workflows you've deselected, keeping your project clean
|
||||
- **Config drift warning** — `openspec config list` warns when global config is out of sync with the current project
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed onboard preflight giving a false "not initialized" error on freshly initialized projects
|
||||
- Fixed archive workflow stopping mid-way when syncing — it now properly resumes after sync completes
|
||||
- Added Windows PowerShell alternatives for onboard shell commands
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -36,27 +36,20 @@ Our philosophy:
|
||||
> [!TIP]
|
||||
> **New workflow now available!** We've rebuilt OpenSpec with a new artifact-guided workflow.
|
||||
>
|
||||
> Run `/opsx:onboard` to get started. → [Learn more here](docs/opsx.md)
|
||||
> Run `/opsx:propose "your idea"` to get started. → [Learn more here](docs/opsx.md)
|
||||
|
||||
<p align="center">
|
||||
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
|
||||
</p>
|
||||
|
||||
### Teams
|
||||
|
||||
Using OpenSpec in a team? [Email here](mailto:teams@openspec.dev) for access to our Slack channel.
|
||||
|
||||
<!-- TODO: Add GIF demo of /opsx:new → /opsx:archive workflow -->
|
||||
<!-- TODO: Add GIF demo of /opsx:propose → /opsx:archive workflow -->
|
||||
|
||||
## See it in action
|
||||
|
||||
```text
|
||||
You: /opsx:new add-dark-mode
|
||||
You: /opsx:propose add-dark-mode
|
||||
AI: Created openspec/changes/add-dark-mode/
|
||||
Ready to create: proposal
|
||||
|
||||
You: /opsx:ff # "fast-forward" - generate all planning docs
|
||||
AI: ✓ proposal.md — why we're doing this, what's changing
|
||||
✓ proposal.md — why we're doing this, what's changing
|
||||
✓ specs/ — requirements and scenarios
|
||||
✓ design.md — technical approach
|
||||
✓ tasks.md — implementation checklist
|
||||
@@ -101,10 +94,12 @@ cd your-project
|
||||
openspec init
|
||||
```
|
||||
|
||||
Now tell your AI: `/opsx:new <what-you-want-to-build>`
|
||||
Now tell your AI: `/opsx:propose <what-you-want-to-build>`
|
||||
|
||||
If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
||||
|
||||
> [!NOTE]
|
||||
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 20+ tools and growing.
|
||||
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 25+ tools and growing.
|
||||
>
|
||||
> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
|
||||
|
||||
|
||||
+31
-21
@@ -1,6 +1,6 @@
|
||||
# CLI Reference
|
||||
|
||||
The OpenSpec CLI (`openspec`) provides terminal commands for project setup, validation, status inspection, and management. These commands complement the AI slash commands (like `/opsx:new`) documented in [Commands](commands.md).
|
||||
The OpenSpec CLI (`openspec`) provides terminal commands for project setup, validation, status inspection, and management. These commands complement the AI slash commands (like `/opsx:propose`) documented in [Commands](commands.md).
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -67,6 +67,8 @@ These options work with all commands:
|
||||
|
||||
Initialize OpenSpec in your project. Creates the folder structure and configures AI tool integrations.
|
||||
|
||||
Default behavior uses global config defaults: profile `core`, delivery `both`, workflows `propose, explore, apply, archive`.
|
||||
|
||||
```
|
||||
openspec init [path] [options]
|
||||
```
|
||||
@@ -83,8 +85,11 @@ openspec init [path] [options]
|
||||
|--------|-------------|
|
||||
| `--tools <list>` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list |
|
||||
| `--force` | Auto-cleanup legacy files without prompting |
|
||||
| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |
|
||||
|
||||
**Supported tools:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `opencode`, `qoder`, `qwen`, `roocode`, `windsurf`
|
||||
`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).
|
||||
|
||||
**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -101,6 +106,9 @@ openspec init --tools claude,cursor
|
||||
# Configure for all supported tools
|
||||
openspec init --tools all
|
||||
|
||||
# Override profile for this run
|
||||
openspec init --profile core
|
||||
|
||||
# Skip prompts and auto-cleanup legacy files
|
||||
openspec init --force
|
||||
```
|
||||
@@ -113,8 +121,9 @@ openspec/
|
||||
├── changes/ # Proposed changes
|
||||
└── config.yaml # Project configuration
|
||||
|
||||
.claude/skills/ # Claude Code skill files (if claude selected)
|
||||
.cursor/rules/ # Cursor rules (if cursor selected)
|
||||
.claude/skills/ # Claude Code skills (if claude selected)
|
||||
.cursor/skills/ # Cursor skills (if cursor selected)
|
||||
.cursor/commands/ # Cursor OPSX commands (if delivery includes commands)
|
||||
... (other tool configs)
|
||||
```
|
||||
|
||||
@@ -122,7 +131,7 @@ openspec/
|
||||
|
||||
### `openspec update`
|
||||
|
||||
Update OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files.
|
||||
Update OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files using your current global profile, selected workflows, and delivery mode.
|
||||
|
||||
```
|
||||
openspec update [path] [options]
|
||||
@@ -428,29 +437,28 @@ openspec status --change add-dark-mode --json
|
||||
```
|
||||
Change: add-dark-mode
|
||||
Schema: spec-driven
|
||||
Progress: 2/4 artifacts complete
|
||||
|
||||
Artifacts:
|
||||
✓ proposal proposal.md exists
|
||||
✓ specs specs/ exists
|
||||
◆ design ready (requires: specs)
|
||||
○ tasks blocked (requires: design)
|
||||
|
||||
Next: Create design using /opsx:continue
|
||||
[x] proposal
|
||||
[ ] design
|
||||
[x] specs
|
||||
[-] tasks (blocked by: design)
|
||||
```
|
||||
|
||||
**Output (JSON):**
|
||||
|
||||
```json
|
||||
{
|
||||
"change": "add-dark-mode",
|
||||
"schema": "spec-driven",
|
||||
"changeName": "add-dark-mode",
|
||||
"schemaName": "spec-driven",
|
||||
"isComplete": false,
|
||||
"applyRequires": ["tasks"],
|
||||
"artifacts": [
|
||||
{"id": "proposal", "status": "complete", "path": "proposal.md"},
|
||||
{"id": "specs", "status": "complete", "path": "specs/"},
|
||||
{"id": "design", "status": "ready", "requires": ["specs"]},
|
||||
{"id": "tasks", "status": "blocked", "requires": ["design"]}
|
||||
],
|
||||
"next": "design"
|
||||
{"id": "proposal", "outputPath": "proposal.md", "status": "done"},
|
||||
{"id": "design", "outputPath": "design.md", "status": "ready"},
|
||||
{"id": "specs", "outputPath": "specs/**/*.md", "status": "done"},
|
||||
{"id": "tasks", "outputPath": "tasks.md", "status": "blocked", "missingDeps": ["design"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -912,6 +920,8 @@ openspec completion uninstall
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry |
|
||||
| `DO_NOT_TRACK` | Set to `1` to disable telemetry (standard DNT signal) |
|
||||
| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |
|
||||
| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |
|
||||
| `NO_COLOR` | Disable color output when set |
|
||||
@@ -920,7 +930,7 @@ openspec completion uninstall
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Commands](commands.md) - AI slash commands (`/opsx:new`, `/opsx:apply`, etc.)
|
||||
- [Commands](commands.md) - AI slash commands (`/opsx:propose`, `/opsx:apply`, etc.)
|
||||
- [Workflows](workflows.md) - Common patterns and when to use each command
|
||||
- [Customization](customization.md) - Create custom schemas and templates
|
||||
- [Getting Started](getting-started.md) - First-time setup guide
|
||||
|
||||
+61
-12
@@ -6,23 +6,70 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Default Quick Path (`core` profile)
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/opsx:propose` | Create a change and generate planning artifacts in one step |
|
||||
| `/opsx:explore` | Think through ideas before committing to a change |
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:apply` | Implement tasks from the change |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
|
||||
### Expanded Workflow Commands (custom workflow selection)
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/opsx:new` | Start a new change scaffold |
|
||||
| `/opsx:continue` | Create the next artifact based on dependencies |
|
||||
| `/opsx:ff` | Fast-forward: create all planning artifacts at once |
|
||||
| `/opsx:apply` | Implement tasks from the change |
|
||||
| `/opsx:verify` | Validate implementation matches artifacts |
|
||||
| `/opsx:sync` | Merge delta specs into main specs |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
| `/opsx:bulk-archive` | Archive multiple changes at once |
|
||||
| `/opsx:onboard` | Guided tutorial through the complete workflow |
|
||||
|
||||
The default global profile is `core`. To enable expanded workflow commands, run `openspec config profile`, select workflows, then run `openspec update` in your project.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `/opsx:propose`
|
||||
|
||||
Create a new change and generate planning artifacts in one step. This is the default start command in the `core` profile.
|
||||
|
||||
**Syntax:**
|
||||
```text
|
||||
/opsx:propose [change-name-or-description]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
| Argument | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `change-name-or-description` | No | Kebab-case name or plain-language change description |
|
||||
|
||||
**What it does:**
|
||||
- Creates `openspec/changes/<change-name>/`
|
||||
- Generates artifacts needed before implementation (for `spec-driven`: proposal, specs, design, tasks)
|
||||
- Stops when the change is ready for `/opsx:apply`
|
||||
|
||||
**Example:**
|
||||
```text
|
||||
You: /opsx:propose add-dark-mode
|
||||
|
||||
AI: Created openspec/changes/add-dark-mode/
|
||||
✓ proposal.md
|
||||
✓ specs/ui/spec.md
|
||||
✓ design.md
|
||||
✓ tasks.md
|
||||
Ready for implementation. Run /opsx:apply.
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- Use this for the fastest end-to-end path
|
||||
- If you want step-by-step artifact control, enable expanded workflows and use `/opsx:new` + `/opsx:continue`
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:explore`
|
||||
|
||||
Think through ideas, investigate problems, and clarify requirements before committing to a change.
|
||||
@@ -42,7 +89,7 @@ Think through ideas, investigate problems, and clarify requirements before commi
|
||||
- Investigates the codebase to answer questions
|
||||
- Compares options and approaches
|
||||
- Creates visual diagrams to clarify thinking
|
||||
- Can transition to `/opsx:new` when insights crystallize
|
||||
- Can transition to `/opsx:propose` (default) or `/opsx:new` (expanded workflow) when insights crystallize
|
||||
|
||||
**Example:**
|
||||
```text
|
||||
@@ -66,7 +113,7 @@ AI: Let me investigate your current auth setup...
|
||||
|
||||
You: Let's go with JWT. Can we start a change for that?
|
||||
|
||||
AI: Ready when you are. Run /opsx:new add-jwt-auth to begin.
|
||||
AI: Ready when you are. Run /opsx:propose add-jwt-auth to begin.
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
@@ -79,7 +126,9 @@ AI: Ready when you are. Run /opsx:new add-jwt-auth to begin.
|
||||
|
||||
### `/opsx:new`
|
||||
|
||||
Start a new change. Creates the change folder structure and scaffolds it with the selected schema.
|
||||
Start a new change scaffold. Creates the change folder and waits for you to generate artifacts with `/opsx:continue` or `/opsx:ff`.
|
||||
|
||||
This command is part of the expanded workflow set (not included in the default `core` profile).
|
||||
|
||||
**Syntax:**
|
||||
```
|
||||
@@ -565,13 +614,13 @@ Different AI tools use slightly different command syntax. Use the format that ma
|
||||
|
||||
| Tool | Syntax Example |
|
||||
|------|----------------|
|
||||
| Claude Code | `/opsx:new`, `/opsx:apply` |
|
||||
| Cursor | `/opsx-new`, `/opsx-apply` |
|
||||
| Windsurf | `/opsx-new`, `/opsx-apply` |
|
||||
| Copilot (IDE) | `/opsx-new`, `/opsx-apply` |
|
||||
| Trae | `/openspec-new-change`, `/openspec-apply-change` |
|
||||
| Claude Code | `/opsx:propose`, `/opsx:apply` |
|
||||
| Cursor | `/opsx-propose`, `/opsx-apply` |
|
||||
| Windsurf | `/opsx-propose`, `/opsx-apply` |
|
||||
| Copilot (IDE) | `/opsx-propose`, `/opsx-apply` |
|
||||
| Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) |
|
||||
|
||||
The functionality is identical regardless of syntax.
|
||||
The intent is the same across tools, but how commands are surfaced can differ by integration.
|
||||
|
||||
> **Note:** GitHub Copilot commands (`.github/prompts/*.prompt.md`) are only available in IDE extensions (VS Code, JetBrains, Visual Studio). GitHub Copilot CLI does not currently support custom prompt files — see [Supported Tools](supported-tools.md) for details and workarounds.
|
||||
|
||||
|
||||
+27
-27
@@ -7,10 +7,10 @@ This guide explains the core ideas behind OpenSpec and how they fit together. Fo
|
||||
OpenSpec is built around four principles:
|
||||
|
||||
```
|
||||
fluid not rigid — no phase gates, work on what makes sense
|
||||
fluid not rigid — no phase gates, work on what makes sense
|
||||
iterative not waterfall — learn as you build, refine as you go
|
||||
easy not complex — lightweight setup, minimal ceremony
|
||||
brownfield-first — works with existing codebases, not just greenfield
|
||||
easy not complex — lightweight setup, minimal ceremony
|
||||
brownfield-first — works with existing codebases, not just greenfield
|
||||
```
|
||||
|
||||
### Why These Principles Matter
|
||||
@@ -28,19 +28,19 @@ brownfield-first — works with existing codebases, not just greenfield
|
||||
OpenSpec organizes your work into two main areas:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ openspec/ │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ specs/ │ │ changes/ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Source of truth │◄─────│ Proposed modifications │ │
|
||||
│ │ How your system │ merge│ Each change = one folder │ │
|
||||
│ │ currently works │ │ Contains artifacts + deltas │ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────────────┘ └──────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ openspec/ │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ specs/ │ │ changes/ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Source of truth │◄─────│ Proposed modifications │ │
|
||||
│ │ How your system │ merge│ Each change = one folder │ │
|
||||
│ │ currently works │ │ Contains artifacts + deltas │ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────────────┘ └───────────────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Specs** are the source of truth — they describe how your system currently behaves.
|
||||
@@ -270,7 +270,7 @@ Delta specs describe **what's changing** relative to the current specs. See [Del
|
||||
|
||||
The design captures **technical approach** and **architecture decisions**.
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
# Design: Add Dark Mode
|
||||
|
||||
## Technical Approach
|
||||
@@ -306,7 +306,7 @@ CSS Variables (applied to :root)
|
||||
- `src/contexts/ThemeContext.tsx` (new)
|
||||
- `src/components/ThemeToggle.tsx` (new)
|
||||
- `src/styles/globals.css` (modified)
|
||||
```
|
||||
````
|
||||
|
||||
**When to update the design:**
|
||||
- Implementation reveals the approach won't work
|
||||
@@ -558,17 +558,17 @@ openspec/
|
||||
## How It All Fits Together
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ OPENSPEC FLOW │
|
||||
│ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ 1. START │ /opsx:new creates a change folder │
|
||||
│ │ 1. START │ /opsx:propose (core) or /opsx:new (expanded) │
|
||||
│ │ CHANGE │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ 2. CREATE │ /opsx:ff or /opsx:continue │
|
||||
│ │ 2. CREATE │ /opsx:ff or /opsx:continue (expanded workflow) │
|
||||
│ │ ARTIFACTS │ Creates proposal → specs → design → tasks │
|
||||
│ │ │ (based on schema dependencies) │
|
||||
│ └───────┬────────┘ │
|
||||
@@ -587,13 +587,13 @@ openspec/
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────┐ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ 5. ARCHIVE │────►│ Delta specs merge into main specs │ │
|
||||
│ │ CHANGE │ │ Change folder moves to archive/ │ │
|
||||
│ └────────────────┘ │ Specs are now the updated source of truth │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────┐ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ 5. ARCHIVE │────►│ Delta specs merge into main specs │ │
|
||||
│ │ CHANGE │ │ Change folder moves to archive/ │ │
|
||||
│ └────────────────┘ │ Specs are now the updated source of truth │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**The virtuous cycle:**
|
||||
|
||||
+20
-40
@@ -4,33 +4,22 @@ This guide explains how OpenSpec works after you've installed and initialized it
|
||||
|
||||
## How It Works
|
||||
|
||||
OpenSpec helps you and your AI coding assistant agree on what to build before any code is written. The workflow follows a simple pattern:
|
||||
OpenSpec helps you and your AI coding assistant agree on what to build before any code is written.
|
||||
|
||||
**Default quick path (core profile):**
|
||||
|
||||
```text
|
||||
/opsx:propose ──► /opsx:apply ──► /opsx:archive
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ Start a Change │ /opsx:new
|
||||
└────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Create Artifacts │ /opsx:ff or /opsx:continue
|
||||
│ (proposal, specs, │
|
||||
│ design, tasks) │
|
||||
└────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Implement Tasks │ /opsx:apply
|
||||
│ (AI writes code) │
|
||||
└────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Archive & Merge │ /opsx:archive
|
||||
│ Specs │
|
||||
└────────────────────┘
|
||||
|
||||
**Expanded path (custom workflow selection):**
|
||||
|
||||
```text
|
||||
/opsx:new ──► /opsx:ff or /opsx:continue ──► /opsx:apply ──► /opsx:verify ──► /opsx:archive
|
||||
```
|
||||
|
||||
The default global profile is `core`, which includes `propose`, `explore`, `apply`, and `archive`. You can enable the expanded workflow commands with `openspec config profile` and then `openspec update`.
|
||||
|
||||
## What OpenSpec Creates
|
||||
|
||||
After running `openspec init`, your project has this structure:
|
||||
@@ -131,23 +120,12 @@ The change folder moves to `openspec/changes/archive/` for audit history.
|
||||
|
||||
Let's walk through adding dark mode to an application.
|
||||
|
||||
### 1. Start the Change
|
||||
### 1. Start the Change (Default)
|
||||
|
||||
```
|
||||
You: /opsx:new add-dark-mode
|
||||
```text
|
||||
You: /opsx:propose add-dark-mode
|
||||
|
||||
AI: Created openspec/changes/add-dark-mode/
|
||||
Ready to create: proposal
|
||||
```
|
||||
|
||||
### 2. Create Artifacts
|
||||
|
||||
Use `/opsx:ff` (fast-forward) to create all planning artifacts at once:
|
||||
|
||||
```
|
||||
You: /opsx:ff
|
||||
|
||||
AI: Creating artifacts for add-dark-mode...
|
||||
✓ proposal.md — why we're doing this, what's changing
|
||||
✓ specs/ — requirements and scenarios
|
||||
✓ design.md — technical approach
|
||||
@@ -155,7 +133,9 @@ AI: Creating artifacts for add-dark-mode...
|
||||
Ready for implementation!
|
||||
```
|
||||
|
||||
### 3. What Gets Created
|
||||
If you've enabled the expanded workflow profile, you can also do this as two steps: `/opsx:new` then `/opsx:ff` (or `/opsx:continue` incrementally).
|
||||
|
||||
### 2. What Gets Created
|
||||
|
||||
**proposal.md** - Captures the intent:
|
||||
|
||||
@@ -218,7 +198,7 @@ The system SHALL allow users to choose between light and dark themes.
|
||||
- [ ] 3.2 Update components to use CSS variables
|
||||
```
|
||||
|
||||
### 4. Implement
|
||||
### 3. Implement
|
||||
|
||||
```
|
||||
You: /opsx:apply
|
||||
@@ -234,7 +214,7 @@ AI: Working through tasks...
|
||||
|
||||
During implementation, if you discover the design needs adjustment, just update the artifact and continue.
|
||||
|
||||
### 5. Archive
|
||||
### 4. Archive
|
||||
|
||||
```
|
||||
You: /opsx:archive
|
||||
|
||||
+35
-15
@@ -8,7 +8,7 @@ OPSX replaces the old phase-locked workflow with a fluid, action-based approach.
|
||||
|
||||
| Aspect | Legacy | OPSX |
|
||||
|--------|--------|------|
|
||||
| **Commands** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` | `/opsx:new`, `/opsx:continue`, `/opsx:apply`, and more |
|
||||
| **Commands** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` | Default: `/opsx:propose`, `/opsx:apply`, `/opsx:archive` (expanded workflow commands optional) |
|
||||
| **Workflow** | Create all artifacts at once | Create incrementally or all at once—your choice |
|
||||
| **Going back** | Awkward phase gates | Natural—update any artifact anytime |
|
||||
| **Customization** | Fixed structure | Schema-driven, fully hackable |
|
||||
@@ -84,6 +84,9 @@ Don't worry about getting it perfect. We're still learning what works best here,
|
||||
|
||||
Both `openspec init` and `openspec update` detect legacy files and guide you through the same cleanup process. Use whichever fits your situation:
|
||||
|
||||
- New installs default to profile `core` (`propose`, `explore`, `apply`, `archive`).
|
||||
- Migrated installs preserve your previously installed workflows by writing a `custom` profile when needed.
|
||||
|
||||
### Using `openspec init`
|
||||
|
||||
Run this if you want to add new tools or reconfigure which tools are set up:
|
||||
@@ -141,7 +144,7 @@ Run this if you just want to migrate and refresh your existing tools to the late
|
||||
openspec update
|
||||
```
|
||||
|
||||
The update command also detects and cleans up legacy artifacts, then refreshes your skills to the latest version.
|
||||
The update command also detects and cleans up legacy artifacts, then refreshes generated skills/commands to match your current profile and delivery settings.
|
||||
|
||||
### Non-Interactive / CI Environments
|
||||
|
||||
@@ -275,30 +278,43 @@ The AI will help you identify what's essential vs. what can be trimmed.
|
||||
|
||||
## The New Commands
|
||||
|
||||
After migration, you have 9 OPSX commands instead of 3:
|
||||
Command availability is profile-dependent:
|
||||
|
||||
**Default (`core` profile):**
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/opsx:propose` | Create a change and generate planning artifacts in one step |
|
||||
| `/opsx:explore` | Think through ideas with no structure |
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (one at a time) |
|
||||
| `/opsx:ff` | Fast-forward—create all planning artifacts at once |
|
||||
| `/opsx:apply` | Implement tasks from tasks.md |
|
||||
| `/opsx:verify` | Validate implementation matches specs |
|
||||
| `/opsx:sync` | Preview spec merge (optional—archive prompts if needed) |
|
||||
| `/opsx:archive` | Finalize and archive the change |
|
||||
|
||||
**Expanded workflow (custom selection):**
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/opsx:new` | Start a new change scaffold |
|
||||
| `/opsx:continue` | Create the next artifact (one at a time) |
|
||||
| `/opsx:ff` | Fast-forward—create planning artifacts at once |
|
||||
| `/opsx:verify` | Validate implementation matches specs |
|
||||
| `/opsx:sync` | Preview/spec-merge without archiving |
|
||||
| `/opsx:bulk-archive` | Archive multiple changes at once |
|
||||
| `/opsx:onboard` | Guided end-to-end onboarding workflow |
|
||||
|
||||
Enable expanded commands with `openspec config profile`, then run `openspec update`.
|
||||
|
||||
### Command Mapping from Legacy
|
||||
|
||||
| Legacy | OPSX Equivalent |
|
||||
|--------|-----------------|
|
||||
| `/openspec:proposal` | `/opsx:new` then `/opsx:ff` |
|
||||
| `/openspec:proposal` | `/opsx:propose` (default) or `/opsx:new` then `/opsx:ff` (expanded) |
|
||||
| `/openspec:apply` | `/opsx:apply` |
|
||||
| `/openspec:archive` | `/opsx:archive` |
|
||||
|
||||
### New Capabilities
|
||||
|
||||
These capabilities are part of the expanded workflow command set.
|
||||
|
||||
**Granular artifact creation:**
|
||||
```
|
||||
/opsx:continue
|
||||
@@ -542,9 +558,10 @@ project/
|
||||
│ └── config.yaml # NEW: Project configuration
|
||||
├── .claude/
|
||||
│ └── skills/ # NEW: OPSX skills
|
||||
│ ├── openspec-propose/ # default core profile
|
||||
│ ├── openspec-explore/
|
||||
│ ├── openspec-new-change/
|
||||
│ └── ...
|
||||
│ ├── openspec-apply-change/
|
||||
│ └── ... # expanded profile adds new/continue/ff/etc.
|
||||
├── CLAUDE.md # OpenSpec markers removed, your content preserved
|
||||
└── AGENTS.md # OpenSpec markers removed, your content preserved
|
||||
```
|
||||
@@ -558,12 +575,15 @@ project/
|
||||
|
||||
### Command Cheatsheet
|
||||
|
||||
```
|
||||
/opsx:new Start a change
|
||||
/opsx:continue Create next artifact
|
||||
/opsx:ff Create all planning artifacts
|
||||
```text
|
||||
/opsx:propose Start quickly (default core profile)
|
||||
/opsx:apply Implement tasks
|
||||
/opsx:archive Finish and archive
|
||||
|
||||
# Expanded workflow (if enabled):
|
||||
/opsx:new Scaffold a change
|
||||
/opsx:continue Create next artifact
|
||||
/opsx:ff Create planning artifacts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+24
-9
@@ -65,6 +65,8 @@ openspec init
|
||||
|
||||
This creates skills in `.claude/skills/` (or equivalent) that AI coding assistants auto-detect.
|
||||
|
||||
By default, OpenSpec uses the `core` workflow profile (`propose`, `explore`, `apply`, `archive`). If you want the expanded workflow commands (`new`, `continue`, `ff`, `verify`, `sync`, `bulk-archive`, `onboard`), configure them with `openspec config profile` and apply with `openspec update`.
|
||||
|
||||
During setup, you'll be prompted to create a **project config** (`openspec/config.yaml`). This is optional but recommended.
|
||||
|
||||
## Project Configuration
|
||||
@@ -155,13 +157,17 @@ rules:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:propose` | Create a change and generate planning artifacts in one step (default quick path) |
|
||||
| `/opsx:explore` | Think through ideas, investigate problems, clarify requirements |
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward — create all planning artifacts at once |
|
||||
| `/opsx:new` | Start a new change scaffold (expanded workflow) |
|
||||
| `/opsx:continue` | Create the next artifact (expanded workflow) |
|
||||
| `/opsx:ff` | Fast-forward planning artifacts (expanded workflow) |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:sync` | Sync delta specs to main (optional—archive prompts if needed) |
|
||||
| `/opsx:verify` | Validate implementation against artifacts (expanded workflow) |
|
||||
| `/opsx:sync` | Sync delta specs to main (expanded workflow, optional) |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
| `/opsx:bulk-archive` | Archive multiple completed changes (expanded workflow) |
|
||||
| `/opsx:onboard` | Guided walkthrough of an end-to-end change (expanded workflow) |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -169,13 +175,21 @@ rules:
|
||||
```
|
||||
/opsx:explore
|
||||
```
|
||||
Think through ideas, investigate problems, compare options. No structure required - just a thinking partner. When insights crystallize, transition to `/opsx:new` or `/opsx:ff`.
|
||||
Think through ideas, investigate problems, compare options. No structure required - just a thinking partner. When insights crystallize, transition to `/opsx:propose` (default) or `/opsx:new`/`/opsx:ff` (expanded).
|
||||
|
||||
### Start a new change
|
||||
```
|
||||
/opsx:new
|
||||
/opsx:propose
|
||||
```
|
||||
Creates the change and generates planning artifacts needed before implementation.
|
||||
|
||||
If you've enabled expanded workflows, you can instead use:
|
||||
|
||||
```text
|
||||
/opsx:new # scaffold only
|
||||
/opsx:continue # create one artifact at a time
|
||||
/opsx:ff # create all planning artifacts at once
|
||||
```
|
||||
You'll be asked what you want to build and which workflow schema to use.
|
||||
|
||||
### Create artifacts
|
||||
```
|
||||
@@ -299,6 +313,7 @@ Think of it like git branches:
|
||||
## Architecture Deep Dive
|
||||
|
||||
This section explains how OPSX works under the hood and how it compares to the legacy workflow.
|
||||
Examples in this section use the expanded command set (`new`, `continue`, etc.); default `core` users can map the same flow to `propose → apply → archive`.
|
||||
|
||||
### Philosophy: Phases vs Actions
|
||||
|
||||
@@ -356,7 +371,7 @@ This section explains how OPSX works under the hood and how it compares to the l
|
||||
│ Hardcoded Templates (TypeScript strings) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Configurators (18+ classes, one per editor) │
|
||||
│ Tool-specific configurators/adapters │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Generated Command Files (.claude/commands/openspec/*.md) │
|
||||
@@ -604,7 +619,7 @@ artifacts:
|
||||
| **State** | Phase-based mental model | Filesystem existence |
|
||||
| **Customization** | Edit source, rebuild | Create schema.yaml |
|
||||
| **Iteration** | Phase-locked | Fluid, edit anything |
|
||||
| **Editor Support** | 18+ configurator classes | Single skills directory |
|
||||
| **Editor Support** | Tool-specific configurator/adapters | Single skills directory |
|
||||
|
||||
## Schemas
|
||||
|
||||
|
||||
+69
-52
@@ -1,50 +1,61 @@
|
||||
# Supported Tools
|
||||
|
||||
OpenSpec works with 20+ AI coding assistants. When you run `openspec init`, you'll be prompted to select which tools you use, and OpenSpec will configure the appropriate integrations.
|
||||
OpenSpec works with many AI coding assistants. When you run `openspec init`, OpenSpec configures selected tools using your active profile/workflow selection and delivery mode.
|
||||
|
||||
## How It Works
|
||||
|
||||
For each tool you select, OpenSpec installs:
|
||||
For each selected tool, OpenSpec can install:
|
||||
|
||||
1. **Skills** — Reusable instruction files that power the `/opsx:*` workflow commands
|
||||
2. **Commands** — Tool-specific slash command bindings
|
||||
1. **Skills** (if delivery includes skills): `.../skills/openspec-*/SKILL.md`
|
||||
2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files
|
||||
|
||||
By default, OpenSpec uses the `core` profile, which includes:
|
||||
- `propose`
|
||||
- `explore`
|
||||
- `apply`
|
||||
- `archive`
|
||||
|
||||
You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `bulk-archive`, `onboard`) via `openspec config profile`, then run `openspec update`.
|
||||
|
||||
## Tool Directory Reference
|
||||
|
||||
| Tool | Skills Location | Commands Location |
|
||||
|------|-----------------|-------------------|
|
||||
| Amazon Q Developer | `.amazonq/skills/` | `.amazonq/prompts/` |
|
||||
| Antigravity | `.agent/skills/` | `.agent/workflows/` |
|
||||
| Auggie (Augment CLI) | `.augment/skills/` | `.augment/commands/` |
|
||||
| Claude Code | `.claude/skills/` | `.claude/commands/opsx/` |
|
||||
| Cline | `.cline/skills/` | `.clinerules/workflows/` |
|
||||
| CodeBuddy | `.codebuddy/skills/` | `.codebuddy/commands/opsx/` |
|
||||
| Codex | `.codex/skills/` | `~/.codex/prompts/`\* |
|
||||
| Continue | `.continue/skills/` | `.continue/prompts/` |
|
||||
| CoStrict | `.cospec/skills/` | `.cospec/openspec/commands/` |
|
||||
| Crush | `.crush/skills/` | `.crush/commands/opsx/` |
|
||||
| Cursor | `.cursor/skills/` | `.cursor/commands/` |
|
||||
| Factory Droid | `.factory/skills/` | `.factory/commands/` |
|
||||
| Gemini CLI | `.gemini/skills/` | `.gemini/commands/opsx/` |
|
||||
| GitHub Copilot | `.github/skills/` | `.github/prompts/`\*\* |
|
||||
| iFlow | `.iflow/skills/` | `.iflow/commands/` |
|
||||
| Kilo Code | `.kilocode/skills/` | `.kilocode/workflows/` |
|
||||
| Kiro | `.kiro/skills/` | `.kiro/prompts/` |
|
||||
| OpenCode | `.opencode/skills/` | `.opencode/command/` |
|
||||
| Pi | `.pi/skills/` | `.pi/prompts/` |
|
||||
| Qoder | `.qoder/skills/` | `.qoder/commands/opsx/` |
|
||||
| Qwen Code | `.qwen/skills/` | `.qwen/commands/` |
|
||||
| RooCode | `.roo/skills/` | `.roo/commands/` |
|
||||
| Trae | `.trae/skills/` | `.trae/skills/` (via `/openspec-*`) |
|
||||
| Windsurf | `.windsurf/skills/` | `.windsurf/workflows/` |
|
||||
| Tool (ID) | Skills path pattern | Command path pattern |
|
||||
|-----------|---------------------|----------------------|
|
||||
| Amazon Q Developer (`amazon-q`) | `.amazonq/skills/openspec-*/SKILL.md` | `.amazonq/prompts/opsx-<id>.md` |
|
||||
| Antigravity (`antigravity`) | `.agent/skills/openspec-*/SKILL.md` | `.agent/workflows/opsx-<id>.md` |
|
||||
| Auggie (`auggie`) | `.augment/skills/openspec-*/SKILL.md` | `.augment/commands/opsx-<id>.md` |
|
||||
| IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-<id>.md` |
|
||||
| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |
|
||||
| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.md` |
|
||||
| CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/<id>.md` |
|
||||
| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-<id>.md`\* |
|
||||
| ForgeCode (`forgecode`) | `.forge/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
|
||||
| Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-<id>.prompt` |
|
||||
| CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-<id>.md` |
|
||||
| Crush (`crush`) | `.crush/skills/openspec-*/SKILL.md` | `.crush/commands/opsx/<id>.md` |
|
||||
| Cursor (`cursor`) | `.cursor/skills/openspec-*/SKILL.md` | `.cursor/commands/opsx-<id>.md` |
|
||||
| Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-<id>.md` |
|
||||
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
|
||||
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
|
||||
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
|
||||
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
|
||||
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
|
||||
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
|
||||
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
|
||||
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
|
||||
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
|
||||
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.toml` |
|
||||
| RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.md` |
|
||||
| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
|
||||
| Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-<id>.md` |
|
||||
|
||||
\* Codex commands are installed to the global home directory (`~/.codex/prompts/` or `$CODEX_HOME/prompts/`), not the project directory.
|
||||
\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory.
|
||||
|
||||
\*\* GitHub Copilot's `.github/prompts/*.prompt.md` files are recognized as custom slash commands in **IDE extensions only** (VS Code, JetBrains, Visual Studio). GitHub Copilot CLI does not currently support custom prompts from this directory — see [github/copilot-cli#618](https://github.com/github/copilot-cli/issues/618). If you use Copilot CLI, you may need to manually set up [custom agents](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents) in `.github/agents/` as a workaround.
|
||||
\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly.
|
||||
|
||||
## Non-Interactive Setup
|
||||
|
||||
For CI/CD or scripted setup, use the `--tools` flag:
|
||||
For CI/CD or scripted setup, use `--tools` (and optionally `--profile`):
|
||||
|
||||
```bash
|
||||
# Configure specific tools
|
||||
@@ -55,34 +66,40 @@ openspec init --tools all
|
||||
|
||||
# Skip tool configuration
|
||||
openspec init --tools none
|
||||
|
||||
# Override profile for this init run
|
||||
openspec init --profile core
|
||||
```
|
||||
|
||||
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
|
||||
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `forgecode`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
|
||||
|
||||
## What Gets Installed
|
||||
## Workflow-Dependent Installation
|
||||
|
||||
For each tool, OpenSpec generates 10 skill files that power the OPSX workflow:
|
||||
OpenSpec installs workflow artifacts based on selected workflows:
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `openspec-explore` | Thinking partner for exploring ideas |
|
||||
| `openspec-new-change` | Start a new change |
|
||||
| `openspec-continue-change` | Create the next artifact |
|
||||
| `openspec-ff-change` | Fast-forward through all planning artifacts |
|
||||
| `openspec-apply-change` | Implement tasks |
|
||||
| `openspec-verify-change` | Verify implementation completeness |
|
||||
| `openspec-sync-specs` | Sync delta specs to main (optional—archive prompts if needed) |
|
||||
| `openspec-archive-change` | Archive a completed change |
|
||||
| `openspec-bulk-archive-change` | Archive multiple changes at once |
|
||||
| `openspec-onboard` | Guided onboarding through a complete workflow cycle |
|
||||
- **Core profile (default):** `propose`, `explore`, `apply`, `archive`
|
||||
- **Custom selection:** any subset of all workflow IDs:
|
||||
`propose`, `explore`, `new`, `continue`, `apply`, `ff`, `sync`, `archive`, `bulk-archive`, `verify`, `onboard`
|
||||
|
||||
These skills are invoked via slash commands like `/opsx:new`, `/opsx:apply`, etc. See [Commands](commands.md) for the full list.
|
||||
In other words, skill/command counts are profile-dependent and delivery-dependent, not fixed.
|
||||
|
||||
## Adding a New Tool
|
||||
## Generated Skill Names
|
||||
|
||||
Want to add support for another AI coding assistant? Check out the [command adapter pattern](../CONTRIBUTING.md) or open an issue on GitHub.
|
||||
When selected by profile/workflow config, OpenSpec generates these skills:
|
||||
|
||||
---
|
||||
- `openspec-propose`
|
||||
- `openspec-explore`
|
||||
- `openspec-new-change`
|
||||
- `openspec-continue-change`
|
||||
- `openspec-apply-change`
|
||||
- `openspec-ff-change`
|
||||
- `openspec-sync-specs`
|
||||
- `openspec-archive-change`
|
||||
- `openspec-bulk-archive-change`
|
||||
- `openspec-verify-change`
|
||||
- `openspec-onboard`
|
||||
|
||||
See [Commands](commands.md) for command behavior and [CLI](cli.md) for `init`/`update` options.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+33
-7
@@ -28,7 +28,32 @@ OPSX (fluid actions):
|
||||
|
||||
> **Customization:** OPSX workflows are driven by schemas that define artifact sequences. See [Customization](customization.md) for details on creating custom schemas.
|
||||
|
||||
## Workflow Patterns
|
||||
## Two Modes
|
||||
|
||||
### Default Quick Path (`core` profile)
|
||||
|
||||
New installs default to `core`, which provides:
|
||||
- `/opsx:propose`
|
||||
- `/opsx:explore`
|
||||
- `/opsx:apply`
|
||||
- `/opsx:archive`
|
||||
|
||||
Typical flow:
|
||||
|
||||
```text
|
||||
/opsx:propose ──► /opsx:apply ──► /opsx:archive
|
||||
```
|
||||
|
||||
### Expanded/Full Workflow (custom selection)
|
||||
|
||||
If you want explicit scaffold-and-build commands (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), enable them with:
|
||||
|
||||
```bash
|
||||
openspec config profile
|
||||
openspec update
|
||||
```
|
||||
|
||||
## Workflow Patterns (Expanded Mode)
|
||||
|
||||
### Quick Feature
|
||||
|
||||
@@ -408,15 +433,16 @@ For full command details and options, see [Commands](commands.md).
|
||||
|
||||
| Command | Purpose | When to Use |
|
||||
|---------|---------|-------------|
|
||||
| `/opsx:propose` | Create change + planning artifacts | Fast default path (`core` profile) |
|
||||
| `/opsx:explore` | Think through ideas | Unclear requirements, investigation |
|
||||
| `/opsx:new` | Start a change | Beginning any new work |
|
||||
| `/opsx:continue` | Create next artifact | Step-by-step artifact creation |
|
||||
| `/opsx:ff` | Create all planning artifacts | Clear scope, ready to build |
|
||||
| `/opsx:new` | Start a change scaffold | Expanded mode, explicit artifact control |
|
||||
| `/opsx:continue` | Create next artifact | Expanded mode, step-by-step artifact creation |
|
||||
| `/opsx:ff` | Create all planning artifacts | Expanded mode, clear scope |
|
||||
| `/opsx:apply` | Implement tasks | Ready to write code |
|
||||
| `/opsx:verify` | Validate implementation | Before archiving, catch mismatches |
|
||||
| `/opsx:sync` | Merge delta specs | Optional—archive prompts if needed |
|
||||
| `/opsx:verify` | Validate implementation | Expanded mode, before archiving |
|
||||
| `/opsx:sync` | Merge delta specs | Expanded mode, optional |
|
||||
| `/opsx:archive` | Complete the change | All work finished |
|
||||
| `/opsx:bulk-archive` | Archive multiple changes | Parallel work, batch completion |
|
||||
| `/opsx:bulk-archive` | Archive multiple changes | Expanded mode, parallel work |
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-25
|
||||
@@ -0,0 +1,48 @@
|
||||
## Context
|
||||
|
||||
The OpenCode adapter in `src/core/command-generation/adapters/opencode.ts` currently generates command files at `.opencode/command/opsx-<id>.md` (singular `command`). OpenCode's official documentation uses `.opencode/commands/` (plural), and every other adapter in the codebase follows the plural convention for commands directories. The legacy cleanup module in `src/core/legacy-cleanup.ts` also references the singular form for detecting old artifacts.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Align the OpenCode adapter path with OpenCode's official `.opencode/commands/` convention
|
||||
- Add the old singular path `.opencode/command/` to legacy cleanup so existing installations are properly cleaned
|
||||
- Update documentation to reflect the corrected path
|
||||
- Update test assertions to match the new path
|
||||
|
||||
**Non-Goals:**
|
||||
- Changing the OpenCode skill path (`.opencode/skills/`) — already correct
|
||||
- Modifying any other adapter's directory structure
|
||||
- Adding migration prompts or interactive upgrade flows
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Direct path rename in adapter
|
||||
|
||||
**Decision:** Change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` in the adapter's `getFilePath` method.
|
||||
|
||||
**Rationale:** This is a single-line change that aligns with the established pattern across all other adapters. No abstraction or indirection needed.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Add a configuration option for the directory name — rejected as over-engineering for a bug fix
|
||||
- Keep singular and add plural as alias — rejected as it creates ambiguity about which is canonical
|
||||
|
||||
### 2. Legacy cleanup via existing constant map
|
||||
|
||||
**Decision:** Update the `LEGACY_SLASH_COMMAND_PATHS` entry for `'opencode'` from `'.opencode/command/openspec-*.md'` to `'.opencode/command/opsx-*.md'` (the old singular path becomes the legacy pattern) and ensure the new path is handled by the current command generation pipeline.
|
||||
|
||||
**Rationale:** The existing legacy cleanup infrastructure uses `LEGACY_SLASH_COMMAND_PATHS` as an explicit lookup. The old singular-path pattern already matches the legacy format (`openspec-*` prefix from the old SlashCommandRegistry era). The current command generation uses the `opsx-*` prefix, so we also need to add a legacy pattern for `opsx-*` files in the old singular directory.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Add a separate migration script — rejected; the existing legacy cleanup mechanism handles this scenario
|
||||
|
||||
### 3. Documentation update
|
||||
|
||||
**Decision:** Update the `docs/supported-tools.md` table entry for OpenCode from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`.
|
||||
|
||||
**Rationale:** Documentation must match the actual generated paths.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Existing installations have files at old path]** → Mitigated by legacy cleanup detecting `.opencode/command/` artifacts. On next `openspec init`, old files are cleaned up and new files written to `.opencode/commands/`.
|
||||
- **[Users referencing old path in custom scripts]** → Low risk. The old path was incorrect per OpenCode's specification, so custom references were already misaligned.
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
The OpenCode adapter uses `.opencode/command/` (singular) for its commands directory, but OpenCode's official documentation specifies `.opencode/commands/` (plural). Every other adapter in the codebase also uses plural directory names (`.claude/commands/`, `.cursor/commands/`, `.factory/commands/`, etc.). This inconsistency was introduced in Oct 2025 without documented rationale. Fixes [#748](https://github.com/Fission-AI/OpenSpec/issues/748).
|
||||
|
||||
## What Changes
|
||||
|
||||
- OpenCode adapter path changes from `.opencode/command/` to `.opencode/commands/`
|
||||
- Legacy cleanup adds `.opencode/command/` (old singular path) for backward compatibility
|
||||
- Documentation updated to reflect the new plural path
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `command-generation`: OpenCode adapter path changes from singular `command/` to plural `commands/` to match OpenCode's official directory convention
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/core/command-generation/adapters/opencode.ts` — adapter path
|
||||
- `src/core/legacy-cleanup.ts` — legacy cleanup pattern + add old singular path
|
||||
- `docs/supported-tools.md` — documentation table
|
||||
- `test/core/command-generation/adapters.test.ts` — test assertion
|
||||
@@ -0,0 +1,63 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: ToolCommandAdapter interface
|
||||
|
||||
The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.
|
||||
|
||||
#### Scenario: Adapter interface structure
|
||||
|
||||
- **WHEN** implementing a tool adapter
|
||||
- **THEN** `ToolCommandAdapter` SHALL require:
|
||||
- `toolId`: string identifier matching `AIToolOption.value`
|
||||
- `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)
|
||||
- `formatFile(content: CommandContent)`: returns complete file content with frontmatter
|
||||
|
||||
#### Scenario: Claude adapter formatting
|
||||
|
||||
- **WHEN** formatting a command for Claude Code
|
||||
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
|
||||
- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`
|
||||
|
||||
#### Scenario: Cursor adapter formatting
|
||||
|
||||
- **WHEN** formatting a command for Cursor
|
||||
- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields
|
||||
- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`
|
||||
|
||||
#### Scenario: Windsurf adapter 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/workflows/opsx-<id>.md`
|
||||
|
||||
#### Scenario: OpenCode adapter formatting
|
||||
|
||||
- **WHEN** formatting a command for OpenCode
|
||||
- **THEN** the adapter SHALL output YAML frontmatter with `description` field
|
||||
- **AND** file path SHALL follow pattern `.opencode/commands/opsx-<id>.md` using `path.join('.opencode', 'commands', ...)` for cross-platform compatibility
|
||||
- **AND** the adapter SHALL transform colon-based command references (`/opsx:name`) to hyphen-based (`/opsx-name`) in the body
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Legacy cleanup for renamed OpenCode command directory
|
||||
|
||||
The legacy cleanup module SHALL detect and remove old OpenCode command files from the previous singular `.opencode/command/` directory path.
|
||||
|
||||
#### Scenario: Detect old singular-path OpenCode command files
|
||||
|
||||
- **WHEN** running legacy artifact detection on a project with files matching `.opencode/command/opsx-*.md` or `.opencode/command/openspec-*.md`
|
||||
- **THEN** the system SHALL include those files in the legacy slash command files list via `LEGACY_SLASH_COMMAND_PATHS`
|
||||
- **AND** `LegacySlashCommandPattern.pattern` SHALL accept `string | string[]` to support multiple glob patterns per tool
|
||||
|
||||
#### Scenario: Clean up old OpenCode command files on init
|
||||
|
||||
- **WHEN** a user runs `openspec init` in a project with old `.opencode/command/` artifacts
|
||||
- **THEN** the system SHALL remove the old files
|
||||
- **AND** generate new command files at `.opencode/commands/`
|
||||
|
||||
#### Scenario: Auto-cleanup legacy artifacts in non-interactive mode
|
||||
|
||||
- **WHEN** a user runs `openspec init` in non-interactive mode (e.g., CI) and legacy artifacts are detected
|
||||
- **THEN** the system SHALL auto-cleanup legacy artifacts without requiring `--force`
|
||||
- **AND** legacy slash command files (100% OpenSpec-managed) SHALL be removed
|
||||
- **AND** config file cleanup SHALL only remove OpenSpec markers (never delete user files)
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. Adapter Fix
|
||||
|
||||
- [x] 1.1 Update `src/core/command-generation/adapters/opencode.ts`: change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` and update the JSDoc comment
|
||||
|
||||
## 2. Legacy Cleanup
|
||||
|
||||
- [x] 2.1 Update `src/core/legacy-cleanup.ts`: update the `'opencode'` entry in `LEGACY_SLASH_COMMAND_PATHS` to detect both `opsx-*.md` and `openspec-*.md` patterns at `.opencode/command/` for backward compatibility
|
||||
|
||||
## 3. Documentation
|
||||
|
||||
- [x] 3.1 Update `docs/supported-tools.md`: change OpenCode command path from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [x] 4.1 Update `test/core/command-generation/adapters.test.ts`: change the OpenCode file path assertion from `path.join('.opencode', 'command', 'opsx-explore.md')` to `path.join('.opencode', 'commands', 'opsx-explore.md')`
|
||||
|
||||
## 5. Changeset
|
||||
|
||||
- [x] 5.1 Create a changeset file (`.changeset/fix-opencode-commands-directory.md`) with a patch bump describing the path fix
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-25
|
||||
@@ -0,0 +1,38 @@
|
||||
## Context
|
||||
|
||||
`statusCommand` in `src/commands/workflow/status.ts` calls `validateChangeExists()` from `shared.ts` as its first operation. When no `--change` option is provided and no change directories exist, `validateChangeExists` throws: `No changes found. Create one with: openspec new change <name>`. This error propagates up as a fatal CLI error (non-zero exit code).
|
||||
|
||||
This is correct behavior for commands like `apply` and `show` that require a change to operate on. However, `status` is an informational command — it should report the current state, even when that state is "no changes exist."
|
||||
|
||||
The error surfaces during onboarding (issue #714) when AI agents call `openspec status` before any change has been created.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Make `openspec status` exit with code 0 and a friendly message when no changes exist
|
||||
- Support both text and JSON output modes for the no-changes case
|
||||
- Keep all other commands' validation behavior unchanged
|
||||
|
||||
**Non-Goals:**
|
||||
- Changing the behavior of `validateChangeExists` (keep it strict for all consumers; only extract its internal helper)
|
||||
- Changing the onboard template or skill instructions
|
||||
- Handling the case where `--change` is provided but the specific change doesn't exist (this should remain an error)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Extract `getAvailableChanges` and check before validation
|
||||
|
||||
**Rationale**: Extract the private `getAvailableChanges` closure from `validateChangeExists` into a public exported function in `shared.ts`. Then, in `statusCommand`, call `getAvailableChanges` *before* `validateChangeExists` to detect the no-changes case early and handle it gracefully. This avoids using try/catch for control flow and eliminates any coupling to error message strings.
|
||||
|
||||
**Alternative considered**: Catching the error from `validateChangeExists` by matching `error.message.startsWith('No changes found')`. Rejected because string coupling is fragile — if the error message changes, the catch silently stops working.
|
||||
|
||||
**Alternative considered**: Adding a `throwOnEmpty` parameter to `validateChangeExists`. Rejected because it adds complexity to a shared function for a single consumer's needs and mixes UX concerns into a validation utility.
|
||||
|
||||
### Keep `validateChangeExists` strict
|
||||
|
||||
**Rationale**: `validateChangeExists` remains unchanged in behavior — it still throws for all error cases. The graceful handling lives entirely in `statusCommand`, which is the appropriate layer for UX decisions. Other commands (`apply`, `show`, `instructions`) are unaffected.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Extra filesystem read when no `--change` is provided and changes *do* exist (`getAvailableChanges` is called first, then `validateChangeExists` performs its own read) → Mitigation: `statusCommand` returns early before reaching `validateChangeExists` when no changes exist, so the double-read only occurs when changes are present — minimal overhead.
|
||||
- [Risk] Other commands may also benefit from graceful no-changes handling in the future → Mitigation: `getAvailableChanges` is now public and reusable, making it easy to apply the same pattern elsewhere.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
When `openspec status` is called without `--change` and no changes exist (e.g., during onboarding on a freshly initialized project), the CLI throws a fatal error: `No changes found. Create one with: openspec new change <name>`. This breaks the onboarding flow because AI agents may call `openspec status` before any change has been created, causing the agent to halt or report failure. Fixes [#714](https://github.com/Fission-AI/OpenSpec/issues/714).
|
||||
|
||||
## What Changes
|
||||
|
||||
- `openspec status` will exit gracefully (code 0) with a friendly message when no changes exist, instead of throwing a fatal error
|
||||
- `openspec status --json` will return a valid JSON object with an empty changes array when no changes exist
|
||||
- Other commands (`apply`, `show`, etc.) retain their current strict validation behavior
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `graceful-status-empty`: Graceful handling of `openspec status` when no changes exist, covering both text and JSON output modes
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
_None — `validateChangeExists` was internally refactored to delegate to the newly exported `getAvailableChanges`, but its behavior and public contract are unchanged. Other consumers are unaffected._
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/commands/workflow/shared.ts` — extract `getAvailableChanges` as a public function (validation behavior unchanged)
|
||||
- `src/commands/workflow/status.ts` — check for available changes before validation, handle empty case gracefully
|
||||
- Tests for the status command need to cover the new graceful behavior
|
||||
@@ -0,0 +1,27 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Status command exits gracefully when no changes exist
|
||||
The `statusCommand` function SHALL check for available changes via `getAvailableChanges` before calling `validateChangeExists`. When no `--change` option is provided and no change directories exist, it SHALL print a friendly informational message and exit with code 0, instead of reaching `validateChangeExists` and propagating a fatal error.
|
||||
|
||||
#### Scenario: No changes exist, text mode
|
||||
- **WHEN** user runs `openspec status` without `--change` and no change directories exist under `openspec/changes/`
|
||||
- **THEN** the CLI prints `No active changes. Create one with: openspec new change <name>` to stdout and exits with code 0
|
||||
|
||||
#### Scenario: No changes exist, JSON mode
|
||||
- **WHEN** user runs `openspec status --json` without `--change` and no change directories exist
|
||||
- **THEN** the CLI outputs `{"changes":[],"message":"No active changes."}` as valid JSON to stdout and exits with code 0
|
||||
|
||||
### Requirement: Existing status validation behavior is preserved
|
||||
Other error paths in `validateChangeExists` that apply to the status command SHALL continue to throw errors as before. Commands other than `status` that use `validateChangeExists` SHALL NOT be affected.
|
||||
|
||||
#### Scenario: Changes exist but --change not specified
|
||||
- **WHEN** user runs `openspec status` without `--change` and one or more change directories exist
|
||||
- **THEN** the CLI throws an error listing available changes with the message `Missing required option --change. Available changes: ...`
|
||||
|
||||
#### Scenario: Specified change does not exist
|
||||
- **WHEN** user runs `openspec status --change non-existent`
|
||||
- **THEN** the CLI throws an error with message `Change 'non-existent' not found`
|
||||
|
||||
#### Scenario: Other commands unaffected
|
||||
- **WHEN** user runs `openspec show` or `openspec instructions` without `--change` and no changes exist
|
||||
- **THEN** the CLI throws the original `No changes found` error (no behavior change)
|
||||
@@ -0,0 +1,16 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 Extract `getAvailableChanges` in `shared.ts` and use it in `statusCommand` to check for changes before calling `validateChangeExists`
|
||||
- [x] 1.2 In text mode: print `No active changes. Create one with: openspec new change <name>` and return (exit 0)
|
||||
- [x] 1.3 In JSON mode: output `{"changes":[],"message":"No active changes."}` and return (exit 0)
|
||||
|
||||
## 2. Tests
|
||||
|
||||
- [x] 2.1 Add test: `openspec status` with no changes exits gracefully with friendly message (text mode)
|
||||
- [x] 2.2 Add test: `openspec status --json` with no changes returns valid JSON with empty changes array
|
||||
- [x] 2.3 Verify existing behavior: `openspec status` without `--change` when changes exist still throws missing option error
|
||||
- [x] 2.4 Verify cross-platform: tests use `path.join()` for any path assertions
|
||||
|
||||
## 3. Release
|
||||
|
||||
- [x] 3.1 Add changeset describing the fix
|
||||
@@ -160,15 +160,14 @@ The update command SHALL only run inside an initialized OpenSpec project.
|
||||
- **THEN** the system SHALL display: "No OpenSpec project found. Run 'openspec init' to set up."
|
||||
- **THEN** the system SHALL exit with code 1
|
||||
|
||||
### Requirement: Extra workflows preserved
|
||||
The update command SHALL NOT remove workflow files that aren't in the current profile.
|
||||
### Requirement: Extra workflows synchronized to active profile
|
||||
The update command SHALL remove workflow files that are no longer selected in the current profile.
|
||||
|
||||
#### Scenario: Extra workflows from previous profile
|
||||
#### Scenario: Deselected workflows from previous profile
|
||||
- **WHEN** user runs `openspec update`
|
||||
- **AND** project has workflows not in current profile (e.g., user switched from custom to core)
|
||||
- **THEN** the system SHALL NOT delete those extra workflow files
|
||||
- **THEN** the system SHALL only add/update workflows in the current profile
|
||||
- **THEN** the system SHALL display a note: "Note: <count> extra workflows not in profile (use `openspec config profile` to manage)"
|
||||
- **AND** project has workflows not in current profile (e.g., user switched from custom to core or deselected workflows via `openspec config profile`)
|
||||
- **THEN** the system SHALL delete skill and command workflow files for deselected workflows (respecting active delivery mode)
|
||||
- **THEN** the system SHALL keep only workflows currently selected in profile
|
||||
|
||||
#### Scenario: Delivery change with extra workflows
|
||||
- **WHEN** user runs `openspec update`
|
||||
|
||||
@@ -15,21 +15,19 @@ The design goal is to preserve current behavior while making extension points ex
|
||||
- Define one canonical source for workflow content and metadata
|
||||
- Make tool/agent-specific behavior explicit and centrally discoverable
|
||||
- Keep command adapters as the formatting boundary for tool syntax differences
|
||||
- Represent tool-specific command surfaces and terminology explicitly (not as scattered string rewrites)
|
||||
- Consolidate artifact generation/write orchestration into one reusable engine
|
||||
- Improve correctness with enforceable validation and parity tests
|
||||
|
||||
**Non-Goals:**
|
||||
- Redesigning command semantics or workflow instruction content
|
||||
- Changing user-facing CLI command names/flags in this proposal
|
||||
- Guaranteeing fully accurate literal slash-command strings for every supported tool on day one
|
||||
- Merging unrelated legacy cleanup behavior beyond artifact generation reuse
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Canonical `WorkflowManifest`
|
||||
|
||||
**Decision**: Represent each workflow once in a manifest entry containing canonical skill and command definitions plus metadata defaults. Canonical text uses semantic tokens for tool-specific references.
|
||||
**Decision**: Represent each workflow once in a manifest entry containing canonical skill and command definitions plus metadata defaults.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
@@ -43,12 +41,6 @@ interface WorkflowManifestEntry {
|
||||
tags: string[];
|
||||
compatibility: string;
|
||||
}
|
||||
|
||||
// Examples in canonical workflow text:
|
||||
// - {{cmd.apply}}
|
||||
// - {{cmd.continue.withArg}}
|
||||
// - {{term.change}}
|
||||
// - {{term.workflow}}
|
||||
```
|
||||
|
||||
**Rationale**:
|
||||
@@ -58,7 +50,7 @@ interface WorkflowManifestEntry {
|
||||
|
||||
### 2. `ToolProfileRegistry` for capability wiring
|
||||
|
||||
**Decision**: Add a tool profile layer that maps tool IDs to generation capabilities, command-surface rendering, and terminology.
|
||||
**Decision**: Add a tool profile layer that maps tool IDs to generation capabilities and behavior.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
@@ -67,16 +59,6 @@ interface ToolProfile {
|
||||
toolId: string;
|
||||
skillsDir?: string;
|
||||
commandAdapterId?: string;
|
||||
commandSurface: {
|
||||
pattern: 'opsx-colon' | 'opsx-hyphen' | 'opsx-slash' | 'openspec-hyphen' | 'custom';
|
||||
verified: boolean;
|
||||
aliases?: string[];
|
||||
};
|
||||
terminology: {
|
||||
change: string;
|
||||
workflow: string;
|
||||
command: string;
|
||||
};
|
||||
transforms: string[];
|
||||
}
|
||||
```
|
||||
@@ -85,12 +67,10 @@ interface ToolProfile {
|
||||
- Prevents capability drift between `AI_TOOLS`, adapter registry, and detection logic
|
||||
- Allows intentional "skills-only" tools without implicit special casing
|
||||
- Provides one place to answer "what does this tool support?"
|
||||
- Makes command rendering decisions explicit and testable
|
||||
- Supports future terminology tailoring without copy/paste template forks
|
||||
|
||||
### 3. First-class transform pipeline
|
||||
|
||||
**Decision**: Model transforms as ordered plugins with scope + phase + applicability. Include token rendering in the transform pipeline instead of hardcoding literal command strings in templates.
|
||||
**Decision**: Model transforms as ordered plugins with scope + phase + applicability.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
@@ -107,32 +87,17 @@ interface ArtifactTransform {
|
||||
|
||||
Execution order:
|
||||
1. Render canonical content from manifest
|
||||
2. Apply token-render transform (`{{cmd.*}}`, `{{term.*}}`) using tool profile
|
||||
3. Apply matching `preAdapter` transforms
|
||||
4. For commands, run adapter formatting
|
||||
5. Apply matching `postAdapter` transforms
|
||||
6. Validate and write
|
||||
2. Apply matching `preAdapter` transforms
|
||||
3. For commands, run adapter formatting
|
||||
4. Apply matching `postAdapter` transforms
|
||||
5. Validate and write
|
||||
|
||||
**Rationale**:
|
||||
- Keeps adapters focused on tool formatting, not scattered behavioral rewrites
|
||||
- Makes agent-specific modifications explicit and testable
|
||||
- Replaces ad-hoc transform calls in `init`/`update`
|
||||
- Enables neutral fallback rendering when a tool profile is not verified for literal command syntax
|
||||
|
||||
### 4. Fallback policy for unverified command surfaces
|
||||
|
||||
**Decision**: When a tool profile has `commandSurface.verified === false`, command tokens SHALL render to neutral workflow guidance instead of literal slash-command strings.
|
||||
|
||||
Examples:
|
||||
- Literal (verified): `Run {{cmd.apply}}`
|
||||
- Neutral (unverified): `Run the Apply workflow` or `use the apply skill`
|
||||
|
||||
**Rationale**:
|
||||
- Prevents confidently wrong guidance in generated artifacts
|
||||
- Allows incremental tool-surface verification without blocking rollout
|
||||
- Keeps templates stable while rendering policy evolves
|
||||
|
||||
### 5. Shared `ArtifactSyncEngine`
|
||||
### 4. Shared `ArtifactSyncEngine`
|
||||
|
||||
**Decision**: Introduce a single orchestration engine used by all generation entry points.
|
||||
|
||||
@@ -147,15 +112,13 @@ Responsibilities:
|
||||
- Enables dry-run and future preview features without re-implementing logic
|
||||
- Improves reliability of updates and legacy migrations
|
||||
|
||||
### 6. Validation + parity guardrails
|
||||
### 5. Validation + parity guardrails
|
||||
|
||||
**Decision**: Add strict checks in tests (and optional runtime assertions in dev builds) for:
|
||||
|
||||
- Required skill metadata fields (`license`, `compatibility`, `metadata`) present for all manifest entries
|
||||
- Projection consistency (skills, commands, detection names derived from manifest)
|
||||
- Tool profile consistency (adapter existence, expected capabilities)
|
||||
- Token coverage checks (no unresolved `{{...}}` tokens in rendered outputs)
|
||||
- Tool command-surface verification matrix and fallback expectations
|
||||
- Golden/parity output for key workflows/tools
|
||||
|
||||
**Rationale**:
|
||||
@@ -180,8 +143,7 @@ Adding manifest/profile/transform registries increases conceptual surface area.
|
||||
## Implementation Approach
|
||||
|
||||
1. Build manifest + profile + transform types and registries behind current public API
|
||||
2. Tokenize command/terminology references in workflow templates
|
||||
3. Rewire `getSkillTemplates`/`getCommandContents` to derive from manifest
|
||||
4. Introduce `ArtifactSyncEngine` and switch `init` to use it with parity checks
|
||||
5. Switch `update` and legacy upgrade flows to same engine
|
||||
6. Remove duplicate/hardcoded lists after parity is green
|
||||
2. Rewire `getSkillTemplates`/`getCommandContents` to derive from manifest
|
||||
3. Introduce `ArtifactSyncEngine` and switch `init` to use it with parity checks
|
||||
4. Switch `update` and legacy upgrade flows to same engine
|
||||
5. Remove duplicate/hardcoded lists after parity is green
|
||||
|
||||
@@ -4,8 +4,7 @@ The recent split of `skill-templates.ts` into workflow modules improved readabil
|
||||
|
||||
- Workflow definitions are split from projection logic (`getSkillTemplates`, `getCommandTemplates`, `getCommandContents`)
|
||||
- Tool capability and compatibility are spread across `AI_TOOLS`, `CommandAdapterRegistry`, and hardcoded lists like `SKILL_NAMES`
|
||||
- Agent/tool-specific transformations are applied in different places (`init`, `update`, and adapter code)
|
||||
- Command and terminology references are currently hardcoded in workflow text, but tool invocation surfaces vary (`/opsx:apply`, `/opsx-apply`, `/opsx/apply`, and tool-specific naming)
|
||||
- Agent/tool-specific transformations (for example OpenCode command reference rewrites) are applied in different places (`init`, `update`, and adapter code)
|
||||
- Artifact writing logic is duplicated across `init`, `update`, and legacy-upgrade flow
|
||||
|
||||
This fragmentation creates drift risk (missing exports, missing metadata parity, mismatched counts/support) and makes future workflow/tool additions slower and less predictable.
|
||||
@@ -16,16 +15,13 @@ This fragmentation creates drift risk (missing exports, missing metadata parity,
|
||||
- Introduce a `ToolProfileRegistry` to centralize tool capabilities (skills path, command adapter, transforms)
|
||||
- Introduce a first-class transform pipeline with explicit phases (`preAdapter`, `postAdapter`) and scopes (`skill`, `command`, `both`)
|
||||
- Introduce a shared `ArtifactSyncEngine` used by `init`, `update`, and legacy upgrade paths
|
||||
- Add tokenized workflow text rendering so command references and tool terminology are resolved per tool profile at generation time
|
||||
- Add explicit command-surface profiles per tool (pattern, namespace/path style, alias support, verification status)
|
||||
- Add safe fallback behavior: when a tool command surface is not verified, render neutral workflow guidance (for example skill/workflow names) instead of potentially wrong literal command strings
|
||||
- Add strict validation and test guardrails to preserve fidelity during migration and future changes
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `template-artifact-pipeline`: Unified workflow manifest, tool profile registry, token-aware transform pipeline, and sync engine for skill/command generation
|
||||
- `template-artifact-pipeline`: Unified workflow manifest, tool profile registry, transform pipeline, and sync engine for skill/command generation
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
@@ -45,9 +41,7 @@ This fragmentation creates drift risk (missing exports, missing metadata parity,
|
||||
- **Testing additions**:
|
||||
- Manifest completeness tests (workflows, required metadata, projection parity)
|
||||
- Transform ordering and applicability tests
|
||||
- Tool command-surface/terminology profile validation tests
|
||||
- End-to-end parity tests for generated skill/command outputs across tools
|
||||
- **User-facing behavior**:
|
||||
- No new CLI surface area required
|
||||
- Generated text may become more tool-accurate for verified tool profiles
|
||||
- Generated text may intentionally use neutral workflow wording for unverified tools to avoid incorrect slash-command guidance
|
||||
- Existing generated artifacts remain behaviorally equivalent unless explicitly changed in future deltas
|
||||
|
||||
@@ -10,18 +10,14 @@
|
||||
- [ ] 2.1 Add `ToolProfile` types and `ToolProfileRegistry`
|
||||
- [ ] 2.2 Map all currently supported tools to explicit profile entries
|
||||
- [ ] 2.3 Wire profile lookups to command adapter resolution and skills path resolution
|
||||
- [ ] 2.4 Add per-tool `commandSurface` metadata (pattern, aliases, `verified` flag)
|
||||
- [ ] 2.5 Add per-tool terminology metadata (for example change/workflow/command labels)
|
||||
- [ ] 2.6 Replace hardcoded detection arrays (for example `SKILL_NAMES`) with manifest-derived values
|
||||
- [ ] 2.4 Replace hardcoded detection arrays (for example `SKILL_NAMES`) with manifest-derived values
|
||||
|
||||
## 3. Transform Pipeline
|
||||
|
||||
- [ ] 3.1 Introduce transform interfaces (`scope`, `phase`, `priority`, `applies`, `transform`)
|
||||
- [ ] 3.2 Implement transform runner with deterministic ordering
|
||||
- [ ] 3.3 Add token renderer transform for command + terminology tokens (`{{cmd.*}}`, `{{term.*}}`)
|
||||
- [ ] 3.4 Implement neutral fallback rendering for tools with unverified command surfaces
|
||||
- [ ] 3.5 Migrate OpenCode command reference rewrite to transform pipeline
|
||||
- [ ] 3.6 Remove ad-hoc transform invocation from `init` and `update`
|
||||
- [ ] 3.3 Migrate OpenCode command reference rewrite to transform pipeline
|
||||
- [ ] 3.4 Remove ad-hoc transform invocation from `init` and `update`
|
||||
|
||||
## 4. Artifact Sync Engine
|
||||
|
||||
@@ -33,12 +29,10 @@
|
||||
## 5. Validation and Tests
|
||||
|
||||
- [ ] 5.1 Add manifest completeness tests (metadata required fields, command IDs, dir names)
|
||||
- [ ] 5.2 Add tool-profile consistency tests (skillsDir support, adapter/profile alignment, command-surface metadata)
|
||||
- [ ] 5.3 Add token rendering tests (all tokens resolved, per-tool rendering correctness)
|
||||
- [ ] 5.4 Add fallback tests for unverified tool command surfaces
|
||||
- [ ] 5.5 Add transform applicability/order tests
|
||||
- [ ] 5.6 Expand parity tests for representative workflow/tool matrix
|
||||
- [ ] 5.7 Run full test suite and verify generated artifacts remain stable
|
||||
- [ ] 5.2 Add tool-profile consistency tests (skillsDir support and adapter/profile alignment)
|
||||
- [ ] 5.3 Add transform applicability/order tests
|
||||
- [ ] 5.4 Expand parity tests for representative workflow/tool matrix
|
||||
- [ ] 5.5 Run full test suite and verify generated artifacts remain stable
|
||||
|
||||
## 6. Cleanup and Documentation
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ The explore workflow is part of the core loop (`propose`, `explore`, `apply`, `a
|
||||
|
||||
Currently, explore references `/opsx:new` and `/opsx:ff` which are being replaced with `/opsx:propose`. But beyond just updating references, there are deeper UX questions about how explore should work.
|
||||
|
||||
This exploration is also affected by the emerging workspace direction: for larger cross-team or cross-repo work, OpenSpec may need to treat the **initiative** as the first-class planning object and repo-local changes as execution artifacts. That means explore may sometimes be seeding an initiative, not just a single change.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Exploration Artifacts
|
||||
@@ -17,6 +19,7 @@ Currently, explore references `/opsx:new` and `/opsx:ff` which are being replace
|
||||
2. **Where should exploration files live?**
|
||||
- `openspec/explorations/<name>.md`?
|
||||
- `openspec/changes/<change>/explorations/`?
|
||||
- `.openspec-workspace/initiatives/<initiative>/explorations/` for coordinated work?
|
||||
- Somewhere else?
|
||||
|
||||
3. **What should the format be?**
|
||||
@@ -30,15 +33,17 @@ Currently, explore references `/opsx:new` and `/opsx:ff` which are being replace
|
||||
- e.g., exploring auth approaches separately from UI approaches
|
||||
- How would these relate to each other?
|
||||
|
||||
5. **How do explorations relate to changes?**
|
||||
5. **How do explorations relate to changes or initiatives?**
|
||||
- Before change exists: standalone exploration
|
||||
- After change exists: exploration linked to change?
|
||||
- After repo-local change exists: exploration linked to change?
|
||||
- For coordinated work: exploration linked to initiative first, then optionally referenced by repo-local changes?
|
||||
|
||||
### Lifecycle & Transitions
|
||||
|
||||
6. **What happens before a change proposal exists?**
|
||||
- Exploration is standalone
|
||||
- When ready, user runs `/opsx:propose`
|
||||
- For coordinated work, should exploration context seed an initiative first?
|
||||
- Should exploration context automatically seed the proposal?
|
||||
|
||||
7. **What happens after a change proposal exists?**
|
||||
@@ -90,11 +95,19 @@ Currently, explore references `/opsx:new` and `/opsx:ff` which are being replace
|
||||
- **Pro:** Clear relationship to changes
|
||||
- **Con:** Where do pre-change explorations go?
|
||||
|
||||
### Approach E: Initiative-First Explorations for Coordinated Work
|
||||
- Local work can stay standalone or change-linked
|
||||
- Coordinated work saves exploration notes under an initiative in the coordination workspace
|
||||
- Repo-local changes can reference the shared exploration when execution starts
|
||||
- **Pro:** Matches the emerging split between shared planning and repo-local execution
|
||||
- **Con:** Adds another context where exploration artifacts may live
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] User research: How do people actually use explore today?
|
||||
- [ ] Prototype: Try saving explorations and see if propose benefits
|
||||
- [ ] Decide: Pick an approach based on findings
|
||||
- [ ] Reconcile explore UX with initiative-first coordinated planning
|
||||
- [ ] Implement: Update explore workflow accordingly
|
||||
|
||||
## Related
|
||||
|
||||
@@ -645,23 +645,199 @@ To avoid losing this in exploration notes, codify it in:
|
||||
|
||||
---
|
||||
|
||||
## Part 10: Design Decisions (April 2026)
|
||||
|
||||
After evaluating the models above against real multi-repo use cases (see [#725](https://github.com/Fission-AI/OpenSpec/issues/725)), we converged on the following design direction.
|
||||
|
||||
### Core Insight
|
||||
|
||||
The workspace itself is not the durable thing. For large teams, the durable planning object is the **initiative** or **plan**, while repo-local specs and changes remain the execution artifacts owned by each repo. The set of repos involved in a feature is typically feature-scoped and changes over time, so a static workspace manifest that must be configured before work begins creates ceremony that doesn't match how teams actually work.
|
||||
|
||||
### Decision: Model D with Lazy Workspace
|
||||
|
||||
Choose Model D (Hybrid) from Part 4, but make the workspace manifest **optional and lazy, not prerequisite**.
|
||||
|
||||
- **Each repo keeps its own canonical `openspec/`** — no change to the fundamental storage model.
|
||||
- **Cross-root work can be coordinated through an initiative in a coordination workspace** — this is where shared planning lives when the work stops being cleanly repo-scoped.
|
||||
- **"Workspace" is a derived or explicit coordination view** over linked repos and linked changes, not something users must register up front.
|
||||
- **Persist a workspace manifest only when someone explicitly wants a reusable cross-repo bundle** — this is an opt-in convenience, not a requirement.
|
||||
|
||||
### Decision: Initiative-First Planning with Linked Repo-Local Changes
|
||||
|
||||
For larger multi-team work, repo-centric planning is the wrong primary abstraction. Teams and repos are many-to-many facets over the same work. OpenSpec should treat the **initiative / plan** as the first-class planning object, then link repo-local changes to it.
|
||||
|
||||
This is especially important because a change today bundles:
|
||||
|
||||
- `proposal.md`
|
||||
- `design.md`
|
||||
- `tasks.md`
|
||||
- delta specs
|
||||
- `.openspec.yaml`
|
||||
|
||||
That bundled shape works well for repo-local work, but becomes awkward when one piece of work spans multiple repos or teams. In those cases, a single repo-local change is trying to act as both:
|
||||
|
||||
- the shared planning object
|
||||
- the repo-specific execution artifact
|
||||
|
||||
Those should be split.
|
||||
|
||||
The preferred model is:
|
||||
|
||||
```text
|
||||
coordination workspace /
|
||||
.openspec-workspace/
|
||||
workspace.yaml
|
||||
initiatives/
|
||||
add-3ds/
|
||||
initiative.yaml
|
||||
proposal.md
|
||||
design.md
|
||||
links.yaml
|
||||
|
||||
repo-A/
|
||||
openspec/
|
||||
changes/
|
||||
add-3ds-api/
|
||||
.openspec.yaml
|
||||
tasks.md
|
||||
specs/
|
||||
|
||||
repo-B/
|
||||
openspec/
|
||||
changes/
|
||||
add-3ds-web/
|
||||
.openspec.yaml
|
||||
tasks.md
|
||||
specs/
|
||||
```
|
||||
|
||||
The initiative holds the shared planning layer:
|
||||
|
||||
- proposal / intent
|
||||
- shared design and tradeoffs
|
||||
- participating teams
|
||||
- impacted repos
|
||||
- milestones, risks, and dependencies
|
||||
- links to repo-local changes
|
||||
|
||||
Each repo-local change holds the execution layer for that repo:
|
||||
|
||||
- repo-specific tasks
|
||||
- delta specs
|
||||
- local implementation status
|
||||
- optional local notes that should archive with that repo's work
|
||||
|
||||
Cross-repo linking still matters, but it should hang off the initiative and the repo-local changes:
|
||||
|
||||
```yaml
|
||||
# billing-service/openspec/changes/add-3ds/.openspec.yaml
|
||||
schema: spec-driven
|
||||
created: 2026-04-12
|
||||
initiative: add-3ds
|
||||
links:
|
||||
- project: github.com/fission/web-client
|
||||
change: add-3ds-checkout
|
||||
- project: github.com/fission/ios-client
|
||||
change: add-3ds-checkout
|
||||
```
|
||||
|
||||
Each repo still holds its own change with its own deltas. A cross-repo effort is represented as one initiative plus N linked single-repo changes. This is preferable to a single mega-change because:
|
||||
- Shared planning has one truthful home
|
||||
- Each repo's change goes through its own archive cycle
|
||||
- No need to resolve cross-repo file paths in delta specs
|
||||
- Teams can move at different speeds (web ships before iOS)
|
||||
|
||||
For small single-repo work, a repo-local change may still be "good enough" as both plan and execution bundle. The initiative-first split matters once work becomes cross-team, cross-module, cross-repo, or otherwise coordination-heavy.
|
||||
|
||||
### Decision: Stable Project Identifiers, Not Paths
|
||||
|
||||
Cross-repo links must use **stable project identifiers**, not filesystem paths.
|
||||
|
||||
- **Canonical form:** A normalized `host/org/repo` tuple (e.g., `github.com/fission/web-client`).
|
||||
- **Authoring shorthand:** The CLI accepts `org/repo` (e.g., `fission/web-client`) and infers the host from the current repo's remote.
|
||||
- **Relative paths are never the durable identifier.** They may exist only as cached local resolution results.
|
||||
|
||||
### Decision: Offline-First Resolution
|
||||
|
||||
The CLI resolves project identifiers to local paths using an offline-first chain:
|
||||
|
||||
1. **Explicit paths** passed for the current run (e.g., CLI flags, ad-hoc multi-root).
|
||||
2. **Local OpenSpec repo registry** — a persistent mapping in `~/.config/openspec/` or `~/.local/share/openspec/` (see `src/core/global-config.ts`).
|
||||
3. **Parent directory scanning** — scan known parent directories for git checkouts whose remotes match the target identifier.
|
||||
4. **Unresolved** — if no local path is found, leave the target unresolved and continue with a partial workspace. The CLI must not fail.
|
||||
|
||||
The registry is populated progressively: when the CLI discovers a clone (via scanning or user prompt), it persists the mapping for future resolution. The registry also stores "known scan roots" (e.g., `~/work/`) so scanning improves over time without upfront configuration.
|
||||
|
||||
### Decision: Informational References Only (v1)
|
||||
|
||||
Spec-level cross-repo references are **documentation-only pointers**:
|
||||
|
||||
```yaml
|
||||
# web-client/openspec/specs/checkout/spec.md frontmatter
|
||||
references:
|
||||
- project: github.com/fission/contracts-service
|
||||
spec: checkout-contract
|
||||
```
|
||||
|
||||
- The CLI does **not** fail validation because a referenced cross-repo spec is missing or unresolved.
|
||||
- The CLI **does** surface references to humans and agents when planning, viewing, or applying changes.
|
||||
- Stronger guarantees (e.g., staleness warnings, cross-repo validation) are an opt-in layer added later — via `lint`, `doctor`, or a feature flag — not baseline behavior.
|
||||
|
||||
This avoids accidentally committing OpenSpec to a full dependency graph system before the use cases justify it.
|
||||
|
||||
### Decision: Explicit Owner Repo for Shared Contracts
|
||||
|
||||
When a spec cannot be mapped to a single implementation repo (e.g., a shared API contract):
|
||||
|
||||
- **One repo must be the explicit owner.** This can be a dedicated "contracts" repo, or whichever repo is the natural source of truth.
|
||||
- **Other repos reference the owning repo's spec** via informational references (see above).
|
||||
- **There is no default "pure spec repo" pattern.** Separating spec ownership from code ownership too aggressively makes agent execution awkward and diffuses responsibility.
|
||||
|
||||
### Monorepo vs. Multi-Repo Summary
|
||||
|
||||
| Concern | Monorepo | Multi-Repo |
|
||||
|---------|----------|------------|
|
||||
| **Spec organization** | Nested specs inside one `openspec/` (Model B) | Each repo has its own `openspec/` |
|
||||
| **Cross-cutting specs** | Nested under a `contracts/` or `shared/` directory | Dedicated owner repo, others reference it |
|
||||
| **Planning object** | Initiative optional for simple work, useful for large cross-team efforts | Initiative is the primary coordination object |
|
||||
| **Changes** | One or more repo-local changes can implement one initiative | Linked per-repo changes implement one initiative |
|
||||
| **Relationships** | References (no inheritance in v1) | Project identifier links, informational only |
|
||||
| **Workspace** | Usually not needed, but can host initiative planning for complex work | Coordination workspace hosts initiative planning; optional manifest for reuse |
|
||||
|
||||
### Implementation Path
|
||||
|
||||
1. **Define initiative artifacts** — add an initiative format for shared planning in coordination workspaces.
|
||||
2. **Extend change metadata** — let repo-local changes point at an initiative and linked sibling changes.
|
||||
3. **Extend spec metadata** — add `references` field for cross-repo spec pointers.
|
||||
4. **Build project resolution** — implement the offline-first resolution chain and local registry.
|
||||
5. **Build initiative and link views** — commands that resolve and display the initiative graph plus linked repo-local changes.
|
||||
6. **Support ad-hoc multi-root** — "add these dirs for this run" or "derive roots from this initiative's links."
|
||||
7. **Optional workspace manifest** — add saved workspaces only if teams demonstrate reuse patterns.
|
||||
|
||||
Nested specs (Model B inside a single repo) are a prerequisite for clean monorepo support and should be tackled first, as outlined in #662.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Question | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Profile UX | Decided | `openspec config profile` with presets |
|
||||
| Config layering | Decided | Two layers: global + project (no workspace layer) |
|
||||
| Spec organization | Open | Four models under consideration (including hybrid Model D) |
|
||||
| Spec organization | **Direction set** | Nested specs per repo, explicit owner repos for shared contracts, references for cross-repo context |
|
||||
| Spec philosophy | Direction set | Behavior-first contracts, progressive rigor, and agent-aligned authoring |
|
||||
| Spec inheritance | Open | Inheritance vs references vs none |
|
||||
| Multi-repo support | Open | Workspace concept TBD |
|
||||
| Dependency tracking | Open | Probably out of scope initially |
|
||||
| Spec inheritance | **Decided** | References only, no inheritance in v1 |
|
||||
| Initiative / planning model | **Direction set** | Initiative-first planning for larger work, with repo-local changes as execution artifacts |
|
||||
| Multi-repo support | **Direction set** | Linked per-repo changes under shared initiatives; workspace is coordination, not canonical execution storage |
|
||||
| Dependency tracking | **Decided** | Out of scope for v1; references are informational only |
|
||||
| Cross-repo resolution | **Decided** | Offline-first resolution chain with local registry |
|
||||
| Shared contracts | **Decided** | Explicit owner repo required; no default pure-spec-repo pattern |
|
||||
|
||||
### Key Insight
|
||||
|
||||
The "workspace" question is really two separate questions:
|
||||
1. **Config/profile scope** → Solved with global + project (no workspace needed)
|
||||
2. **Spec/change organization** → Unsolved, needs deeper design work
|
||||
2. **Plan vs. execution organization** → Direction set: initiatives coordinate, repo-local changes implement, workspace remains a coordination layer
|
||||
|
||||
These should be separate changes with separate explorations.
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# Workspace Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This document proposes a lightweight roadmap for workspace, monorepo, and multi-repo support in OpenSpec.
|
||||
|
||||
It assumes:
|
||||
|
||||
- single-repo is the current default experience
|
||||
- monorepo pain is already real
|
||||
- multi-repo coordination is not hypothetical
|
||||
- large engineering organizations already need this
|
||||
|
||||
This roadmap is intentionally staged.
|
||||
|
||||
The goal is not to build the full conceptual system at once.
|
||||
|
||||
The goal is to ship the smallest credible version of cross-boundary support while preserving a path to a stronger long-term model.
|
||||
|
||||
---
|
||||
|
||||
## Product Principle
|
||||
|
||||
> Prefer the smallest feature set that solves real cross-boundary work without blocking the likely long-term direction.
|
||||
|
||||
This means:
|
||||
|
||||
- do not overbuild governance before usage proves it
|
||||
- do not underbuild coordination if real teams already need it
|
||||
- do not add complexity to the single-repo path unless it clearly pays for itself
|
||||
|
||||
---
|
||||
|
||||
## What We Believe Now
|
||||
|
||||
Based on the current exploration work, several things look increasingly clear.
|
||||
|
||||
### 1. Nested spec organization is needed
|
||||
|
||||
OpenSpec needs a better way to organize:
|
||||
|
||||
- shared contracts
|
||||
- local implementation specs
|
||||
- multi-area behavior inside one root
|
||||
|
||||
### 2. Informational references are low-risk and useful
|
||||
|
||||
References help agents and humans navigate related specs without requiring OpenSpec to build a dependency graph system on day one.
|
||||
|
||||
### 3. Initiatives plus linked per-repo changes are the right primitive
|
||||
|
||||
For true multi-repo work, the likely durable primitive is:
|
||||
|
||||
- one initiative as the shared planning object
|
||||
- one linked change per owning repo as the execution artifact
|
||||
- stable identifiers connecting them
|
||||
|
||||
### 4. Cross-repo work needs a neutral planning location
|
||||
|
||||
For multi-repo work, a single repo is not an honest home for the whole planning artifact.
|
||||
|
||||
Some form of coordination workspace or coordination repo is needed for the initiative-level plan.
|
||||
|
||||
### 5. Team-shared coordination is a real requirement
|
||||
|
||||
This is not just a solo-user thought experiment.
|
||||
|
||||
Real teams and large engineering orgs already need a way to coordinate multi-repo work.
|
||||
|
||||
### 6. The risk is shipping too much at once
|
||||
|
||||
Even though the need is real, the full model has many moving parts:
|
||||
|
||||
- nested spec paths
|
||||
- shared contracts
|
||||
- linked changes
|
||||
- cross-root planning
|
||||
- partial repo resolution
|
||||
- agent capability differences
|
||||
- team-shared coordination state
|
||||
|
||||
The roadmap should sequence these carefully.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Better Structure Inside One Root
|
||||
|
||||
### Goal
|
||||
|
||||
Reduce pain in single-repo and monorepo setups without introducing coordination machinery yet.
|
||||
|
||||
### Ship
|
||||
|
||||
1. Nested spec paths within one `openspec/` root
|
||||
2. Informational `references` in specs
|
||||
3. Better filtering of relevant spec paths during planning
|
||||
4. Better handling of multi-area changes inside one root
|
||||
|
||||
### User value
|
||||
|
||||
- monorepo users can organize shared and local specs more naturally
|
||||
- large roots become less noisy
|
||||
- shared contracts inside one root become easier to model
|
||||
|
||||
### Do not ship yet
|
||||
|
||||
- coordination workspaces
|
||||
- linked multi-repo changes
|
||||
- team-shared coordination repos
|
||||
- sponsor/owner workflow machinery
|
||||
|
||||
### Success criteria
|
||||
|
||||
- users can model large monorepos without flattening everything at the top level
|
||||
- users can represent shared contracts inside one root
|
||||
- planning context gets smaller and more relevant
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Thin Cross-Repo Coordination
|
||||
|
||||
### Goal
|
||||
|
||||
Support real multi-repo planning demand with the thinnest credible coordination layer.
|
||||
|
||||
### Ship
|
||||
|
||||
1. Initiative artifacts for shared planning in a neutral coordination workspace or coordination repo
|
||||
2. Linked per-repo changes using stable project identifiers
|
||||
3. Explicit repo linking via project IDs
|
||||
4. Resolution through:
|
||||
- explicit input
|
||||
- git remote matching
|
||||
5. Partial-resolution support
|
||||
6. Basic agent handoff instructions for coordinated planning
|
||||
|
||||
### User value
|
||||
|
||||
- users have an honest place to stand for multi-repo work
|
||||
- cross-repo plans are no longer buried in one repo
|
||||
- shared planning and repo-local execution are clearly separated
|
||||
- ownership stays with the real repos
|
||||
- agents can be told what roots matter
|
||||
|
||||
### Key constraints
|
||||
|
||||
This phase should remain thin.
|
||||
|
||||
Avoid:
|
||||
|
||||
- dependency validation across repos
|
||||
- rich governance flows
|
||||
- too many new abstractions in the CLI
|
||||
- heavyweight local/shared state semantics
|
||||
|
||||
### v1 shape
|
||||
|
||||
This phase should feel like:
|
||||
|
||||
- local planning by default
|
||||
- upgrade to a coordinated initiative when needed
|
||||
- initiative-level planning in the coordination workspace
|
||||
- linked repo-local changes underneath
|
||||
|
||||
Not like:
|
||||
|
||||
- a whole second product mode with many admin concepts
|
||||
|
||||
### Success criteria
|
||||
|
||||
- teams can coordinate multi-repo changes without inventing ad hoc spreadsheets or naming conventions
|
||||
- users understand where planning lives and where implementation lives
|
||||
- agents can plan across roots in a way that is operationally usable
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Team-Shared Coordination Hardening
|
||||
|
||||
### Goal
|
||||
|
||||
Make coordinated planning work cleanly across teammates and teams.
|
||||
|
||||
### Ship
|
||||
|
||||
1. Shared coordination repo/workspace support as a first-class pattern
|
||||
2. Clear split between:
|
||||
- committed shared initiative state
|
||||
- local machine-specific path resolution
|
||||
3. Lightweight relinking / repair flows
|
||||
4. Better onboarding for teammates joining an initiative
|
||||
5. Better agent instruction generation for shared workspaces
|
||||
|
||||
### User value
|
||||
|
||||
- teams can share a stable cross-repo initiative
|
||||
- each teammate can map project IDs to their own local clones
|
||||
- new participants can join without reverse-engineering how the initiative is set up
|
||||
|
||||
### Important constraint
|
||||
|
||||
The local side of this model should stay as thin as possible.
|
||||
|
||||
The ideal local layer is:
|
||||
|
||||
- regenerable
|
||||
- non-authoritative
|
||||
- not semantically important beyond path resolution
|
||||
|
||||
### Success criteria
|
||||
|
||||
- team-shared coordination works without local path leakage into committed state
|
||||
- joining an initiative feels lightweight
|
||||
- maintenance cost stays acceptable
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Shared Contract and Governance Maturity
|
||||
|
||||
### Goal
|
||||
|
||||
Support organizations that need stronger contract ownership and more formal cross-boundary planning.
|
||||
|
||||
### Ship only if demand justifies it
|
||||
|
||||
1. Guided shared contract ownership flows
|
||||
2. Promotion of initiative-only draft behavior into canonical shared contracts
|
||||
3. Stronger role visibility:
|
||||
- canonical shared contract owner
|
||||
- initiative sponsor/driver
|
||||
4. Optional linting or policy checks
|
||||
5. Optional validation around missing owners or unresolved references
|
||||
|
||||
### User value
|
||||
|
||||
- larger orgs can create durable shared contracts cleanly
|
||||
- governance becomes explicit where needed
|
||||
- cross-team ownership becomes easier to understand
|
||||
|
||||
### Important constraint
|
||||
|
||||
This should not become mandatory for normal users.
|
||||
|
||||
These features should remain:
|
||||
|
||||
- opt-in
|
||||
- advanced
|
||||
- proportional to org complexity
|
||||
|
||||
### Success criteria
|
||||
|
||||
- shared-contract workflows solve real org-scale problems without making normal planning feel bureaucratic
|
||||
|
||||
---
|
||||
|
||||
## What Should Not Be Delayed
|
||||
|
||||
Because demand is already real, some things should not be treated as purely future work.
|
||||
|
||||
### Should happen soon
|
||||
|
||||
- nested spec paths
|
||||
- references
|
||||
- initiative artifact + linked change primitive
|
||||
- stable project identifiers
|
||||
- thin coordination layer for multi-repo planning
|
||||
|
||||
### Can wait
|
||||
|
||||
- rich ownership workflows
|
||||
- strong dependency semantics
|
||||
- broad policy and governance features
|
||||
- too much agent-specific machinery
|
||||
|
||||
---
|
||||
|
||||
## UX Guardrails Across All Phases
|
||||
|
||||
No matter the phase, the UX should follow these rules.
|
||||
|
||||
### 1. Default local
|
||||
|
||||
Users should start where they already are.
|
||||
|
||||
### 2. Escalate only when necessary
|
||||
|
||||
Coordinated planning should appear as an upgrade path, not the default mode.
|
||||
|
||||
### 3. Keep advanced concepts mostly implicit
|
||||
|
||||
Only expose concepts like shared owners, sponsor roles, overlays, and manifests when the user truly needs to decide something.
|
||||
|
||||
### 4. Canonical storage follows ownership
|
||||
|
||||
Specs and repo-local changes stay with the owning root.
|
||||
|
||||
### 5. Shared coordination is not canonical spec storage
|
||||
|
||||
Coordination data helps planning, but does not replace the source of truth.
|
||||
|
||||
### 6. Hidden local state must stay thin
|
||||
|
||||
If OpenSpec uses local path caches or machine-specific mappings, they should be:
|
||||
|
||||
- repairable
|
||||
- replaceable
|
||||
- non-authoritative
|
||||
|
||||
---
|
||||
|
||||
## Likely Deliverable Sequence
|
||||
|
||||
If this roadmap were translated into actual change proposals, the sequence would likely be:
|
||||
|
||||
1. nested spec paths + references
|
||||
2. monorepo scope filtering and multi-area planning improvements
|
||||
3. initiative artifact for shared planning
|
||||
4. linked change metadata across repos
|
||||
5. thin coordination workspace / repo for multi-repo planning
|
||||
6. team-shared coordination hardening
|
||||
7. optional shared contract maturity features
|
||||
|
||||
---
|
||||
|
||||
## Open Risks
|
||||
|
||||
### 1. Coordination may still be too heavy in v1
|
||||
|
||||
Even a thin coordination layer may feel like too much if the handoff is clumsy.
|
||||
|
||||
### 2. Hidden local state may become more important than intended
|
||||
|
||||
If path resolution or local repo linking becomes semantically important, the system will become harder to trust and debug.
|
||||
|
||||
### 3. Monorepo and multi-repo may diverge unintentionally
|
||||
|
||||
The product should resist evolving two completely separate mental models.
|
||||
|
||||
### 4. Agent capability differences may distort the design
|
||||
|
||||
The UX should not assume every coding agent handles multi-root planning equally well.
|
||||
|
||||
### 5. Team-scale needs may pressure early governance
|
||||
|
||||
Large orgs may quickly ask for ownership, permissions, and review structures. That should not force all users into heavyweight flows.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The roadmap should not be:
|
||||
|
||||
- "wait on multi-repo until later"
|
||||
|
||||
Because the demand is already real.
|
||||
|
||||
It also should not be:
|
||||
|
||||
- "build the full workspace model now"
|
||||
|
||||
Because the complexity surface is too large.
|
||||
|
||||
The right roadmap is:
|
||||
|
||||
1. improve structure inside one root
|
||||
2. ship a thin but real coordination layer for multi-repo work
|
||||
3. harden team-shared coordination
|
||||
4. add more formal shared contract and governance support only as justified
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
# Workspace UX Simplification
|
||||
|
||||
## Purpose
|
||||
|
||||
This document focuses on one UX goal:
|
||||
|
||||
> OpenSpec should have one default path, one escalation path, and fewer explicit concepts shown to the user unless the system actually needs a decision from them.
|
||||
|
||||
This is a follow-up to `workspace-user-journeys.md`. That document is useful for completeness, but it exposes too much of the conceptual model too early.
|
||||
|
||||
This document is about how the product should **feel**.
|
||||
|
||||
---
|
||||
|
||||
## The UX Problem
|
||||
|
||||
The current user-journey exploration is coherent, but it is too heavy at first contact.
|
||||
|
||||
The main issues are:
|
||||
|
||||
1. Too many concepts appear before the user has done anything:
|
||||
- scope
|
||||
- project
|
||||
- owning root
|
||||
- shared contract owner
|
||||
- coordination workspace
|
||||
- initiative sponsor
|
||||
- shared manifest vs local overlay
|
||||
|
||||
2. Cross-root work feels like a workflow restart:
|
||||
- user starts in one repo
|
||||
- OpenSpec says this is multi-repo
|
||||
- user creates a workspace
|
||||
- user reopens the agent there
|
||||
- user effectively starts again
|
||||
|
||||
3. Shared contract decisions are asked too explicitly and too early.
|
||||
|
||||
4. Team-scale coordination is conceptually right, but reads more like infra setup than a lightweight workflow.
|
||||
|
||||
The system is internally clean, but the product experience should be more progressive.
|
||||
|
||||
---
|
||||
|
||||
## Design Goal
|
||||
|
||||
The user should feel:
|
||||
|
||||
- "I just start where I am"
|
||||
- "OpenSpec figures out whether this stays local or needs to expand"
|
||||
- "If it expands, it carries me forward instead of making me restart"
|
||||
- "I only see advanced concepts when OpenSpec needs a real decision from me"
|
||||
|
||||
---
|
||||
|
||||
## The Core UX Shape
|
||||
|
||||
### One default path
|
||||
|
||||
The default path should always be:
|
||||
|
||||
1. Enter a repo or monorepo root
|
||||
2. Run `/opsx:explore` or `/opsx:propose`
|
||||
3. OpenSpec plans locally unless it has a strong reason not to
|
||||
|
||||
This should work for:
|
||||
|
||||
- single repo
|
||||
- normal monorepo work
|
||||
- many users in many situations
|
||||
|
||||
The default assumption should be:
|
||||
|
||||
> This is a local change until proven otherwise.
|
||||
|
||||
### One escalation path
|
||||
|
||||
The only escalation path should be:
|
||||
|
||||
> This work spans multiple owned areas strongly enough that OpenSpec needs to upgrade it into a coordinated initiative.
|
||||
|
||||
That escalation may happen for:
|
||||
|
||||
- large monorepo cross-team work
|
||||
- true multi-repo work
|
||||
- creation of a shared cross-boundary contract
|
||||
|
||||
The important UX point is that these should all feel like the same escalation:
|
||||
|
||||
- "OpenSpec is upgrading this into a coordinated initiative"
|
||||
|
||||
Not:
|
||||
|
||||
- one flow for multi-repo
|
||||
- another flow for large monorepos
|
||||
- another flow for shared contracts
|
||||
|
||||
---
|
||||
|
||||
## Progressive Disclosure
|
||||
|
||||
Users should not have to understand the full data model up front.
|
||||
|
||||
### Concepts users should see by default
|
||||
|
||||
At the start, users should mostly see:
|
||||
|
||||
- change
|
||||
- affected area
|
||||
- maybe repo if relevant
|
||||
|
||||
That is enough for the first planning step.
|
||||
|
||||
### Concepts OpenSpec should keep implicit until needed
|
||||
|
||||
These should usually stay hidden until escalation:
|
||||
|
||||
- scope
|
||||
- coordination workspace
|
||||
- initiative
|
||||
- shared contract owner
|
||||
- sponsor/driver
|
||||
- manifest vs local overlay
|
||||
|
||||
### Concepts OpenSpec should only show when a real decision is needed
|
||||
|
||||
Show these only at the point of action:
|
||||
|
||||
- "This spans multiple repos. Create a coordinated initiative?"
|
||||
- "This looks like shared behavior. Where should the canonical contract live?"
|
||||
- "This initiative is team-shared. Do you want to commit it in a shared coordination repo?"
|
||||
|
||||
The system should not front-load these concepts as theory.
|
||||
|
||||
---
|
||||
|
||||
## The Simplest User Story
|
||||
|
||||
This is the baseline story the UX should optimize for.
|
||||
|
||||
### Story
|
||||
|
||||
The user is in a repo and types:
|
||||
|
||||
```text
|
||||
/opsx:propose add-3ds
|
||||
```
|
||||
|
||||
OpenSpec should:
|
||||
|
||||
1. inspect local context
|
||||
2. infer likely affected areas
|
||||
3. ask for confirmation only if needed
|
||||
4. continue immediately
|
||||
|
||||
The user should feel like they are doing one thing:
|
||||
|
||||
```text
|
||||
I am proposing a change.
|
||||
```
|
||||
|
||||
Not:
|
||||
|
||||
```text
|
||||
I am selecting between multiple planning abstractions.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Escalation Story
|
||||
|
||||
If OpenSpec realizes the work is no longer local, it should escalate in one motion.
|
||||
|
||||
### Desired feel
|
||||
|
||||
```text
|
||||
This change affects multiple owned areas.
|
||||
I can upgrade it into a coordinated initiative and carry your current planning context forward.
|
||||
```
|
||||
|
||||
That wording matters.
|
||||
|
||||
It should feel like:
|
||||
|
||||
- an upgrade
|
||||
- a continuation
|
||||
- a convenience
|
||||
|
||||
It should not feel like:
|
||||
|
||||
- an error
|
||||
- a hard stop
|
||||
- a separate setup workflow
|
||||
|
||||
### What should happen during escalation
|
||||
|
||||
If escalation is needed, OpenSpec should do as much as possible automatically:
|
||||
|
||||
1. carry forward the current change name / description
|
||||
2. preserve the already inferred affected areas
|
||||
3. create the coordination artifact
|
||||
4. resolve any local roots it can
|
||||
5. generate agent instructions
|
||||
6. then tell the user the next step
|
||||
|
||||
### Example escalation UX
|
||||
|
||||
```text
|
||||
This work spans multiple owned areas:
|
||||
- contracts
|
||||
- billing-service
|
||||
- web-client
|
||||
- ios-client
|
||||
|
||||
OpenSpec can upgrade this into a coordinated initiative.
|
||||
|
||||
Suggested next step:
|
||||
- create a coordination workspace at ~/work/openspec-workspaces/add-3ds
|
||||
|
||||
I’ll carry forward:
|
||||
- your current change description
|
||||
- affected repos
|
||||
- any planning notes already gathered
|
||||
```
|
||||
|
||||
This is much better than making the user feel they must restart.
|
||||
|
||||
---
|
||||
|
||||
## The Minimum Decision Set
|
||||
|
||||
When OpenSpec has to ask questions, it should ask the smallest useful set.
|
||||
|
||||
### Decision 1: Is this local or coordinated?
|
||||
|
||||
Most important product question.
|
||||
|
||||
User-facing form:
|
||||
|
||||
```text
|
||||
This appears to span multiple owned areas.
|
||||
|
||||
How should I proceed?
|
||||
- Keep this as one local change
|
||||
- Upgrade to a coordinated initiative
|
||||
```
|
||||
|
||||
This should be used sparingly and only when ambiguity matters.
|
||||
|
||||
### Decision 2: What areas are affected?
|
||||
|
||||
User-facing form:
|
||||
|
||||
```text
|
||||
Which areas are affected?
|
||||
```
|
||||
|
||||
This is much more intuitive than asking users about "scopes" first.
|
||||
|
||||
Internally this is scope selection, but the user does not need that term unless advanced users want it.
|
||||
|
||||
### Decision 3: Is this shared behavior?
|
||||
|
||||
Only ask if OpenSpec has strong evidence of a cross-boundary contract.
|
||||
|
||||
User-facing form:
|
||||
|
||||
```text
|
||||
This looks like behavior that multiple areas need to follow.
|
||||
|
||||
Should I treat this as:
|
||||
- local changes only
|
||||
- a shared contract
|
||||
- draft coordination notes for now
|
||||
```
|
||||
|
||||
### Decision 4: Where should shared ownership live?
|
||||
|
||||
Only ask if the user confirms shared contract behavior and no obvious existing owner exists.
|
||||
|
||||
User-facing form:
|
||||
|
||||
```text
|
||||
Where should the canonical shared contract live?
|
||||
```
|
||||
|
||||
This should appear late, not early.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Terminology
|
||||
|
||||
The internal model may use many precise terms. The UI should use simpler terms.
|
||||
|
||||
### Prefer in user-facing UX
|
||||
|
||||
- "area" instead of "scope" by default
|
||||
- "coordinated initiative" instead of "workspace model"
|
||||
- "shared contract" instead of "cross-boundary canonical spec"
|
||||
- "owner" instead of "owning root"
|
||||
- "team-shared initiative" instead of "shared coordination manifest"
|
||||
|
||||
### Reserve for advanced UX or docs
|
||||
|
||||
- scope
|
||||
- project root
|
||||
- local overlay
|
||||
- sponsor/driver
|
||||
- coordination workspace
|
||||
|
||||
These terms are useful, but not ideal as the first thing users must absorb.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Default Behavior
|
||||
|
||||
To keep the UX intuitive, OpenSpec should aggressively choose defaults.
|
||||
|
||||
### Default 1: Stay local
|
||||
|
||||
Unless there is strong evidence otherwise, planning stays in the current root.
|
||||
|
||||
### Default 2: Infer affected areas
|
||||
|
||||
OpenSpec should infer affected areas from:
|
||||
|
||||
- request wording
|
||||
- current repo
|
||||
- known spec layout
|
||||
- recent initiative context
|
||||
|
||||
Ask the user only when there is meaningful ambiguity.
|
||||
|
||||
### Default 3: Reuse existing shared owners
|
||||
|
||||
If an existing shared contract owner already exists, OpenSpec should suggest it instead of asking an abstract ownership question.
|
||||
|
||||
### Default 4: Treat unresolved roots as partial, not fatal
|
||||
|
||||
For coordinated initiatives, unresolved repos should not block planning unless the user explicitly needs implementation there now.
|
||||
|
||||
### Default 5: Team-shared only when collaboration is real
|
||||
|
||||
Do not force team/shared setup for solo or exploratory work.
|
||||
|
||||
OpenSpec can start with a local coordination workspace and later offer:
|
||||
|
||||
```text
|
||||
This now looks collaborative. Do you want to move it into a shared coordination repo?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How To Make Team UX Feel Light
|
||||
|
||||
The team story should not feel like an admin ceremony.
|
||||
|
||||
### Desired team experience
|
||||
|
||||
1. One person starts planning normally
|
||||
2. OpenSpec upgrades to a coordinated initiative if needed
|
||||
3. When the work becomes collaborative, OpenSpec offers to make it team-shared
|
||||
4. Teammates clone the initiative repo and run one linking command
|
||||
5. Everyone starts from the same shared initiative context
|
||||
|
||||
### Team onboarding should feel like this
|
||||
|
||||
```text
|
||||
Clone the initiative repo.
|
||||
Run `openspec workspace doctor`.
|
||||
Open your agent here.
|
||||
```
|
||||
|
||||
Not like this:
|
||||
|
||||
```text
|
||||
Learn a new planning model, understand manifests, configure overlays, and attach roots manually.
|
||||
```
|
||||
|
||||
The implementation may require those concepts, but the UX should compress them into a few actions.
|
||||
|
||||
---
|
||||
|
||||
## UX Heuristics For Prompting
|
||||
|
||||
OpenSpec should avoid asking users to classify work in abstract ways if it can infer a reasonable default.
|
||||
|
||||
### Good prompt
|
||||
|
||||
```text
|
||||
This affects:
|
||||
- web checkout
|
||||
- billing API
|
||||
- shared checkout behavior
|
||||
|
||||
I think this should become a coordinated initiative.
|
||||
Proceed?
|
||||
```
|
||||
|
||||
Why this is good:
|
||||
|
||||
- concrete
|
||||
- recommendation included
|
||||
- low cognitive load
|
||||
|
||||
### Weaker prompt
|
||||
|
||||
```text
|
||||
Would you like to create a coordination workspace with linked changes and shared ownership metadata?
|
||||
```
|
||||
|
||||
Why this is weaker:
|
||||
|
||||
- too much internal machinery exposed
|
||||
- user has to parse product architecture before saying yes
|
||||
|
||||
### Good ownership prompt
|
||||
|
||||
```text
|
||||
I found an existing shared contracts area: `contracts/checkout`.
|
||||
Use that as the canonical owner?
|
||||
```
|
||||
|
||||
### Weaker ownership prompt
|
||||
|
||||
```text
|
||||
Choose a canonical shared contract owner for this cross-boundary behavior.
|
||||
```
|
||||
|
||||
The latter is precise, but too abstract unless the user is already deep in the workflow.
|
||||
|
||||
---
|
||||
|
||||
## The Experience We Should Aim For
|
||||
|
||||
By default, OpenSpec should feel like:
|
||||
|
||||
- "Start here"
|
||||
- "Describe the work"
|
||||
- "I’ll handle the shape unless I need your judgment"
|
||||
|
||||
When the system escalates, it should feel like:
|
||||
|
||||
- "This got bigger than one local change"
|
||||
- "I’ve prepared the coordinated setup for you"
|
||||
- "Here is the next obvious step"
|
||||
|
||||
When collaboration expands, it should feel like:
|
||||
|
||||
- "This is now team-shared"
|
||||
- "Commit the stable plan"
|
||||
- "Everyone links their own local clones"
|
||||
|
||||
The user should not feel like they are constantly switching conceptual frameworks.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Follow-Up Changes To The Journeys
|
||||
|
||||
To make `workspace-user-journeys.md` simpler and more intuitive, the next revision should:
|
||||
|
||||
1. Move the simplest single-repo and monorepo journey to the top.
|
||||
2. Move most terminology and internal model sections later or into an appendix.
|
||||
3. Reframe "coordination workspace" as an escalation artifact, not a starting abstraction.
|
||||
4. Replace many uses of "scope" with "area" in user-facing examples.
|
||||
5. Convert abstract ownership questions into recommendation-first prompts.
|
||||
6. Compress the team-scale setup into one simple story:
|
||||
- shared initiative repo
|
||||
- local link command
|
||||
- open agent here
|
||||
7. Make the escalation flow explicitly preserve user context so it reads as continuation, not restart.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The current workspace thinking is directionally right, but the UX should become much more opinionated and much less explanatory up front.
|
||||
|
||||
The simplest product shape is:
|
||||
|
||||
- one default path: local planning from where the user already is
|
||||
- one escalation path: upgrade into a coordinated initiative when needed
|
||||
- progressive disclosure: only show advanced concepts when OpenSpec needs a real decision
|
||||
|
||||
If OpenSpec does this well, the same system can feel intuitive for:
|
||||
|
||||
- solo users
|
||||
- small teams
|
||||
- large monorepos
|
||||
- multi-repo teams
|
||||
- cross-team initiatives
|
||||
@@ -203,7 +203,7 @@ The system SHALL generate schema-aware apply instructions via `openspec instruct
|
||||
- **WHEN** user runs `openspec instructions apply --change <id>`
|
||||
- **AND** all required artifacts (per schema's `apply.requires`) exist
|
||||
- **THEN** the system outputs:
|
||||
- Context files from all existing artifacts
|
||||
- `contextFiles` mapping artifact IDs to arrays of concrete paths for all existing artifacts
|
||||
- Schema-specific instruction text
|
||||
- Progress tracking file path (if `apply.tracks` is set)
|
||||
|
||||
@@ -218,7 +218,7 @@ The system SHALL generate schema-aware apply instructions via `openspec instruct
|
||||
|
||||
- **WHEN** user runs `openspec instructions apply --change <id> --json`
|
||||
- **THEN** the system outputs JSON with:
|
||||
- `contextFiles`: array of paths to existing artifacts
|
||||
- `contextFiles`: object mapping artifact IDs to arrays of concrete paths for existing artifacts
|
||||
- `instruction`: the apply instruction text
|
||||
- `tracks`: path to progress file or null
|
||||
- `applyRequires`: list of required artifact IDs
|
||||
|
||||
@@ -168,7 +168,7 @@ The archive slash command template SHALL support optional change ID arguments fo
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Requirement: Error Handling
|
||||
### Error Handling
|
||||
|
||||
The command SHALL handle edge cases gracefully.
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ The system SHALL follow these principles:
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Requirement: Project Structure
|
||||
### Project Structure
|
||||
|
||||
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
|
||||
|
||||
@@ -285,7 +285,7 @@ openspec/
|
||||
|
||||
## Specification Format
|
||||
|
||||
### Requirement: Structured Format for Behavioral Specs
|
||||
### Behavioral Spec Format
|
||||
|
||||
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
|
||||
|
||||
@@ -316,7 +316,7 @@ Behavioral specifications SHALL use a structured format with consistent section
|
||||
|
||||
## Change Storage Convention
|
||||
|
||||
### Requirement: Header-Based Requirement Identification
|
||||
### Header-Based Requirement Identification
|
||||
|
||||
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
|
||||
|
||||
@@ -345,7 +345,7 @@ Requirement headers SHALL serve as unique identifiers for programmatic matching
|
||||
- **THEN** ensure no duplicate headers exist within a spec
|
||||
- **AND** validation tools SHALL flag duplicate headers as errors
|
||||
|
||||
### Requirement: Change Storage Convention
|
||||
### Change Storage Convention
|
||||
|
||||
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
|
||||
|
||||
@@ -388,7 +388,7 @@ The `changes/[name]/specs/` directory SHALL contain:
|
||||
- `-` for REMOVED (red)
|
||||
- `→` for RENAMED (cyan)
|
||||
|
||||
### Requirement: Archive Process Enhancement
|
||||
### Archive Process Enhancement
|
||||
|
||||
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
|
||||
|
||||
@@ -411,7 +411,7 @@ The archive process SHALL programmatically apply delta changes to current specif
|
||||
- **AND** require manual resolution before proceeding
|
||||
- **AND** provide clear guidance on resolving conflicts
|
||||
|
||||
### Requirement: Proposal Format
|
||||
### Proposal Format
|
||||
|
||||
Proposals SHALL explicitly document all changes with clear from/to comparisons.
|
||||
|
||||
@@ -444,7 +444,7 @@ The change process SHALL follow these states:
|
||||
|
||||
## Viewing Changes
|
||||
|
||||
### Requirement: Change Review
|
||||
### Change Review
|
||||
|
||||
The system SHALL support multiple methods for reviewing proposed changes.
|
||||
|
||||
|
||||
Generated
+13
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1803,6 +1803,7 @@
|
||||
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1852,6 +1853,7 @@
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
@@ -2182,6 +2184,7 @@
|
||||
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2219,6 +2222,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2685,6 +2689,7 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4448,6 +4453,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4546,6 +4552,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4611,6 +4618,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4727,6 +4735,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4740,6 +4749,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -4919,6 +4929,7 @@
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.1",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
+8
-72
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Postinstall script for auto-installing shell completions
|
||||
* Postinstall script that hints about shell completions
|
||||
*
|
||||
* This script runs automatically after npm install unless:
|
||||
* Completion installation is opt-in: the user must run
|
||||
* `openspec completion install` explicitly. This script only
|
||||
* prints a one-line tip after npm install.
|
||||
*
|
||||
* The tip is suppressed when:
|
||||
* - CI=true environment variable is set
|
||||
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
|
||||
* - dist/ directory doesn't exist (dev setup scenario)
|
||||
@@ -48,65 +52,6 @@ async function distExists() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the user's shell
|
||||
*/
|
||||
async function detectShell() {
|
||||
try {
|
||||
const { detectShell } = await import('../dist/utils/shell-detection.js');
|
||||
const result = detectShell();
|
||||
return result.shell;
|
||||
} catch (error) {
|
||||
// Fail silently if detection module doesn't exist
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install completions for the detected shell
|
||||
*/
|
||||
async function installCompletions(shell) {
|
||||
try {
|
||||
const { CompletionFactory } = await import('../dist/core/completions/factory.js');
|
||||
const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');
|
||||
|
||||
// Check if shell is supported
|
||||
if (!CompletionFactory.isSupported(shell)) {
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate completion script
|
||||
const generator = CompletionFactory.createGenerator(shell);
|
||||
const script = generator.generate(COMMAND_REGISTRY);
|
||||
|
||||
// Install completion script
|
||||
const installer = CompletionFactory.createInstaller(shell);
|
||||
const result = await installer.install(script);
|
||||
|
||||
if (result.success) {
|
||||
// Show success message based on installation type
|
||||
if (result.isOhMyZsh) {
|
||||
console.log(`✓ Shell completions installed`);
|
||||
console.log(` Restart shell: exec zsh`);
|
||||
} else if (result.zshrcConfigured) {
|
||||
console.log(`✓ Shell completions installed and configured`);
|
||||
console.log(` Restart shell: exec zsh`);
|
||||
} else {
|
||||
console.log(`✓ Shell completions installed to ~/.zsh/completions/`);
|
||||
console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);
|
||||
console.log(` Then: exec zsh`);
|
||||
}
|
||||
} else {
|
||||
// Installation failed, show tip for manual install
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail gracefully - show tip for manual install
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
@@ -124,19 +69,10 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect shell
|
||||
const shell = await detectShell();
|
||||
if (!shell) {
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Install completions
|
||||
await installCompletions(shell);
|
||||
// Completions are opt-in — just print a hint
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
} catch (error) {
|
||||
// Fail gracefully - never break npm install
|
||||
// Show tip for manual install
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ ORIGINAL_CI="${CI:-}"
|
||||
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
|
||||
|
||||
# Test 1: Normal install
|
||||
echo "Test 1: Normal install (should attempt to install completions)"
|
||||
echo "Test 1: Normal install (should print tip about completions)"
|
||||
echo "--------------------------------------"
|
||||
unset CI
|
||||
unset OPENSPEC_NO_COMPLETIONS
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
resolveSchema,
|
||||
resolveArtifactOutputs,
|
||||
type ArtifactInstructions,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ export async function instructionsCommand(
|
||||
artifactId: string | undefined,
|
||||
options: InstructionsOptions
|
||||
): Promise<void> {
|
||||
const spinner = ora('Generating instructions...').start();
|
||||
const spinner = options.json ? undefined : ora('Generating instructions...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
@@ -60,7 +61,7 @@ export async function instructionsCommand(
|
||||
const context = loadChangeContext(projectRoot, changeName, options.schema);
|
||||
|
||||
if (!artifactId) {
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
|
||||
throw new Error(
|
||||
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
|
||||
@@ -70,7 +71,7 @@ export async function instructionsCommand(
|
||||
const artifact = context.graph.getArtifact(artifactId);
|
||||
|
||||
if (!artifact) {
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
|
||||
throw new Error(
|
||||
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
|
||||
@@ -80,7 +81,7 @@ export async function instructionsCommand(
|
||||
const instructions = generateInstructions(context, artifactId, projectRoot);
|
||||
const isBlocked = instructions.dependencies.some((d) => !d.done);
|
||||
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(instructions, null, 2));
|
||||
@@ -89,7 +90,7 @@ export async function instructionsCommand(
|
||||
|
||||
printInstructionsText(instructions, isBlocked);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -237,68 +238,6 @@ function parseTasksFile(content: string): TaskItem[] {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an artifact output exists in the change directory.
|
||||
* Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
|
||||
*/
|
||||
function artifactOutputExists(changeDir: string, generates: string): boolean {
|
||||
// Normalize the generates path to use platform-specific separators
|
||||
const normalizedGenerates = generates.split('/').join(path.sep);
|
||||
const fullPath = path.join(changeDir, normalizedGenerates);
|
||||
|
||||
// If it's a glob pattern (contains ** or *), check for matching files
|
||||
if (generates.includes('*')) {
|
||||
// Extract the directory part before the glob pattern
|
||||
const parts = normalizedGenerates.split(path.sep);
|
||||
const dirParts: string[] = [];
|
||||
let patternPart = '';
|
||||
for (const part of parts) {
|
||||
if (part.includes('*')) {
|
||||
patternPart = part;
|
||||
break;
|
||||
}
|
||||
dirParts.push(part);
|
||||
}
|
||||
const dirPath = path.join(changeDir, ...dirParts);
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract expected extension from pattern (e.g., "*.md" -> ".md")
|
||||
const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
|
||||
const expectedExt = extMatch ? extMatch[1] : null;
|
||||
|
||||
// Recursively check for matching files
|
||||
const hasMatchingFiles = (dir: string): boolean => {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
// For ** patterns, recurse into subdirectories
|
||||
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
|
||||
return true;
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
// Check if file matches expected extension (or any file if no extension specified)
|
||||
if (!expectedExt || entry.name.endsWith(expectedExt)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return hasMatchingFiles(dirPath);
|
||||
}
|
||||
|
||||
return fs.existsSync(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates apply instructions for implementing tasks from a change.
|
||||
* Schema-aware: reads apply phase configuration from schema to determine
|
||||
@@ -311,7 +250,7 @@ export async function generateApplyInstructions(
|
||||
): Promise<ApplyInstructions> {
|
||||
// loadChangeContext will auto-detect schema from metadata if not provided
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
const changeDir = context.changeDir;
|
||||
|
||||
// Get the full schema to access the apply phase configuration
|
||||
const schema = resolveSchema(context.schemaName, projectRoot);
|
||||
@@ -327,16 +266,17 @@ export async function generateApplyInstructions(
|
||||
const missingArtifacts: string[] = [];
|
||||
for (const artifactId of requiredArtifactIds) {
|
||||
const artifact = schema.artifacts.find((a) => a.id === artifactId);
|
||||
if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {
|
||||
if (artifact && resolveArtifactOutputs(changeDir, artifact.generates).length === 0) {
|
||||
missingArtifacts.push(artifactId);
|
||||
}
|
||||
}
|
||||
|
||||
// Build context files from all existing artifacts in schema
|
||||
const contextFiles: Record<string, string> = {};
|
||||
const contextFiles: Record<string, string[]> = {};
|
||||
for (const artifact of schema.artifacts) {
|
||||
if (artifactOutputExists(changeDir, artifact.generates)) {
|
||||
contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
|
||||
const outputs = resolveArtifactOutputs(changeDir, artifact.generates);
|
||||
if (outputs.length > 0) {
|
||||
contextFiles[artifact.id] = outputs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +340,7 @@ export async function generateApplyInstructions(
|
||||
}
|
||||
|
||||
export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {
|
||||
const spinner = ora('Generating apply instructions...').start();
|
||||
const spinner = options.json ? undefined : ora('Generating apply instructions...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
@@ -414,7 +354,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions
|
||||
// generateApplyInstructions uses loadChangeContext which auto-detects schema
|
||||
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
|
||||
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(instructions, null, 2));
|
||||
@@ -423,7 +363,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions
|
||||
|
||||
printApplyInstructionsText(instructions);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -448,8 +388,10 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi
|
||||
const contextFileEntries = Object.entries(contextFiles);
|
||||
if (contextFileEntries.length > 0) {
|
||||
console.log('### Context Files');
|
||||
for (const [artifactId, filePath] of contextFileEntries) {
|
||||
console.log(`- ${artifactId}: ${filePath}`);
|
||||
for (const [artifactId, filePaths] of contextFileEntries) {
|
||||
for (const filePath of filePaths) {
|
||||
console.log(`- ${artifactId}: ${filePath}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ApplyInstructions {
|
||||
changeName: string;
|
||||
changeDir: string;
|
||||
schemaName: string;
|
||||
contextFiles: Record<string, string>;
|
||||
contextFiles: Record<string, string[]>;
|
||||
progress: {
|
||||
total: number;
|
||||
complete: number;
|
||||
@@ -86,6 +86,23 @@ export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of available change directory names under openspec/changes/.
|
||||
* Excludes the archive directory and hidden directories.
|
||||
*/
|
||||
export async function getAvailableChanges(projectRoot: string): Promise<string[]> {
|
||||
const changesPath = path.join(projectRoot, 'openspec', 'changes');
|
||||
try {
|
||||
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a change exists and returns available changes if not.
|
||||
* Checks directory existence directly to support scaffolded changes (without proposal.md).
|
||||
@@ -94,22 +111,8 @@ export async function validateChangeExists(
|
||||
changeName: string | undefined,
|
||||
projectRoot: string
|
||||
): Promise<string> {
|
||||
const changesPath = path.join(projectRoot, 'openspec', 'changes');
|
||||
|
||||
// Get all change directories (not just those with proposal.md)
|
||||
const getAvailableChanges = async (): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
if (!changeName) {
|
||||
const available = await getAvailableChanges();
|
||||
const available = await getAvailableChanges(projectRoot);
|
||||
if (available.length === 0) {
|
||||
throw new Error('No changes found. Create one with: openspec new change <name>');
|
||||
}
|
||||
@@ -125,11 +128,11 @@ export async function validateChangeExists(
|
||||
}
|
||||
|
||||
// Check directory existence directly
|
||||
const changePath = path.join(changesPath, changeName);
|
||||
const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
|
||||
|
||||
if (!exists) {
|
||||
const available = await getAvailableChanges();
|
||||
const available = await getAvailableChanges(projectRoot);
|
||||
if (available.length === 0) {
|
||||
throw new Error(
|
||||
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
validateChangeExists,
|
||||
validateSchemaExists,
|
||||
getAvailableChanges,
|
||||
getStatusIndicator,
|
||||
getStatusColor,
|
||||
} from './shared.js';
|
||||
@@ -33,10 +34,31 @@ export interface StatusOptions {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const spinner = ora('Loading change status...').start();
|
||||
const spinner = options.json ? undefined : ora('Loading change status...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Handle no-changes case gracefully — status is informational,
|
||||
// so "no changes" is a valid state, not an error.
|
||||
if (!options.change) {
|
||||
const available = await getAvailableChanges(projectRoot);
|
||||
if (available.length === 0) {
|
||||
spinner?.stop();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log('No active changes. Create one with: openspec new change <name>');
|
||||
return;
|
||||
}
|
||||
// Changes exist but --change not provided
|
||||
spinner?.stop();
|
||||
throw new Error(
|
||||
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
|
||||
// Validate schema if explicitly provided
|
||||
@@ -48,7 +70,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const context = loadChangeContext(projectRoot, changeName, options.schema);
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
@@ -57,7 +79,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
|
||||
printStatusText(status);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSchemaDir,
|
||||
ArtifactGraph,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -33,7 +34,7 @@ export interface TemplateInfo {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function templatesCommand(options: TemplatesOptions): Promise<void> {
|
||||
const spinner = ora('Loading templates...').start();
|
||||
const spinner = options.json ? undefined : ora('Loading templates...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
@@ -68,11 +69,13 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>
|
||||
|
||||
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
|
||||
artifactId: artifact.id,
|
||||
templatePath: path.join(schemaDir, 'templates', artifact.template),
|
||||
templatePath: FileSystemUtils.canonicalizeExistingPath(
|
||||
path.join(schemaDir, 'templates', artifact.template)
|
||||
),
|
||||
source,
|
||||
}));
|
||||
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
|
||||
if (options.json) {
|
||||
const output: Record<string, { path: string; source: string }> = {};
|
||||
@@ -92,7 +95,7 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>
|
||||
console.log(` ${t.templatePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
spinner?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export { ArtifactGraph } from './graph.js';
|
||||
|
||||
// State detection
|
||||
export { detectCompleted } from './state.js';
|
||||
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
||||
|
||||
// Schema resolution
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getSchemaDir, resolveSchema } from './resolver.js';
|
||||
import { ArtifactGraph } from './graph.js';
|
||||
import { detectCompleted } from './state.js';
|
||||
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { readProjectConfig, validateConfigRules } from '../project-config.js';
|
||||
import type { Artifact, CompletedSet } from './types.js';
|
||||
|
||||
@@ -137,15 +138,17 @@ export function loadTemplate(
|
||||
);
|
||||
}
|
||||
|
||||
const fullPath = path.join(schemaDir, 'templates', templatePath);
|
||||
const templatePathOnDisk = path.join(schemaDir, 'templates', templatePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
if (!fs.existsSync(templatePathOnDisk)) {
|
||||
throw new TemplateLoadError(
|
||||
`Template not found: ${fullPath}`,
|
||||
fullPath
|
||||
`Template not found: ${templatePathOnDisk}`,
|
||||
templatePathOnDisk
|
||||
);
|
||||
}
|
||||
|
||||
const fullPath = FileSystemUtils.canonicalizeExistingPath(templatePathOnDisk);
|
||||
|
||||
try {
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch (err) {
|
||||
@@ -175,7 +178,9 @@ export function loadChangeContext(
|
||||
changeName: string,
|
||||
schemaName?: string
|
||||
): ChangeContext {
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
const changeDir = FileSystemUtils.canonicalizeExistingPath(
|
||||
path.join(projectRoot, 'openspec', 'changes', changeName)
|
||||
);
|
||||
|
||||
// Resolve schema: explicit > metadata > default
|
||||
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
/**
|
||||
* Checks if a path contains glob pattern characters.
|
||||
*/
|
||||
export function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
||||
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
||||
*/
|
||||
export function resolveArtifactOutputs(changeDir: string, generates: string): string[] {
|
||||
if (!isGlobPattern(generates)) {
|
||||
const fullPath = path.join(changeDir, generates);
|
||||
try {
|
||||
return fs.statSync(fullPath).isFile()
|
||||
? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
||||
const matches = fg
|
||||
.sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true })
|
||||
.map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
|
||||
|
||||
return Array.from(new Set(matches)).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an artifact has at least one resolved output file.
|
||||
*/
|
||||
export function artifactOutputExists(changeDir: string, generates: string): boolean {
|
||||
return resolveArtifactOutputs(changeDir, generates).length > 0;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import type { CompletedSet } from './types.js';
|
||||
import type { ArtifactGraph } from './graph.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { artifactOutputExists } from './outputs.js';
|
||||
|
||||
/**
|
||||
* Detects which artifacts are completed by checking file existence in the change directory.
|
||||
@@ -35,30 +33,5 @@ export function detectCompleted(graph: ArtifactGraph, changeDir: string): Comple
|
||||
* Supports both simple paths and glob patterns.
|
||||
*/
|
||||
function isArtifactComplete(generates: string, changeDir: string): boolean {
|
||||
const fullPattern = path.join(changeDir, generates);
|
||||
|
||||
// Check if it's a glob pattern
|
||||
if (isGlobPattern(generates)) {
|
||||
return hasGlobMatches(fullPattern);
|
||||
}
|
||||
|
||||
// Simple file path - check if file exists
|
||||
return fs.existsSync(fullPattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path contains glob pattern characters.
|
||||
*/
|
||||
function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a glob pattern has any matches.
|
||||
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
|
||||
*/
|
||||
function hasGlobMatches(pattern: string): boolean {
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
|
||||
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
|
||||
return matches.length > 0;
|
||||
return artifactOutputExists(changeDir, generates);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,26 @@ import { AI_TOOLS, type AIToolOption } from './config.js';
|
||||
* Scans the project path for AI tool configuration directories and returns
|
||||
* the tools that are present.
|
||||
*
|
||||
* Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the
|
||||
* project root. Only tools with a `skillsDir` property are considered.
|
||||
* For tools with `detectionPaths`, checks those specific paths (files or
|
||||
* directories). Otherwise checks for the tool's `skillsDir` directory at
|
||||
* the project root. Only tools with a `skillsDir` property are considered.
|
||||
*/
|
||||
export function getAvailableTools(projectPath: string): AIToolOption[] {
|
||||
return AI_TOOLS.filter((tool) => {
|
||||
if (!tool.skillsDir) return false;
|
||||
|
||||
if (tool.detectionPaths && tool.detectionPaths.length > 0) {
|
||||
// statSync without .isDirectory() — detection paths can be files or directories
|
||||
return tool.detectionPaths.some((p) => {
|
||||
try {
|
||||
fs.statSync(path.join(projectPath, p));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const dirPath = path.join(projectPath, tool.skillsDir);
|
||||
try {
|
||||
return fs.statSync(dirPath).isDirectory();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Bob Shell Command Adapter
|
||||
*
|
||||
* Formats commands for Bob Shell following its markdown specification.
|
||||
* Commands are stored in .bob/commands/ directory.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
import { transformToHyphenCommands } from '../../../utils/command-references.js';
|
||||
|
||||
/**
|
||||
* Escapes a string value for safe YAML output.
|
||||
* Quotes the string if it contains special YAML characters.
|
||||
*/
|
||||
function escapeYamlValue(value: string): string {
|
||||
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
|
||||
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
|
||||
if (needsQuoting) {
|
||||
// Use double quotes and escape internal double quotes and backslashes
|
||||
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bob Shell adapter for command generation.
|
||||
* File path: .bob/commands/opsx-<id>.md
|
||||
* Frontmatter: description, argument-hint
|
||||
*/
|
||||
export const bobAdapter: ToolCommandAdapter = {
|
||||
toolId: 'bob',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.bob', 'commands', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
// Transform command references from colon to hyphen format for Bob
|
||||
const transformedBody = transformToHyphenCommands(content.body);
|
||||
|
||||
return `---
|
||||
description: ${escapeYamlValue(content.description)}
|
||||
argument-hint: command arguments
|
||||
---
|
||||
|
||||
${transformedBody}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
export { amazonQAdapter } from './amazon-q.js';
|
||||
export { antigravityAdapter } from './antigravity.js';
|
||||
export { auggieAdapter } from './auggie.js';
|
||||
export { bobAdapter } from './bob.js';
|
||||
export { claudeAdapter } from './claude.js';
|
||||
export { clineAdapter } from './cline.js';
|
||||
export { codexAdapter } from './codex.js';
|
||||
@@ -19,11 +20,13 @@ export { factoryAdapter } from './factory.js';
|
||||
export { geminiAdapter } from './gemini.js';
|
||||
export { githubCopilotAdapter } from './github-copilot.js';
|
||||
export { iflowAdapter } from './iflow.js';
|
||||
export { junieAdapter } from './junie.js';
|
||||
export { kilocodeAdapter } from './kilocode.js';
|
||||
export { kiroAdapter } from './kiro.js';
|
||||
export { opencodeAdapter } from './opencode.js';
|
||||
export { piAdapter } from './pi.js';
|
||||
export { qoderAdapter } from './qoder.js';
|
||||
export { lingmaAdapter } from './lingma.js';
|
||||
export { qwenAdapter } from './qwen.js';
|
||||
export { roocodeAdapter } from './roocode.js';
|
||||
export { windsurfAdapter } from './windsurf.js';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Junie Command Adapter
|
||||
*
|
||||
* Formats commands for Junie following its frontmatter specification.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Junie adapter for command generation.
|
||||
* File path: .junie/commands/opsx-<id>.md
|
||||
* Frontmatter: description
|
||||
*/
|
||||
export const junieAdapter: ToolCommandAdapter = {
|
||||
toolId: 'junie',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.junie', 'commands', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
return `---
|
||||
description: ${content.description}
|
||||
---
|
||||
|
||||
${content.body}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Lingma Command Adapter
|
||||
*
|
||||
* Formats commands for Lingma following its frontmatter specification.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Lingma adapter for command generation.
|
||||
* File path: .lingma/commands/opsx/<id>.md
|
||||
* Frontmatter: name, description, category, tags
|
||||
*/
|
||||
export const lingmaAdapter: ToolCommandAdapter = {
|
||||
toolId: 'lingma',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.lingma', 'commands', 'opsx', `${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
const tagsStr = content.tags.join(', ');
|
||||
return `---
|
||||
name: ${content.name}
|
||||
description: ${content.description}
|
||||
category: ${content.category}
|
||||
tags: [${tagsStr}]
|
||||
---
|
||||
|
||||
${content.body}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -10,14 +10,14 @@ import { transformToHyphenCommands } from '../../../utils/command-references.js'
|
||||
|
||||
/**
|
||||
* OpenCode adapter for command generation.
|
||||
* File path: .opencode/command/opsx-<id>.md
|
||||
* File path: .opencode/commands/opsx-<id>.md
|
||||
* Frontmatter: description
|
||||
*/
|
||||
export const opencodeAdapter: ToolCommandAdapter = {
|
||||
toolId: 'opencode',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.opencode', 'command', `opsx-${commandId}.md`);
|
||||
return path.join('.opencode', 'commands', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
|
||||
@@ -7,6 +7,20 @@
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
import { transformToHyphenCommands } from '../../../utils/command-references.js';
|
||||
|
||||
const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
|
||||
|
||||
function injectPiArgs(body: string): string {
|
||||
if (body.includes('$@') || body.includes('$ARGUMENTS')) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return body.replace(
|
||||
PI_INPUT_HEADING,
|
||||
(heading) => `${heading}\n**Provided arguments**: $@`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a string value for safe YAML output.
|
||||
@@ -27,6 +41,10 @@ function escapeYamlValue(value: string): string {
|
||||
* Pi adapter for prompt template generation.
|
||||
* File path: .pi/prompts/opsx-<id>.md
|
||||
* Frontmatter: description
|
||||
*
|
||||
* Pi uses the filename (minus .md) as the slash command name, so
|
||||
* opsx-propose.md → /opsx-propose. Command references in the body
|
||||
* are transformed from /opsx: to /opsx- for consistency.
|
||||
*/
|
||||
export const piAdapter: ToolCommandAdapter = {
|
||||
toolId: 'pi',
|
||||
@@ -36,11 +54,14 @@ export const piAdapter: ToolCommandAdapter = {
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
// Transform /opsx: references to /opsx- and inject $@ for template args
|
||||
const transformedBody = transformToHyphenCommands(content.body);
|
||||
|
||||
return `---
|
||||
description: ${escapeYamlValue(content.description)}
|
||||
---
|
||||
|
||||
${content.body}
|
||||
${injectPiArgs(transformedBody)}
|
||||
`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ToolCommandAdapter } from './types.js';
|
||||
import { amazonQAdapter } from './adapters/amazon-q.js';
|
||||
import { antigravityAdapter } from './adapters/antigravity.js';
|
||||
import { auggieAdapter } from './adapters/auggie.js';
|
||||
import { bobAdapter } from './adapters/bob.js';
|
||||
import { claudeAdapter } from './adapters/claude.js';
|
||||
import { clineAdapter } from './adapters/cline.js';
|
||||
import { codexAdapter } from './adapters/codex.js';
|
||||
@@ -21,11 +22,13 @@ import { factoryAdapter } from './adapters/factory.js';
|
||||
import { geminiAdapter } from './adapters/gemini.js';
|
||||
import { githubCopilotAdapter } from './adapters/github-copilot.js';
|
||||
import { iflowAdapter } from './adapters/iflow.js';
|
||||
import { junieAdapter } from './adapters/junie.js';
|
||||
import { kilocodeAdapter } from './adapters/kilocode.js';
|
||||
import { kiroAdapter } from './adapters/kiro.js';
|
||||
import { opencodeAdapter } from './adapters/opencode.js';
|
||||
import { piAdapter } from './adapters/pi.js';
|
||||
import { qoderAdapter } from './adapters/qoder.js';
|
||||
import { lingmaAdapter } from './adapters/lingma.js';
|
||||
import { qwenAdapter } from './adapters/qwen.js';
|
||||
import { roocodeAdapter } from './adapters/roocode.js';
|
||||
import { windsurfAdapter } from './adapters/windsurf.js';
|
||||
@@ -41,6 +44,7 @@ export class CommandAdapterRegistry {
|
||||
CommandAdapterRegistry.register(amazonQAdapter);
|
||||
CommandAdapterRegistry.register(antigravityAdapter);
|
||||
CommandAdapterRegistry.register(auggieAdapter);
|
||||
CommandAdapterRegistry.register(bobAdapter);
|
||||
CommandAdapterRegistry.register(claudeAdapter);
|
||||
CommandAdapterRegistry.register(clineAdapter);
|
||||
CommandAdapterRegistry.register(codexAdapter);
|
||||
@@ -53,11 +57,13 @@ export class CommandAdapterRegistry {
|
||||
CommandAdapterRegistry.register(geminiAdapter);
|
||||
CommandAdapterRegistry.register(githubCopilotAdapter);
|
||||
CommandAdapterRegistry.register(iflowAdapter);
|
||||
CommandAdapterRegistry.register(junieAdapter);
|
||||
CommandAdapterRegistry.register(kilocodeAdapter);
|
||||
CommandAdapterRegistry.register(kiroAdapter);
|
||||
CommandAdapterRegistry.register(opencodeAdapter);
|
||||
CommandAdapterRegistry.register(piAdapter);
|
||||
CommandAdapterRegistry.register(qoderAdapter);
|
||||
CommandAdapterRegistry.register(lingmaAdapter);
|
||||
CommandAdapterRegistry.register(qwenAdapter);
|
||||
CommandAdapterRegistry.register(roocodeAdapter);
|
||||
CommandAdapterRegistry.register(windsurfAdapter);
|
||||
|
||||
@@ -23,6 +23,49 @@ export class PowerShellInstaller {
|
||||
this.homeDir = homeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the encoding of a file by inspecting its BOM (Byte Order Mark).
|
||||
* Returns the Node.js BufferEncoding and the raw BOM bytes to preserve on write.
|
||||
*/
|
||||
private detectEncoding(buffer: Buffer): { encoding: BufferEncoding; bom: Buffer } {
|
||||
// UTF-16 LE BOM: FF FE
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||
return { encoding: 'utf16le', bom: Buffer.from([0xff, 0xfe]) };
|
||||
}
|
||||
// UTF-16 BE BOM: FE FF — not natively supported by Node
|
||||
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
|
||||
throw new Error(
|
||||
'File is encoded as UTF-16 BE which is not supported. ' +
|
||||
'Please re-save as UTF-8 or UTF-16 LE, then retry.',
|
||||
);
|
||||
}
|
||||
// UTF-8 BOM: EF BB BF
|
||||
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
|
||||
return { encoding: 'utf-8', bom: Buffer.from([0xef, 0xbb, 0xbf]) };
|
||||
}
|
||||
// No BOM → default UTF-8
|
||||
return { encoding: 'utf-8', bom: Buffer.alloc(0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a profile file, preserving its encoding metadata for round-trip writes.
|
||||
* Throws if the file uses UTF-16 BE (unsupported by Node).
|
||||
*/
|
||||
private async readProfileFile(filePath: string): Promise<{ content: string; encoding: BufferEncoding; bom: Buffer }> {
|
||||
const raw = await fs.readFile(filePath);
|
||||
const { encoding, bom } = this.detectEncoding(raw);
|
||||
const content = raw.subarray(bom.length).toString(encoding);
|
||||
return { content, encoding, bom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a profile file, preserving the original BOM and encoding.
|
||||
*/
|
||||
private async writeProfileFile(filePath: string, content: string, encoding: BufferEncoding, bom: Buffer): Promise<void> {
|
||||
const body = Buffer.from(content, encoding);
|
||||
await fs.writeFile(filePath, Buffer.concat([bom, body]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PowerShell profile path
|
||||
* Prefers $PROFILE environment variable, falls back to platform defaults
|
||||
@@ -132,10 +175,22 @@ export class PowerShellInstaller {
|
||||
await fs.mkdir(profileDir, { recursive: true });
|
||||
|
||||
let profileContent = '';
|
||||
let fileEncoding: BufferEncoding = 'utf-8';
|
||||
let fileBom: Buffer = Buffer.alloc(0);
|
||||
try {
|
||||
profileContent = await fs.readFile(profilePath, 'utf-8');
|
||||
} catch {
|
||||
// Profile doesn't exist yet, that's fine
|
||||
const file = await this.readProfileFile(profilePath);
|
||||
profileContent = file.content;
|
||||
fileEncoding = file.encoding;
|
||||
fileBom = file.bom;
|
||||
} catch (err: any) {
|
||||
// If the file doesn't exist that's fine — we'll create it as UTF-8.
|
||||
// Any other read error (permissions, unsupported encoding, etc.) → skip this profile.
|
||||
if (err?.code === 'ENOENT') {
|
||||
// keep defaults
|
||||
} else {
|
||||
console.warn(`Warning: Skipping ${profilePath}: ${err?.message ?? String(err)}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already configured
|
||||
@@ -154,7 +209,7 @@ export class PowerShellInstaller {
|
||||
].join('\n');
|
||||
|
||||
const newContent = profileContent + openspecBlock;
|
||||
await fs.writeFile(profilePath, newContent, 'utf-8');
|
||||
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
|
||||
anyConfigured = true;
|
||||
} catch (error) {
|
||||
// Continue to next profile if this one fails
|
||||
@@ -177,12 +232,21 @@ export class PowerShellInstaller {
|
||||
|
||||
for (const profilePath of profilePaths) {
|
||||
try {
|
||||
// Read profile content
|
||||
// Read profile content with encoding detection
|
||||
let profileContent: string;
|
||||
let fileEncoding: BufferEncoding = 'utf-8';
|
||||
let fileBom: Buffer = Buffer.alloc(0);
|
||||
try {
|
||||
profileContent = await fs.readFile(profilePath, 'utf-8');
|
||||
} catch {
|
||||
continue; // Profile doesn't exist, nothing to remove
|
||||
const file = await this.readProfileFile(profilePath);
|
||||
profileContent = file.content;
|
||||
fileEncoding = file.encoding;
|
||||
fileBom = file.bom;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
continue; // Profile doesn't exist, nothing to remove
|
||||
}
|
||||
console.warn(`Warning: Could not read ${profilePath}: ${err?.message ?? String(err)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove OPENSPEC:START -> OPENSPEC:END block
|
||||
@@ -207,7 +271,7 @@ export class PowerShellInstaller {
|
||||
// Clean up extra newlines
|
||||
const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n';
|
||||
|
||||
await fs.writeFile(profilePath, newContent, 'utf-8');
|
||||
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
|
||||
anyRemoved = true;
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not clean ${profilePath}: ${error}`);
|
||||
|
||||
+6
-1
@@ -15,15 +15,18 @@ export interface AIToolOption {
|
||||
available: boolean;
|
||||
successLabel?: string;
|
||||
skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec
|
||||
detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection
|
||||
}
|
||||
|
||||
export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
|
||||
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
|
||||
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
|
||||
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
|
||||
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
|
||||
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
|
||||
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },
|
||||
{ name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' },
|
||||
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
|
||||
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },
|
||||
@@ -31,13 +34,15 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
|
||||
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
|
||||
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
|
||||
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
|
||||
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
|
||||
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
|
||||
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
|
||||
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
|
||||
{ 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' },
|
||||
|
||||
+6
-11
@@ -208,19 +208,14 @@ export class InitCommand {
|
||||
|
||||
const canPrompt = this.canPromptInteractively();
|
||||
|
||||
if (this.force) {
|
||||
// --force flag: proceed with cleanup automatically
|
||||
if (this.force || !canPrompt) {
|
||||
// --force flag or non-interactive mode: proceed with cleanup automatically.
|
||||
// Legacy slash commands are 100% OpenSpec-managed, and config file cleanup
|
||||
// only removes markers (never deletes files), so auto-cleanup is safe.
|
||||
await this.performLegacyCleanup(projectPath, detection);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canPrompt) {
|
||||
// Non-interactive mode without --force: abort
|
||||
console.log(chalk.red('Legacy files detected in non-interactive mode.'));
|
||||
console.log(chalk.dim('Run interactively to upgrade, or use --force to auto-cleanup.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Interactive mode: prompt for confirmation
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const shouldCleanup = await confirm({
|
||||
@@ -542,8 +537,8 @@ export class InitCommand {
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
// Generate SKILL.md content with YAML frontmatter including generatedBy
|
||||
// Use hyphen-based command references for OpenCode
|
||||
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
|
||||
// Use hyphen-based command references for tools where filename = command name
|
||||
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
|
||||
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
|
||||
|
||||
// Write the skill file
|
||||
|
||||
+22
-11
@@ -34,6 +34,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec' },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
|
||||
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec' },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
|
||||
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
|
||||
@@ -49,10 +50,11 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
|
||||
'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },
|
||||
'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },
|
||||
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
|
||||
'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' },
|
||||
'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
|
||||
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
|
||||
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
|
||||
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
|
||||
'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
|
||||
'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
|
||||
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
|
||||
};
|
||||
@@ -63,7 +65,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
|
||||
export interface LegacySlashCommandPattern {
|
||||
type: 'directory' | 'files';
|
||||
path?: string; // For directory type
|
||||
pattern?: string; // For files type (glob pattern)
|
||||
pattern?: string | string[]; // For files type (glob pattern or array of patterns)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,8 +194,11 @@ export async function detectLegacySlashCommands(
|
||||
}
|
||||
} else if (pattern.type === 'files' && pattern.pattern) {
|
||||
// For file-based patterns, check for individual files
|
||||
const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern);
|
||||
files.push(...foundFiles);
|
||||
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
||||
for (const p of patterns) {
|
||||
const foundFiles = await findLegacySlashCommandFiles(projectPath, p);
|
||||
files.push(...foundFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,14 +609,20 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s
|
||||
if (pattern.type === 'files' && pattern.pattern) {
|
||||
// Convert glob pattern to regex for matching
|
||||
// e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/
|
||||
const regexPattern = pattern.pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
||||
.replace(/\*/g, '.*'); // Replace * with .*
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(normalizedFile)) {
|
||||
tools.add(toolId);
|
||||
break;
|
||||
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
||||
let matched = false;
|
||||
for (const p of patterns) {
|
||||
const regexPattern = p
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
||||
.replace(/\*/g, '.*'); // Replace * with .*
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(normalizedFile)) {
|
||||
tools.add(toolId);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,17 +179,21 @@ export class ChangeParser extends MarkdownParser {
|
||||
private parseSectionsFromContent(content: string): Section[] {
|
||||
const normalizedContent = ChangeParser.normalizeContent(content);
|
||||
const lines = normalizedContent.split('\n');
|
||||
const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines);
|
||||
const sections: Section[] = [];
|
||||
const stack: Section[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (codeFenceLineMask[i]) {
|
||||
continue;
|
||||
}
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const title = headerMatch[2].trim();
|
||||
const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
|
||||
const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
|
||||
|
||||
const section = {
|
||||
level,
|
||||
@@ -215,12 +219,17 @@ export class ChangeParser extends MarkdownParser {
|
||||
return sections;
|
||||
}
|
||||
|
||||
private getContentUntilNextHeaderFromLines(lines: string[], startLine: number, currentLevel: number): string[] {
|
||||
private getContentUntilNextHeaderFromLines(
|
||||
lines: string[],
|
||||
codeFenceLineMask: boolean[],
|
||||
startLine: number,
|
||||
currentLevel: number
|
||||
): string[] {
|
||||
const contentLines: string[] = [];
|
||||
|
||||
for (let i = startLine; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const headerMatch = line.match(/^(#{1,6})\s+/);
|
||||
const headerMatch = codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
|
||||
|
||||
if (headerMatch && headerMatch[1].length <= currentLevel) {
|
||||
break;
|
||||
@@ -231,4 +240,4 @@ export class ChangeParser extends MarkdownParser {
|
||||
|
||||
return contentLines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ export interface Section {
|
||||
|
||||
export class MarkdownParser {
|
||||
private lines: string[];
|
||||
private codeFenceLineMask: boolean[];
|
||||
private currentLine: number;
|
||||
|
||||
constructor(content: string) {
|
||||
const normalized = MarkdownParser.normalizeContent(content);
|
||||
this.lines = normalized.split('\n');
|
||||
this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines);
|
||||
this.currentLine = 0;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,54 @@ export class MarkdownParser {
|
||||
return content.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
protected static buildCodeFenceMask(lines: string[]): boolean[] {
|
||||
const mask = new Array(lines.length).fill(false);
|
||||
let activeFence: { marker: '`' | '~'; length: number } | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const fence = MarkdownParser.getFenceMarker(lines[i]);
|
||||
|
||||
if (!activeFence) {
|
||||
if (fence) {
|
||||
activeFence = fence;
|
||||
mask[i] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
mask[i] = true;
|
||||
if (MarkdownParser.isClosingFence(lines[i], activeFence)) {
|
||||
activeFence = null;
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
private static getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (!fenceMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
marker: fenceMatch[1][0] as '`' | '~',
|
||||
length: fenceMatch[1].length,
|
||||
};
|
||||
}
|
||||
|
||||
private static isClosingFence(
|
||||
line: string,
|
||||
activeFence: { marker: '`' | '~'; length: number }
|
||||
): boolean {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
|
||||
return Boolean(
|
||||
fenceMatch &&
|
||||
fenceMatch[1][0] === activeFence.marker &&
|
||||
fenceMatch[1].length >= activeFence.length
|
||||
);
|
||||
}
|
||||
|
||||
parseSpec(name: string): Spec {
|
||||
const sections = this.parseSections();
|
||||
const purpose = this.findSection(sections, 'Purpose')?.content || '';
|
||||
@@ -81,6 +131,9 @@ export class MarkdownParser {
|
||||
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
const line = this.lines[i];
|
||||
if (this.codeFenceLineMask[i]) {
|
||||
continue;
|
||||
}
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headerMatch) {
|
||||
@@ -117,7 +170,7 @@ export class MarkdownParser {
|
||||
|
||||
for (let i = startLine; i < this.lines.length; i++) {
|
||||
const line = this.lines[i];
|
||||
const headerMatch = line.match(/^(#{1,6})\s+/);
|
||||
const headerMatch = this.codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
|
||||
|
||||
if (headerMatch && headerMatch[1].length <= currentLevel) {
|
||||
break;
|
||||
@@ -234,4 +287,4 @@ export class MarkdownParser {
|
||||
|
||||
return deltas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
||||
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
||||
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
||||
const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/;
|
||||
|
||||
export interface MainSpecStructureIssue {
|
||||
kind: 'delta-header' | 'requirement-outside-requirements';
|
||||
line: number;
|
||||
header: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function findMainSpecStructureIssues(content: string): MainSpecStructureIssue[] {
|
||||
const normalized = content.replace(/\r\n?/g, '\n');
|
||||
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
|
||||
const lines = stripped.split('\n');
|
||||
const issues: MainSpecStructureIssue[] = [];
|
||||
|
||||
const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
|
||||
let requirementsEndIndex = lines.length;
|
||||
|
||||
if (requirementsHeaderIndex !== -1) {
|
||||
for (let i = requirementsHeaderIndex + 1; i < lines.length; i++) {
|
||||
if (TOP_LEVEL_SECTION_HEADER.test(lines[i])) {
|
||||
requirementsEndIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DELTA_HEADER.test(line)) {
|
||||
issues.push({
|
||||
kind: 'delta-header',
|
||||
line: i + 1,
|
||||
header: trimmed,
|
||||
message:
|
||||
`Main spec contains delta header "${trimmed}". ` +
|
||||
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
|
||||
'and truncate the parsed ## Requirements section.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const requirementMatch = line.match(REQUIREMENT_HEADER);
|
||||
if (!requirementMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const insideRequirements =
|
||||
requirementsHeaderIndex !== -1 &&
|
||||
i > requirementsHeaderIndex &&
|
||||
i < requirementsEndIndex;
|
||||
|
||||
if (!insideRequirements) {
|
||||
issues.push({
|
||||
kind: 'requirement-outside-requirements',
|
||||
line: i + 1,
|
||||
header: trimmed,
|
||||
message:
|
||||
`Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
|
||||
'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function stripFencedCodeBlocksPreservingLines(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const output: string[] = [];
|
||||
let activeFence: { marker: '`' | '~'; length: number } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
||||
|
||||
if (!activeFence) {
|
||||
if (fenceMatch) {
|
||||
activeFence = {
|
||||
marker: fenceMatch[1][0] as '`' | '~',
|
||||
length: fenceMatch[1].length,
|
||||
};
|
||||
output.push('');
|
||||
} else {
|
||||
output.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push('');
|
||||
|
||||
if (isClosingFence(line, activeFence)) {
|
||||
activeFence = null;
|
||||
}
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function isClosingFence(
|
||||
line: string,
|
||||
activeFence: { marker: '`' | '~'; length: number }
|
||||
): boolean {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
|
||||
return Boolean(
|
||||
fenceMatch &&
|
||||
fenceMatch[1][0] === activeFence.marker &&
|
||||
fenceMatch[1].length >= activeFence.length
|
||||
);
|
||||
}
|
||||
@@ -80,11 +80,10 @@ export function getConfiguredToolsForProfileSync(projectPath: string): string[]
|
||||
/**
|
||||
* Detects if a single tool has profile/delivery drift against the desired state.
|
||||
*
|
||||
* Note: this function is intentionally scoped to "required artifacts missing"
|
||||
* and "artifacts that should not exist for the selected delivery mode".
|
||||
* Extra workflows that are outside the desired profile are handled by
|
||||
* `hasProjectConfigDrift`, which compares installed workflow IDs against
|
||||
* the desired workflow set.
|
||||
* This function covers:
|
||||
* - required artifacts missing for selected workflows
|
||||
* - artifacts that should not exist for the selected delivery mode
|
||||
* - artifacts for workflows that were deselected from the current profile
|
||||
*/
|
||||
export function hasToolProfileOrDeliveryDrift(
|
||||
projectPath: string,
|
||||
@@ -96,6 +95,7 @@ export function hasToolProfileOrDeliveryDrift(
|
||||
if (!tool?.skillsDir) return false;
|
||||
|
||||
const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);
|
||||
const desiredWorkflowSet = new Set<WorkflowId>(knownDesiredWorkflows);
|
||||
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
const shouldGenerateSkills = delivery !== 'commands';
|
||||
@@ -109,6 +109,16 @@ export function hasToolProfileOrDeliveryDrift(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Deselecting workflows in a profile should trigger sync.
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredWorkflowSet.has(workflow)) continue;
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
if (fs.existsSync(skillDir)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
@@ -127,6 +137,16 @@ export function hasToolProfileOrDeliveryDrift(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Deselecting workflows in a profile should trigger sync.
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredWorkflowSet.has(workflow)) continue;
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (!shouldGenerateCommands && adapter) {
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizeRequirementName,
|
||||
type RequirementBlock,
|
||||
} from './parsers/requirement-blocks.js';
|
||||
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
||||
import { Validator } from './validation/validator.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -223,6 +224,16 @@ export async function buildUpdatedSpec(
|
||||
targetContent = buildSpecSkeleton(specName, changeName);
|
||||
}
|
||||
|
||||
const structureIssues = findMainSpecStructureIssues(targetContent);
|
||||
if (structureIssues.length > 0) {
|
||||
const details = structureIssues
|
||||
.map(issue => `line ${issue.line}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(
|
||||
`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract requirements section and build name->block map
|
||||
const parts = extractRequirementsSection(targetContent);
|
||||
const nameToBlock = new Map<string, RequirementBlock>();
|
||||
|
||||
@@ -40,7 +40,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
|
||||
\`\`\`
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
@@ -52,7 +52,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in \`contextFiles\` from the apply instructions output.
|
||||
Read every file path listed under \`contextFiles\` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
@@ -197,7 +197,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
|
||||
\`\`\`
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema)
|
||||
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
@@ -209,7 +209,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in \`contextFiles\` from the apply instructions output.
|
||||
Read every file path listed under \`contextFiles\` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
@@ -85,7 +85,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
Display a table summarizing all changes:
|
||||
|
||||
\`\`\`
|
||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
||||
|---------------------|-----------|-------|---------|-----------|--------|
|
||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
||||
@@ -332,7 +332,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
Display a table summarizing all changes:
|
||||
|
||||
\`\`\`
|
||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
||||
|---------------------|-----------|-------|---------|-----------|--------|
|
||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
||||
|
||||
@@ -57,10 +57,10 @@ Depending on what the user brings, you might:
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
@@ -115,14 +115,14 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | \`specs/<capability>/spec.md\` |
|
||||
| Requirement changed | \`specs/<capability>/spec.md\` |
|
||||
| Design decision made | \`design.md\` |
|
||||
| Scope changed | \`proposal.md\` |
|
||||
| New work identified | \`tasks.md\` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | \`specs/<capability>/spec.md\` |
|
||||
| Requirement changed | \`specs/<capability>/spec.md\` |
|
||||
| Design decision made | \`design.md\` |
|
||||
| Scope changed | \`proposal.md\` |
|
||||
| New work identified | \`tasks.md\` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
@@ -228,7 +228,7 @@ User: A CLI tool that tracks local dev environments
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
@@ -353,10 +353,10 @@ Depending on what the user brings, you might:
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
@@ -413,14 +413,14 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | \`specs/<capability>/spec.md\` |
|
||||
| Requirement changed | \`specs/<capability>/spec.md\` |
|
||||
| Design decision made | \`design.md\` |
|
||||
| Scope changed | \`proposal.md\` |
|
||||
| New work identified | \`tasks.md\` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | \`specs/<capability>/spec.md\` |
|
||||
| Requirement changed | \`specs/<capability>/spec.md\` |
|
||||
| Design decision made | \`design.md\` |
|
||||
| Scope changed | \`proposal.md\` |
|
||||
| New work identified | \`tasks.md\` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
|
||||
@@ -477,21 +477,21 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| \`/opsx:propose\` | Create a change and generate all artifacts |
|
||||
| \`/opsx:explore\` | Think through problems before/during work |
|
||||
| \`/opsx:apply\` | Implement tasks from a change |
|
||||
| \`/opsx:archive\` | Archive a completed change |
|
||||
| Command | What it does |
|
||||
|-------------------|--------------------------------------------|
|
||||
| \`/opsx:propose\` | Create a change and generate all artifacts |
|
||||
| \`/opsx:explore\` | Think through problems before/during work |
|
||||
| \`/opsx:apply\` | Implement tasks from a change |
|
||||
| \`/opsx:archive\` | Archive a completed change |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| \`/opsx:new\` | Start a new change, step through artifacts one at a time |
|
||||
| \`/opsx:continue\` | Continue working on an existing change |
|
||||
| \`/opsx:ff\` | Fast-forward: create all artifacts at once |
|
||||
| \`/opsx:verify\` | Verify implementation matches artifacts |
|
||||
| Command | What it does |
|
||||
|--------------------|----------------------------------------------------------|
|
||||
| \`/opsx:new\` | Start a new change, step through artifacts one at a time |
|
||||
| \`/opsx:continue\` | Continue working on an existing change |
|
||||
| \`/opsx:ff\` | Fast-forward: create all artifacts at once |
|
||||
| \`/opsx:verify\` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
@@ -529,21 +529,21 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| \`/opsx:propose <name>\` | Create a change and generate all artifacts |
|
||||
| \`/opsx:explore\` | Think through problems (no code changes) |
|
||||
| \`/opsx:apply <name>\` | Implement tasks |
|
||||
| \`/opsx:archive <name>\` | Archive when done |
|
||||
| Command | What it does |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| \`/opsx:propose <name>\` | Create a change and generate all artifacts |
|
||||
| \`/opsx:explore\` | Think through problems (no code changes) |
|
||||
| \`/opsx:apply <name>\` | Implement tasks |
|
||||
| \`/opsx:archive <name>\` | Archive when done |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| \`/opsx:new <name>\` | Start a new change, step by step |
|
||||
| \`/opsx:continue <name>\` | Continue an existing change |
|
||||
| \`/opsx:ff <name>\` | Fast-forward: all artifacts at once |
|
||||
| \`/opsx:verify <name>\` | Verify implementation |
|
||||
| Command | What it does |
|
||||
|---------------------------|-------------------------------------|
|
||||
| \`/opsx:new <name>\` | Start a new change, step by step |
|
||||
| \`/opsx:continue <name>\` | Continue an existing change |
|
||||
| \`/opsx:ff <name>\` | Fast-forward: all artifacts at once |
|
||||
| \`/opsx:verify <name>\` | Verify implementation |
|
||||
|
||||
Try \`/opsx:propose\` to start your first change.
|
||||
\`\`\`
|
||||
|
||||
@@ -40,7 +40,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
|
||||
openspec instructions apply --change "<name>" --json
|
||||
\`\`\`
|
||||
|
||||
This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
|
||||
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
@@ -54,7 +54,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If tasks.md exists in contextFiles, read it
|
||||
- If \`contextFiles.tasks\` exists, read every file path in it
|
||||
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
@@ -93,7 +93,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If design.md exists in contextFiles:
|
||||
- If \`contextFiles.design\` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
@@ -209,7 +209,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
|
||||
openspec instructions apply --change "<name>" --json
|
||||
\`\`\`
|
||||
|
||||
This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
|
||||
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
@@ -223,7 +223,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If tasks.md exists in contextFiles, read it
|
||||
- If \`contextFiles.tasks\` exists, read every file path in it
|
||||
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
@@ -262,7 +262,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If design.md exists in contextFiles:
|
||||
- If \`contextFiles.design\` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
|
||||
+82
-2
@@ -176,6 +176,8 @@ export class UpdateCommand {
|
||||
const failedTools: Array<{ name: string; error: string }> = [];
|
||||
let removedCommandCount = 0;
|
||||
let removedSkillCount = 0;
|
||||
let removedDeselectedCommandCount = 0;
|
||||
let removedDeselectedSkillCount = 0;
|
||||
|
||||
for (const toolId of toolsToUpdate) {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
@@ -193,10 +195,12 @@ export class UpdateCommand {
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
// Use hyphen-based command references for OpenCode
|
||||
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
|
||||
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
|
||||
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
|
||||
await FileSystemUtils.writeFile(skillFile, skillContent);
|
||||
}
|
||||
|
||||
removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
|
||||
}
|
||||
|
||||
// Delete skill directories if delivery is commands-only
|
||||
@@ -214,6 +218,12 @@ export class UpdateCommand {
|
||||
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
|
||||
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
||||
}
|
||||
|
||||
removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(
|
||||
resolvedProjectPath,
|
||||
toolId,
|
||||
desiredWorkflows
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +257,12 @@ export class UpdateCommand {
|
||||
if (removedSkillCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
|
||||
}
|
||||
if (removedDeselectedCommandCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
|
||||
}
|
||||
if (removedDeselectedSkillCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
|
||||
}
|
||||
|
||||
// 12. Show onboarding message for newly configured tools from legacy upgrade
|
||||
if (newlyConfiguredTools.length > 0) {
|
||||
@@ -378,6 +394,36 @@ export class UpdateCommand {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes skill directories for workflows that are no longer selected in the active profile.
|
||||
* Returns the number of directories removed.
|
||||
*/
|
||||
private async removeUnselectedSkillDirs(
|
||||
skillsDir: string,
|
||||
desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
|
||||
): Promise<number> {
|
||||
const desiredSet = new Set(desiredWorkflows);
|
||||
let removed = 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredSet.has(workflow)) continue;
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
if (!dirName) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
try {
|
||||
if (fs.existsSync(skillDir)) {
|
||||
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows when delivery changed to skills-only.
|
||||
* Returns the number of files removed.
|
||||
@@ -408,6 +454,40 @@ export class UpdateCommand {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows that are no longer selected in the active profile.
|
||||
* Returns the number of files removed.
|
||||
*/
|
||||
private async removeUnselectedCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
const desiredSet = new Set(desiredWorkflows);
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredSet.has(workflow)) continue;
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and handle legacy OpenSpec artifacts.
|
||||
* Unlike init, update warns but continues if legacy files found in non-interactive mode.
|
||||
@@ -586,7 +666,7 @@ export class UpdateCommand {
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
// Use hyphen-based command references for OpenCode
|
||||
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
|
||||
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
|
||||
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
|
||||
await FileSystemUtils.writeFile(skillFile, skillContent);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
VALIDATION_MESSAGES
|
||||
} from './constants.js';
|
||||
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
|
||||
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
export class Validator {
|
||||
@@ -288,6 +289,15 @@ export class Validator {
|
||||
|
||||
private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
for (const structuralIssue of findMainSpecStructureIssues(content)) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path: 'file',
|
||||
line: structuralIssue.line,
|
||||
message: structuralIssue.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (spec.overview.length < MIN_PURPOSE_LENGTH) {
|
||||
issues.push({
|
||||
|
||||
+116
-19
@@ -1,10 +1,19 @@
|
||||
/**
|
||||
* Global configuration for telemetry state.
|
||||
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
|
||||
* Stores anonymous ID and notice-seen flag in the platform-appropriate config directory.
|
||||
*/
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
GLOBAL_CONFIG_DIR_NAME,
|
||||
GLOBAL_CONFIG_FILE_NAME,
|
||||
getGlobalConfigDir,
|
||||
} from '../core/global-config.js';
|
||||
|
||||
// Constants
|
||||
export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
|
||||
export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
|
||||
|
||||
export interface TelemetryConfig {
|
||||
anonymousId?: string;
|
||||
@@ -16,13 +25,112 @@ export interface GlobalConfig {
|
||||
[key: string]: unknown; // Preserve other fields
|
||||
}
|
||||
|
||||
type ConfigReadResult =
|
||||
| { status: 'missing' }
|
||||
| { status: 'ok'; config: GlobalConfig }
|
||||
| { status: 'invalid'; config: GlobalConfig };
|
||||
|
||||
function getConfigDir(): string {
|
||||
return getGlobalConfigDir();
|
||||
}
|
||||
|
||||
function getLegacyConfigPath(): string {
|
||||
return path.join(os.homedir(), '.config', CONFIG_DIR_NAME, CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
async function readConfigFile(configPath: string): Promise<ConfigReadResult> {
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return { status: 'ok', config: JSON.parse(content) as GlobalConfig };
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { status: 'missing' };
|
||||
}
|
||||
// If parse fails or another read error occurs, ignore the file.
|
||||
return { status: 'invalid', config: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigFile(configPath: string, config: GlobalConfig): Promise<void> {
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function hasMissingTelemetryFields(config: GlobalConfig): boolean {
|
||||
const telemetry = config.telemetry;
|
||||
return (
|
||||
!telemetry ||
|
||||
telemetry.anonymousId === undefined ||
|
||||
telemetry.noticeSeen === undefined
|
||||
);
|
||||
}
|
||||
|
||||
function mergeLegacyTelemetry(config: GlobalConfig, legacyConfig: GlobalConfig): GlobalConfig | undefined {
|
||||
const legacyTelemetry = legacyConfig.telemetry;
|
||||
if (!legacyTelemetry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentTelemetry = config.telemetry ?? {};
|
||||
const shouldMigrate =
|
||||
(currentTelemetry.anonymousId === undefined && legacyTelemetry.anonymousId !== undefined) ||
|
||||
(currentTelemetry.noticeSeen === undefined && legacyTelemetry.noticeSeen !== undefined);
|
||||
|
||||
if (!shouldMigrate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
telemetry: {
|
||||
...legacyTelemetry,
|
||||
...currentTelemetry,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateLegacyTelemetryConfig(
|
||||
configPath: string,
|
||||
config: GlobalConfig,
|
||||
persist: boolean,
|
||||
): Promise<GlobalConfig> {
|
||||
const legacyConfigPath = getLegacyConfigPath();
|
||||
if (path.resolve(configPath) === path.resolve(legacyConfigPath) || !hasMissingTelemetryFields(config)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const legacyRead = await readConfigFile(legacyConfigPath);
|
||||
if (legacyRead.status !== 'ok') {
|
||||
return config;
|
||||
}
|
||||
|
||||
const migrated = mergeLegacyTelemetry(config, legacyRead.config);
|
||||
if (!migrated) {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
try {
|
||||
await writeConfigFile(configPath, migrated);
|
||||
} catch {
|
||||
// Preserve telemetry for this run even if the one-time migration cannot be persisted.
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the global config file.
|
||||
* Uses ~/.config/openspec/config.json on all platforms.
|
||||
* Follows XDG Base Directory Specification and platform conventions.
|
||||
*
|
||||
* - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
|
||||
* - Unix/macOS fallback: ~/.config/openspec/
|
||||
* - Windows fallback: %APPDATA%/openspec/
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
const configDir = path.join(os.homedir(), '.config', 'openspec');
|
||||
return path.join(configDir, 'config.json');
|
||||
const configDir = getConfigDir();
|
||||
return path.join(configDir, CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,16 +139,9 @@ export function getConfigPath(): string {
|
||||
*/
|
||||
export async function readConfig(): Promise<GlobalConfig> {
|
||||
const configPath = getConfigPath();
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(content) as GlobalConfig;
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
// If parse fails or other error, return empty config
|
||||
return {};
|
||||
}
|
||||
const read = await readConfigFile(configPath);
|
||||
const config = read.status === 'ok' ? read.config : {};
|
||||
return migrateLegacyTelemetryConfig(configPath, config, read.status !== 'invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,10 +150,6 @@ export async function readConfig(): Promise<GlobalConfig> {
|
||||
*/
|
||||
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
|
||||
const configPath = getConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Read existing config and merge
|
||||
const existing = await readConfig();
|
||||
@@ -63,7 +160,7 @@ export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
await writeConfigFile(configPath, merged);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,10 +17,24 @@ import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
|
||||
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
|
||||
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
|
||||
const POSTHOG_HOST = 'https://edge.openspec.dev';
|
||||
const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
let anonymousId: string | null = null;
|
||||
|
||||
async function safeTelemetryFetch(url: string, options: RequestInit): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
} catch {
|
||||
// Silent failure - telemetry should never surface network noise
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if telemetry is enabled.
|
||||
*
|
||||
@@ -81,6 +95,12 @@ function getClient(): PostHog {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 1, // Send immediately, don't batch
|
||||
flushInterval: 0, // No timer-based flushing
|
||||
fetchRetryCount: 0,
|
||||
requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
|
||||
preloadFeatureFlags: false,
|
||||
disableRemoteConfig: true,
|
||||
disableSurveys: true,
|
||||
fetch: safeTelemetryFetch,
|
||||
});
|
||||
}
|
||||
return posthogClient;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { promises as fs, constants as fsConstants } from 'fs';
|
||||
import * as nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const fs = nodeFs.promises;
|
||||
const { constants: fsConstants } = nodeFs;
|
||||
|
||||
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
|
||||
let leftIndex = markerIndex - 1;
|
||||
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
|
||||
@@ -50,6 +53,23 @@ export class FileSystemUtils {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a canonical absolute path when the target exists.
|
||||
* Falls back to path.resolve() so callers can still produce a stable absolute path.
|
||||
*/
|
||||
static canonicalizeExistingPath(targetPath: string): string {
|
||||
try {
|
||||
// Prefer the native resolver so Windows short-path aliases are expanded.
|
||||
return nodeFs.realpathSync.native(targetPath);
|
||||
} catch {
|
||||
try {
|
||||
return nodeFs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static isWindowsBasePath(basePath: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ async function prepareFixture(fixtureName: string): Promise<string> {
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
function expectJsonOnlyOutput(result: Awaited<ReturnType<typeof runCLI>>) {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(() => JSON.parse(result.stdout)).not.toThrow();
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -71,6 +77,46 @@ describe('openspec CLI e2e basics', () => {
|
||||
expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps list --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['list', '--json'], { cwd: projectDir });
|
||||
expectJsonOnlyOutput(result);
|
||||
});
|
||||
|
||||
it('keeps schemas --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['schemas', '--json'], { cwd: projectDir });
|
||||
expectJsonOnlyOutput(result);
|
||||
});
|
||||
|
||||
it('keeps status --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['status', '--change', 'c1', '--json'], { cwd: projectDir });
|
||||
expectJsonOnlyOutput(result);
|
||||
});
|
||||
|
||||
it('keeps instructions --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['instructions', 'proposal', '--change', 'c1', '--json'], {
|
||||
cwd: projectDir,
|
||||
});
|
||||
expectJsonOnlyOutput(result);
|
||||
});
|
||||
|
||||
it('keeps instructions apply --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['instructions', 'apply', '--change', 'c1', '--json'], {
|
||||
cwd: projectDir,
|
||||
});
|
||||
expectJsonOnlyOutput(result);
|
||||
});
|
||||
|
||||
it('keeps templates --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['templates', '--json'], { cwd: projectDir });
|
||||
expectJsonOnlyOutput(result);
|
||||
});
|
||||
|
||||
it('returns an error for unknown items in the fixture', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir });
|
||||
|
||||
@@ -3,11 +3,14 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
import { FileSystemUtils } from '../../src/utils/file-system.js';
|
||||
|
||||
describe('artifact-workflow CLI commands', () => {
|
||||
let tempDir: string;
|
||||
let changesDir: string;
|
||||
|
||||
const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath);
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
|
||||
changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
@@ -110,6 +113,7 @@ describe('artifact-workflow CLI commands', () => {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changeName).toBe('json-change');
|
||||
@@ -131,6 +135,22 @@ describe('artifact-workflow CLI commands', () => {
|
||||
expect(result.stdout).toContain('All artifacts complete!');
|
||||
});
|
||||
|
||||
it('exits gracefully when no changes exist', async () => {
|
||||
const result = await runCLI(['status'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('No active changes');
|
||||
expect(result.stdout).toContain('openspec new change');
|
||||
});
|
||||
|
||||
it('exits gracefully with JSON when no changes exist', async () => {
|
||||
const result = await runCLI(['status', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toEqual([]);
|
||||
expect(json.message).toBe('No active changes.');
|
||||
});
|
||||
|
||||
it('errors when --change is missing and lists available changes', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
@@ -240,6 +260,7 @@ describe('artifact-workflow CLI commands', () => {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.artifactId).toBe('design');
|
||||
@@ -293,6 +314,7 @@ describe('artifact-workflow CLI commands', () => {
|
||||
it('outputs JSON mapping of templates', async () => {
|
||||
const result = await runCLI(['templates', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.proposal).toBeDefined();
|
||||
@@ -389,13 +411,74 @@ describe('artifact-workflow CLI commands', () => {
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
const expectedProposalPath = canonical(path.join(changesDir, 'json-apply', 'proposal.md'));
|
||||
const expectedSpecPath = canonical(path.join(changesDir, 'json-apply', 'specs', 'test-spec.md'));
|
||||
expect(json.changeName).toBe('json-apply');
|
||||
expect(json.schemaName).toBe('spec-driven');
|
||||
expect(json.state).toBe('ready');
|
||||
expect(json.contextFiles).toBeDefined();
|
||||
expect(typeof json.contextFiles).toBe('object');
|
||||
expect(json.contextFiles.proposal).toEqual([expectedProposalPath]);
|
||||
expect(json.contextFiles.specs).toEqual([expectedSpecPath]);
|
||||
});
|
||||
|
||||
it('resolves single-star glob artifacts consistently between status and apply', async () => {
|
||||
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-test');
|
||||
const templatesDir = path.join(schemaDir, 'templates');
|
||||
await fs.mkdir(templatesDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(schemaDir, 'schema.yaml'),
|
||||
`name: glob-test
|
||||
version: 1
|
||||
description: Test schema for single-star globs
|
||||
artifacts:
|
||||
- id: specs
|
||||
generates: specs/*/spec.md
|
||||
description: Nested specs
|
||||
template: spec.md
|
||||
requires: []
|
||||
apply:
|
||||
requires: [specs]
|
||||
instruction: Ready when specs exist.
|
||||
`
|
||||
);
|
||||
await fs.writeFile(path.join(templatesDir, 'spec.md'), '# Spec\n');
|
||||
|
||||
const changeDir = path.join(changesDir, 'single-star-glob');
|
||||
const specPath = path.join(changeDir, 'specs', 'single-star-glob', 'spec.md');
|
||||
await fs.mkdir(path.dirname(specPath), { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-test\n');
|
||||
await fs.writeFile(specPath, '# Nested spec\n');
|
||||
|
||||
const statusResult = await runCLI(['status', '--change', 'single-star-glob', '--json'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(statusResult.exitCode).toBe(0);
|
||||
const statusJson = JSON.parse(statusResult.stdout);
|
||||
expect(statusJson.artifacts).toEqual([
|
||||
{
|
||||
id: 'specs',
|
||||
outputPath: 'specs/*/spec.md',
|
||||
status: 'done',
|
||||
},
|
||||
]);
|
||||
|
||||
const applyResult = await runCLI(
|
||||
['instructions', 'apply', '--change', 'single-star-glob', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(applyResult.exitCode).toBe(0);
|
||||
const applyJson = JSON.parse(applyResult.stdout);
|
||||
const resolvedSpecPath = canonical(specPath);
|
||||
expect(applyJson.state).toBe('ready');
|
||||
expect(applyJson.missingArtifacts).toBeUndefined();
|
||||
expect(applyJson.contextFiles).toEqual({
|
||||
specs: [resolvedSpecPath],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows schema instruction from apply block', async () => {
|
||||
|
||||
@@ -561,6 +561,68 @@ new text
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should abort with a structural error when target spec hides requirements outside ## Requirements', async () => {
|
||||
const changeName = 'hidden-requirement-target';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'delta-target');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta-target');
|
||||
await fs.mkdir(mainSpecDir, { recursive: true });
|
||||
const malformedMain = `# delta-target Specification
|
||||
|
||||
## Purpose
|
||||
Delta target purpose.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: A
|
||||
The system SHALL do A.
|
||||
|
||||
#### Scenario: A works
|
||||
- **WHEN** foo
|
||||
- **THEN** bar
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Requirement: B
|
||||
The system SHALL do B.
|
||||
|
||||
#### Scenario: B works
|
||||
- **WHEN** baz
|
||||
- **THEN** qux`;
|
||||
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), malformedMain);
|
||||
|
||||
const deltaContent = `# Delta Target Changes
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: B
|
||||
The system SHALL do B differently.
|
||||
|
||||
#### Scenario: B changes
|
||||
- **WHEN** baz changes
|
||||
- **THEN** qux changes`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('delta-target: target spec is structurally invalid and cannot be updated until fixed:')
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Requirement header "### Requirement: B" appears outside the main ## Requirements section.')
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
|
||||
|
||||
const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
|
||||
expect(still).toBe(malformedMain);
|
||||
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(false);
|
||||
});
|
||||
|
||||
it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => {
|
||||
const changeName = 'rename-modify-new-header';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { FileSystemUtils } from '../../../src/utils/file-system.js';
|
||||
import { artifactOutputExists, resolveArtifactOutputs } from '../../../src/core/artifact-graph/outputs.js';
|
||||
|
||||
describe('artifact-graph/outputs', () => {
|
||||
let tempDir: string;
|
||||
|
||||
const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath);
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(os.tmpdir(), `openspec-outputs-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves a direct file path when it exists', () => {
|
||||
const filePath = path.join(tempDir, 'proposal.md');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'proposal.md')).toEqual([canonical(filePath)]);
|
||||
expect(artifactOutputExists(tempDir, 'proposal.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat a directory as a resolved literal artifact output', () => {
|
||||
const dirPath = path.join(tempDir, 'proposal.md');
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'proposal.md')).toEqual([]);
|
||||
expect(artifactOutputExists(tempDir, 'proposal.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves single-star nested globs to concrete files', () => {
|
||||
const nestedDir = path.join(tempDir, 'specs', 'change-a');
|
||||
const filePath = path.join(nestedDir, 'spec.md');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([canonical(filePath)]);
|
||||
expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches basename-sensitive glob patterns correctly', () => {
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
const matching = path.join(specsDir, 'foo-auth.md');
|
||||
const nonMatching = path.join(specsDir, 'bar-auth.md');
|
||||
fs.writeFileSync(matching, 'content');
|
||||
fs.writeFileSync(nonMatching, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'specs/foo*.md')).toEqual([canonical(matching)]);
|
||||
});
|
||||
|
||||
it('supports question-mark glob patterns', () => {
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
const matching = path.join(specsDir, 'a1.md');
|
||||
fs.writeFileSync(matching, 'content');
|
||||
fs.writeFileSync(path.join(specsDir, 'a10.md'), 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'specs/a?.md')).toEqual([canonical(matching)]);
|
||||
});
|
||||
|
||||
it('supports character class glob patterns', () => {
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
const aPath = path.join(specsDir, 'a.md');
|
||||
const bPath = path.join(specsDir, 'b.md');
|
||||
fs.writeFileSync(aPath, 'content');
|
||||
fs.writeFileSync(bPath, 'content');
|
||||
fs.writeFileSync(path.join(specsDir, 'c.md'), 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'specs/[ab].md')).toEqual([
|
||||
canonical(aPath),
|
||||
canonical(bPath),
|
||||
]);
|
||||
});
|
||||
|
||||
it('canonicalizes resolved paths when the change directory is accessed through an alias', () => {
|
||||
const rootDir = path.join(tempDir, 'workspace');
|
||||
const realChangeDir = path.join(rootDir, 'real-change');
|
||||
const aliasChangeDir = path.join(rootDir, 'alias-change');
|
||||
const specDir = path.join(realChangeDir, 'specs', 'change-a');
|
||||
const proposalPath = path.join(realChangeDir, 'proposal.md');
|
||||
const specPath = path.join(specDir, 'spec.md');
|
||||
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
fs.writeFileSync(proposalPath, 'content');
|
||||
fs.writeFileSync(specPath, 'content');
|
||||
fs.symlinkSync(realChangeDir, aliasChangeDir, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
expect(resolveArtifactOutputs(aliasChangeDir, 'proposal.md')).toEqual([
|
||||
canonical(proposalPath),
|
||||
]);
|
||||
expect(resolveArtifactOutputs(aliasChangeDir, 'specs/*/spec.md')).toEqual([
|
||||
canonical(specPath),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when no files match the artifact output', () => {
|
||||
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([]);
|
||||
expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(false);
|
||||
});
|
||||
|
||||
describe('glob-special characters in directory paths', () => {
|
||||
it('resolves glob patterns when directory contains parentheses', () => {
|
||||
const dirWithParens = path.join(tempDir, 'project (work)');
|
||||
const specDir = path.join(dirWithParens, 'specs', 'cap-a');
|
||||
const specFile = path.join(specDir, 'spec.md');
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
fs.writeFileSync(specFile, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(dirWithParens, 'specs/*/spec.md')).toEqual([
|
||||
canonical(specFile),
|
||||
]);
|
||||
expect(artifactOutputExists(dirWithParens, 'specs/*/spec.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves glob patterns when directory contains square brackets', () => {
|
||||
const dirWithBrackets = path.join(tempDir, '[projects]');
|
||||
const specDir = path.join(dirWithBrackets, 'specs', 'cap-a');
|
||||
const specFile = path.join(specDir, 'spec.md');
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
fs.writeFileSync(specFile, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(dirWithBrackets, 'specs/*/spec.md')).toEqual([
|
||||
canonical(specFile),
|
||||
]);
|
||||
expect(artifactOutputExists(dirWithBrackets, 'specs/*/spec.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves glob patterns when directory contains curly braces', () => {
|
||||
const dirWithBraces = path.join(tempDir, '{workspace}');
|
||||
const specDir = path.join(dirWithBraces, 'specs', 'cap-a');
|
||||
const specFile = path.join(specDir, 'spec.md');
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
fs.writeFileSync(specFile, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(dirWithBraces, 'specs/*/spec.md')).toEqual([
|
||||
canonical(specFile),
|
||||
]);
|
||||
expect(artifactOutputExists(dirWithBraces, 'specs/*/spec.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves glob patterns when directory contains brace expansion syntax', () => {
|
||||
const dirWithBraceExpansion = path.join(tempDir, 'project {a,b}');
|
||||
const specDir = path.join(dirWithBraceExpansion, 'specs', 'cap-a');
|
||||
const specFile = path.join(specDir, 'spec.md');
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
fs.writeFileSync(specFile, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(dirWithBraceExpansion, 'specs/*/spec.md')).toEqual([
|
||||
canonical(specFile),
|
||||
]);
|
||||
expect(artifactOutputExists(dirWithBraceExpansion, 'specs/*/spec.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves non-glob generates when directory contains special characters', () => {
|
||||
const dirWithParens = path.join(tempDir, 'project (work)');
|
||||
const proposalFile = path.join(dirWithParens, 'proposal.md');
|
||||
fs.mkdirSync(dirWithParens, { recursive: true });
|
||||
fs.writeFileSync(proposalFile, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(dirWithParens, 'proposal.md')).toEqual([
|
||||
canonical(proposalFile),
|
||||
]);
|
||||
expect(artifactOutputExists(dirWithParens, 'proposal.md')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,5 +87,66 @@ describe('available-tools', () => {
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].value).toBe('claude');
|
||||
});
|
||||
|
||||
it('should not detect GitHub Copilot from bare .github directory', async () => {
|
||||
// .github/ exists in virtually every GitHub repo (for workflows, issue templates, etc.)
|
||||
// A bare .github/ directory should NOT trigger Copilot detection
|
||||
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).not.toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('should detect GitHub Copilot when copilot-instructions.md exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
||||
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('should detect GitHub Copilot when .github/prompts directory exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.github', 'prompts'), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('should detect GitHub Copilot when .github/agents directory exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.github', 'agents'), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('should detect GitHub Copilot when .github/skills directory exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.github', 'skills'), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('should detect GitHub Copilot when copilot-setup-steps.yml exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.github', 'workflows'), { recursive: true });
|
||||
await fs.writeFile(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'), '');
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('should still use skillsDir detection for tools without detectionPaths', async () => {
|
||||
// Claude Code has no detectionPaths, so .claude/ directory should still work
|
||||
await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('claude');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js';
|
||||
import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js';
|
||||
import { auggieAdapter } from '../../../src/core/command-generation/adapters/auggie.js';
|
||||
import { bobAdapter } from '../../../src/core/command-generation/adapters/bob.js';
|
||||
import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js';
|
||||
import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js';
|
||||
import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js';
|
||||
@@ -183,6 +184,71 @@ describe('command-generation/adapters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('bobAdapter', () => {
|
||||
it('should have correct toolId', () => {
|
||||
expect(bobAdapter.toolId).toBe('bob');
|
||||
});
|
||||
|
||||
it('should generate correct file path', () => {
|
||||
const filePath = bobAdapter.getFilePath('explore');
|
||||
expect(filePath).toBe(path.join('.bob', 'commands', 'opsx-explore.md'));
|
||||
});
|
||||
|
||||
it('should generate correct file paths for different commands', () => {
|
||||
expect(bobAdapter.getFilePath('new')).toBe(path.join('.bob', 'commands', 'opsx-new.md'));
|
||||
expect(bobAdapter.getFilePath('bulk-archive')).toBe(path.join('.bob', 'commands', 'opsx-bulk-archive.md'));
|
||||
});
|
||||
|
||||
it('should format file with description and argument-hint frontmatter', () => {
|
||||
const output = bobAdapter.formatFile(sampleContent);
|
||||
expect(output).toContain('---\n');
|
||||
expect(output).toContain('description: Enter explore mode for thinking');
|
||||
expect(output).toContain('argument-hint: command arguments');
|
||||
expect(output).toContain('---\n\n');
|
||||
expect(output).toContain('This is the command body.\n\nWith multiple lines.');
|
||||
});
|
||||
|
||||
it('should transform colon command references to hyphen format', () => {
|
||||
const contentWithRefs: CommandContent = {
|
||||
...sampleContent,
|
||||
body: 'Run /opsx:apply to implement. Then use /opsx:verify.',
|
||||
};
|
||||
const output = bobAdapter.formatFile(contentWithRefs);
|
||||
expect(output).toContain('/opsx-apply');
|
||||
expect(output).toContain('/opsx-verify');
|
||||
expect(output).not.toContain('/opsx:apply');
|
||||
expect(output).not.toContain('/opsx:verify');
|
||||
});
|
||||
|
||||
it('should escape YAML special characters in description', () => {
|
||||
const contentWithSpecialChars: CommandContent = {
|
||||
...sampleContent,
|
||||
description: 'Fix: regression in "auth" feature',
|
||||
};
|
||||
const output = bobAdapter.formatFile(contentWithSpecialChars);
|
||||
expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"');
|
||||
});
|
||||
|
||||
it('should escape newlines in description', () => {
|
||||
const contentWithNewline: CommandContent = {
|
||||
...sampleContent,
|
||||
description: 'Line 1\nLine 2',
|
||||
};
|
||||
const output = bobAdapter.formatFile(contentWithNewline);
|
||||
expect(output).toContain('description: "Line 1\\nLine 2"');
|
||||
});
|
||||
|
||||
it('should handle empty description', () => {
|
||||
const contentEmptyDesc: CommandContent = {
|
||||
...sampleContent,
|
||||
description: '',
|
||||
};
|
||||
const output = bobAdapter.formatFile(contentEmptyDesc);
|
||||
expect(output).toContain('description: \n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clineAdapter', () => {
|
||||
it('should have correct toolId', () => {
|
||||
expect(clineAdapter.toolId).toBe('cline');
|
||||
@@ -444,7 +510,7 @@ describe('command-generation/adapters', () => {
|
||||
|
||||
it('should generate correct file path', () => {
|
||||
const filePath = opencodeAdapter.getFilePath('explore');
|
||||
expect(filePath).toBe(path.join('.opencode', 'command', 'opsx-explore.md'));
|
||||
expect(filePath).toBe(path.join('.opencode', 'commands', 'opsx-explore.md'));
|
||||
});
|
||||
|
||||
it('should format file with description frontmatter', () => {
|
||||
@@ -547,6 +613,28 @@ describe('command-generation/adapters', () => {
|
||||
expect(output).toContain('This is the command body.');
|
||||
});
|
||||
|
||||
it('should transform command references from colon to hyphen format', () => {
|
||||
const contentWithRefs: CommandContent = {
|
||||
...sampleContent,
|
||||
body: 'Run /opsx:apply to implement. Then /opsx:archive when done.',
|
||||
};
|
||||
|
||||
const output = piAdapter.formatFile(contentWithRefs);
|
||||
expect(output).toContain('/opsx-apply');
|
||||
expect(output).toContain('/opsx-archive');
|
||||
expect(output).not.toContain('/opsx:apply');
|
||||
});
|
||||
|
||||
it('should inject template arguments into the input section', () => {
|
||||
const contentWithInput: CommandContent = {
|
||||
...sampleContent,
|
||||
body: '**Input**: The argument after `/opsx:explore` is the topic.\n\n**Steps**\n1. Think.',
|
||||
};
|
||||
|
||||
const output = piAdapter.formatFile(contentWithInput);
|
||||
expect(output).toContain('**Provided arguments**: $@');
|
||||
});
|
||||
|
||||
it('should escape YAML special characters in description', () => {
|
||||
const contentWithSpecialChars: CommandContent = {
|
||||
...sampleContent,
|
||||
@@ -606,7 +694,7 @@ describe('command-generation/adapters', () => {
|
||||
it('All adapters use path.join for paths', () => {
|
||||
// Verify all adapters produce valid paths
|
||||
const adapters = [
|
||||
amazonQAdapter, antigravityAdapter, auggieAdapter, clineAdapter,
|
||||
amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter,
|
||||
codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter,
|
||||
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,
|
||||
iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter,
|
||||
|
||||
@@ -21,6 +21,12 @@ describe('command-generation/registry', () => {
|
||||
expect(adapter?.toolId).toBe('windsurf');
|
||||
});
|
||||
|
||||
it('should return Junie adapter for "junie"', () => {
|
||||
const adapter = CommandAdapterRegistry.get('junie');
|
||||
expect(adapter).toBeDefined();
|
||||
expect(adapter?.toolId).toBe('junie');
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered tool', () => {
|
||||
const adapter = CommandAdapterRegistry.get('unknown-tool');
|
||||
expect(adapter).toBeUndefined();
|
||||
@@ -54,6 +60,7 @@ describe('command-generation/registry', () => {
|
||||
expect(CommandAdapterRegistry.has('claude')).toBe(true);
|
||||
expect(CommandAdapterRegistry.has('cursor')).toBe(true);
|
||||
expect(CommandAdapterRegistry.has('windsurf')).toBe(true);
|
||||
expect(CommandAdapterRegistry.has('junie')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unregistered tools', () => {
|
||||
|
||||
@@ -544,6 +544,173 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding preservation', () => {
|
||||
const mockScriptPath = '/path/to/OpenSpecCompletion.ps1';
|
||||
const utf16leBom = Buffer.from([0xff, 0xfe]);
|
||||
const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]);
|
||||
|
||||
/**
|
||||
* Helper: write a file in UTF-16 LE with BOM, the way Windows PowerShell does.
|
||||
*/
|
||||
function writeUtf16LeFile(filePath: string, text: string): Promise<void> {
|
||||
const body = Buffer.from(text, 'utf16le');
|
||||
return fs.writeFile(filePath, Buffer.concat([utf16leBom, body]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: write a file in UTF-8 with BOM.
|
||||
*/
|
||||
function writeUtf8BomFile(filePath: string, text: string): Promise<void> {
|
||||
const body = Buffer.from(text, 'utf-8');
|
||||
return fs.writeFile(filePath, Buffer.concat([utf8Bom, body]));
|
||||
}
|
||||
|
||||
it('should preserve UTF-16 LE BOM when configuring profile', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n';
|
||||
await writeUtf16LeFile(profilePath, originalText);
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Read back raw bytes and verify BOM is preserved
|
||||
const raw = await fs.readFile(profilePath);
|
||||
expect(raw[0]).toBe(0xff);
|
||||
expect(raw[1]).toBe(0xfe);
|
||||
|
||||
// Decode and verify content is intact
|
||||
const content = raw.subarray(2).toString('utf16le');
|
||||
expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain(`. "${mockScriptPath}"`);
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
});
|
||||
|
||||
it('should preserve UTF-16 LE BOM when removing profile config', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const textWithBlock = [
|
||||
'. "C:\\Code\\profile.ps1"',
|
||||
'# OPENSPEC:START',
|
||||
'. "/path/to/OpenSpecCompletion.ps1"',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
].join('\n');
|
||||
await writeUtf16LeFile(profilePath, textWithBlock);
|
||||
|
||||
const result = await installer.removeProfileConfig();
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify BOM is preserved
|
||||
const raw = await fs.readFile(profilePath);
|
||||
expect(raw[0]).toBe(0xff);
|
||||
expect(raw[1]).toBe(0xfe);
|
||||
|
||||
// Verify content: original line kept, OpenSpec block removed
|
||||
const content = raw.subarray(2).toString('utf16le');
|
||||
expect(content).toContain('. "C:\\Code\\profile.ps1"');
|
||||
expect(content).not.toContain('# OPENSPEC:START');
|
||||
expect(content).not.toContain('# OPENSPEC:END');
|
||||
});
|
||||
|
||||
it('should preserve UTF-8 BOM when configuring profile', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
await writeUtf8BomFile(profilePath, '# My profile\n');
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const raw = await fs.readFile(profilePath);
|
||||
expect(raw[0]).toBe(0xef);
|
||||
expect(raw[1]).toBe(0xbb);
|
||||
expect(raw[2]).toBe(0xbf);
|
||||
|
||||
const content = raw.subarray(3).toString('utf-8');
|
||||
expect(content).toContain('# My profile');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
});
|
||||
|
||||
it('should skip UTF-16 BE profile and leave it unchanged', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
process.env.PROFILE = path.join(testHomeDir, 'custom-profile.ps1');
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
// Write a fake UTF-16 BE file (FE FF BOM + some bytes)
|
||||
const utf16beBom = Buffer.from([0xfe, 0xff]);
|
||||
const body = Buffer.from([0x00, 0x23]); // '#' in UTF-16 BE
|
||||
const originalBytes = Buffer.concat([utf16beBom, body]);
|
||||
await fs.writeFile(profilePath, originalBytes);
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// File should be untouched
|
||||
const raw = await fs.readFile(profilePath);
|
||||
expect(Buffer.compare(raw, originalBytes)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle plain UTF-8 files without BOM (no regression)', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
await fs.writeFile(profilePath, '# Plain UTF-8\n', 'utf-8');
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const raw = await fs.readFile(profilePath);
|
||||
// Should NOT have any BOM
|
||||
expect(raw[0]).not.toBe(0xff);
|
||||
expect(raw[0]).not.toBe(0xfe);
|
||||
expect(raw[0]).not.toBe(0xef);
|
||||
|
||||
const content = raw.toString('utf-8');
|
||||
expect(content).toContain('# Plain UTF-8');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
});
|
||||
|
||||
it('should round-trip UTF-16 LE through install → uninstall without corruption', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n';
|
||||
await writeUtf16LeFile(profilePath, originalText);
|
||||
|
||||
// Install adds the OpenSpec block
|
||||
const mockScript = '# completion script';
|
||||
await installer.install(mockScript);
|
||||
|
||||
// Verify the profile was modified but encoding preserved
|
||||
let raw = await fs.readFile(profilePath);
|
||||
expect(raw[0]).toBe(0xff);
|
||||
expect(raw[1]).toBe(0xfe);
|
||||
let content = raw.subarray(2).toString('utf16le');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain(originalText.trimEnd());
|
||||
|
||||
// Uninstall removes the OpenSpec block
|
||||
await installer.uninstall();
|
||||
|
||||
raw = await fs.readFile(profilePath);
|
||||
expect(raw[0]).toBe(0xff);
|
||||
expect(raw[1]).toBe(0xfe);
|
||||
content = raw.subarray(2).toString('utf16le');
|
||||
expect(content).not.toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const mockCompletionScript = `# PowerShell completion script
|
||||
$openspecCompleter = {}
|
||||
|
||||
@@ -536,6 +536,24 @@ describe('InitCommand - profile and detection features', () => {
|
||||
expect(await fileExists(skillFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should auto-cleanup legacy artifacts in non-interactive mode without --force', async () => {
|
||||
// Create legacy OpenCode command files (singular 'command' path)
|
||||
const legacyDir = path.join(testDir, '.opencode', 'command');
|
||||
await fs.mkdir(legacyDir, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyDir, 'opsx-propose.md'), 'legacy content');
|
||||
|
||||
// Run init in non-interactive mode without --force
|
||||
const initCommand = new InitCommand({ tools: 'opencode' });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// Legacy files should be cleaned up automatically
|
||||
expect(await fileExists(path.join(legacyDir, 'opsx-propose.md'))).toBe(false);
|
||||
|
||||
// New commands should be at the correct plural path
|
||||
const newCommandsDir = path.join(testDir, '.opencode', 'commands');
|
||||
expect(await directoryExists(newCommandsDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should preselect configured tools but not directory-detected tools in extend mode', async () => {
|
||||
// Simulate existing OpenSpec project (extend mode).
|
||||
await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });
|
||||
@@ -547,6 +565,7 @@ describe('InitCommand - profile and detection features', () => {
|
||||
|
||||
// Directory detected only (not configured with OpenSpec)
|
||||
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
||||
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
|
||||
|
||||
searchableMultiSelectMock.mockResolvedValue(['claude']);
|
||||
|
||||
@@ -569,6 +588,7 @@ describe('InitCommand - profile and detection features', () => {
|
||||
it('should preselect detected tools for first-time interactive setup', async () => {
|
||||
// First-time init: no openspec/ directory and no configured OpenSpec skills.
|
||||
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
||||
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
|
||||
|
||||
searchableMultiSelectMock.mockResolvedValue(['github-copilot']);
|
||||
|
||||
|
||||
@@ -335,6 +335,35 @@ ${OPENSPEC_MARKERS.end}`);
|
||||
const result = await detectLegacySlashCommands(testDir);
|
||||
expect(result.files).toContain('.continue/prompts/openspec-apply.prompt');
|
||||
});
|
||||
|
||||
it('should detect legacy OpenCode opsx-* command files', async () => {
|
||||
const dirPath = path.join(testDir, '.opencode', 'command');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(path.join(dirPath, 'opsx-propose.md'), 'content');
|
||||
|
||||
const result = await detectLegacySlashCommands(testDir);
|
||||
expect(result.files).toContain('.opencode/command/opsx-propose.md');
|
||||
});
|
||||
|
||||
it('should detect legacy OpenCode openspec-* command files', async () => {
|
||||
const dirPath = path.join(testDir, '.opencode', 'command');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(path.join(dirPath, 'openspec-new.md'), 'content');
|
||||
|
||||
const result = await detectLegacySlashCommands(testDir);
|
||||
expect(result.files).toContain('.opencode/command/openspec-new.md');
|
||||
});
|
||||
|
||||
it('should detect both opsx-* and openspec-* OpenCode command files', async () => {
|
||||
const dirPath = path.join(testDir, '.opencode', 'command');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(path.join(dirPath, 'opsx-propose.md'), 'content');
|
||||
await fs.writeFile(path.join(dirPath, 'openspec-new.md'), 'content');
|
||||
|
||||
const result = await detectLegacySlashCommands(testDir);
|
||||
expect(result.files).toContain('.opencode/command/opsx-propose.md');
|
||||
expect(result.files).toContain('.opencode/command/openspec-new.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLegacyStructureFiles', () => {
|
||||
@@ -1058,6 +1087,60 @@ ${OPENSPEC_MARKERS.end}`);
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle opencode opsx-* legacy files', () => {
|
||||
const detection = {
|
||||
configFiles: [],
|
||||
configFilesToUpdate: [],
|
||||
slashCommandDirs: [],
|
||||
slashCommandFiles: ['.opencode/command/opsx-propose.md'],
|
||||
hasOpenspecAgents: false,
|
||||
hasProjectMd: false,
|
||||
hasRootAgentsWithMarkers: false,
|
||||
hasLegacyArtifacts: true,
|
||||
};
|
||||
|
||||
const tools = getToolsFromLegacyArtifacts(detection);
|
||||
expect(tools).toContain('opencode');
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle opencode openspec-* legacy files', () => {
|
||||
const detection = {
|
||||
configFiles: [],
|
||||
configFilesToUpdate: [],
|
||||
slashCommandDirs: [],
|
||||
slashCommandFiles: ['.opencode/command/openspec-new.md'],
|
||||
hasOpenspecAgents: false,
|
||||
hasProjectMd: false,
|
||||
hasRootAgentsWithMarkers: false,
|
||||
hasLegacyArtifacts: true,
|
||||
};
|
||||
|
||||
const tools = getToolsFromLegacyArtifacts(detection);
|
||||
expect(tools).toContain('opencode');
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should deduplicate opencode when both opsx-* and openspec-* files exist', () => {
|
||||
const detection = {
|
||||
configFiles: [],
|
||||
configFilesToUpdate: [],
|
||||
slashCommandDirs: [],
|
||||
slashCommandFiles: [
|
||||
'.opencode/command/opsx-propose.md',
|
||||
'.opencode/command/openspec-new.md',
|
||||
],
|
||||
hasOpenspecAgents: false,
|
||||
hasProjectMd: false,
|
||||
hasRootAgentsWithMarkers: false,
|
||||
hasLegacyArtifacts: true,
|
||||
};
|
||||
|
||||
const tools = getToolsFromLegacyArtifacts(detection);
|
||||
expect(tools).toContain('opencode');
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not extract tools from config files only', () => {
|
||||
// Config files don't indicate which tools were configured
|
||||
// Only slash command dirs/files tell us which tools to upgrade
|
||||
|
||||
@@ -102,6 +102,70 @@ This is a test spec`;
|
||||
const parser = new MarkdownParser(content);
|
||||
expect(() => parser.parseSpec('test')).toThrow('must have a Requirements section');
|
||||
});
|
||||
|
||||
it('should ignore headings that appear inside fenced code blocks', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
This spec documents delta syntax with a fenced example.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Explain delta syntax
|
||||
The system SHALL allow quoted markdown examples without changing parsed structure.
|
||||
|
||||
\`\`\`markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Example
|
||||
The system SHALL ...
|
||||
\`\`\`
|
||||
|
||||
#### Scenario: reader follows the example
|
||||
- **WHEN** a reader reviews the documentation
|
||||
- **THEN** the fenced heading stays part of the example`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
expect(spec.requirements).toHaveLength(1);
|
||||
expect(spec.requirements[0].text).toBe(
|
||||
'The system SHALL allow quoted markdown examples without changing parsed structure.'
|
||||
);
|
||||
expect(spec.requirements[0].scenarios).toHaveLength(1);
|
||||
expect(spec.requirements[0].scenarios[0].rawText).toContain('- **WHEN** a reader reviews the documentation');
|
||||
});
|
||||
|
||||
it('should not treat fence-like lines with trailing content as closing fences', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
This spec includes a fence-like line with trailing content inside a fenced block.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Explain fence parsing
|
||||
The system SHALL keep fenced examples isolated until a real closing fence appears.
|
||||
|
||||
\`\`\`markdown
|
||||
\`\`\` still inside the example
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Example
|
||||
The system SHALL remain part of the example.
|
||||
\`\`\`
|
||||
|
||||
#### Scenario: reader follows the example
|
||||
- **WHEN** a reader reviews the documentation
|
||||
- **THEN** the parser ignores headings until the real closing fence`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
expect(spec.requirements).toHaveLength(1);
|
||||
expect(spec.requirements[0].scenarios).toHaveLength(1);
|
||||
expect(spec.requirements[0].scenarios[0].rawText).toContain('parser ignores headings until the real closing fence');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseChange', () => {
|
||||
@@ -288,4 +352,4 @@ Then result`;
|
||||
expect(spec.requirements[0].text).toBe('This is the actual requirement text.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,42 +30,42 @@ import {
|
||||
import { generateSkillContent } from '../../../src/core/shared/skill-generation.js';
|
||||
|
||||
const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getExploreSkillTemplate: '55a2a1afcba0af88c638e77e4e3870f65ed82c030b4a2056d39812ae13a616be',
|
||||
getExploreSkillTemplate: '3f73b4d7ab189ef6367fccc9d99308bee35c6a89dae4c8044582a01cb01b335b',
|
||||
getNewChangeSkillTemplate: '5989672758eccf54e3bb554ab97f2c129a192b12bbb7688cc1ffcf6bccb1ae9d',
|
||||
getContinueChangeSkillTemplate: 'f2e413f0333dfd6641cc2bd1a189273fdea5c399eecdde98ef528b5216f097b3',
|
||||
getApplyChangeSkillTemplate: '26e52e67693e93fbcdd40dcd3e20949c07ce019183d55a8149d0260c791cd7f4',
|
||||
getApplyChangeSkillTemplate: '6238712ba8cd2fd099c4f3bac13436f758fc6ac776fb8be19547f2b195240bfd',
|
||||
getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9',
|
||||
getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b',
|
||||
getOnboardSkillTemplate: '819a2d117ad1386187975686839cb0584b41484013d0ca6a6691f7a439a11a4a',
|
||||
getOpsxExploreCommandTemplate: '91353d9e8633a3a9ce7339e796f1283478fca279153f3807c92f4f8ece246b19',
|
||||
getOnboardSkillTemplate: 'c9e719a02d2ae7f74a0e978f9ad4e767c1921248a9e3724c3321c58a15c38ba9',
|
||||
getOpsxExploreCommandTemplate: 'b421b88c7a532385f7b1404736d7893eb35a05573b4a04a96f72379ac1bbf148',
|
||||
getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364',
|
||||
getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa',
|
||||
getOpsxApplyCommandTemplate: 'a9d631a07fcd832b67d263ff3800b98604ab8d378baf1b0d545907ef3affa3b5',
|
||||
getOpsxApplyCommandTemplate: 'f59cfe9482a1b29f64b9cd7396397991a2f00a5cb1abde4ab8b4757acf1678b9',
|
||||
getOpsxFfCommandTemplate: 'cdebe872cc8e0fcc25c8864b98ffd66a93484c0657db94bd1285b8113092702a',
|
||||
getArchiveChangeSkillTemplate: '6f8ca383fdb5a4eb9872aca81e07bf0ba7f25e4de8617d7a047ca914ca7f14b9',
|
||||
getBulkArchiveChangeSkillTemplate: 'b40fc44ea4e420bdc9c803985b10e5c091fc472cdfc69153b962be6be303bddd',
|
||||
getBulkArchiveChangeSkillTemplate: '8049897ce1ddb2ff6c0d4b72e22636f9ecfd083b5f2c2a30cf3bb1cb828a2f93',
|
||||
getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf',
|
||||
getVerifyChangeSkillTemplate: '63a213ba3b42af54a1cd56f5072234a03b265c3fe4a1da12cd6fbbef5ee46c4b',
|
||||
getVerifyChangeSkillTemplate: '40dde29051a0ba204295b74e49e87b6e9ff30c8b89ff0e791b4f955b4595de59',
|
||||
getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493',
|
||||
getOpsxOnboardCommandTemplate: '10052d05a4e2cdade7fdfa549b3444f7a92f55a39bf81ddd6af7e0e9e83a7302',
|
||||
getOpsxBulkArchiveCommandTemplate: 'eaaba253a950b9e681d8427a5cbc6b50c4e91137fb37fd2360859e08f63a0c14',
|
||||
getOpsxVerifyCommandTemplate: '9b4d3ca422553b7534764eb3a009da87a051612c5238e9baab294c7b1233e9a2',
|
||||
getOpsxOnboardCommandTemplate: 'fce531f952e939ee85a41848fc21e4cc720b0f3eb62737adc3a51ee6ad2dfc57',
|
||||
getOpsxBulkArchiveCommandTemplate: '0d77c82de43840a28c74f5181cb21e33b9a9d00454adf4bc92bdc9e69817d6f5',
|
||||
getOpsxVerifyCommandTemplate: 'd7c0444863faabb16abb091bc40ee56d985ae4bfa9a4db1e622ca8ba03c32fed',
|
||||
getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999',
|
||||
getOpsxProposeCommandTemplate: '41ad59b37eafd7a161bab5c6e41997a37368f9c90b194451295ede5cd42e4d46',
|
||||
getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d',
|
||||
};
|
||||
|
||||
const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
|
||||
'openspec-explore': '90463d00761417dfbca5cb09361adcf8bbdbbb24000b86dd03647869a4104479',
|
||||
'openspec-explore': '08e1ec9958eb04653707dd3e198c3fd69cf1b3acd3cf95a1022693cca83c60fc',
|
||||
'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f',
|
||||
'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960',
|
||||
'openspec-apply-change': 'a0084442b59be9d7e22a0382a279d470501e1ecf74bdd5347e169951c9be191c',
|
||||
'openspec-apply-change': '38ad2cb645827eda555f20e1ac9d483e1d75bae4c817c0669474aaa8c12c0421',
|
||||
'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937',
|
||||
'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde',
|
||||
'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f',
|
||||
'openspec-bulk-archive-change': 'a235a539f7729ab7669e45256905808789240ecd02820e044f4d0eef67b0c2ab',
|
||||
'openspec-verify-change': '30d07c6f7051965f624f5964db51844ec17c7dfd05f0da95281fe0ca73616326',
|
||||
'openspec-onboard': 'dbce376cf895f3fe4f63b4bce66d258c35b7b8884ac746670e5e35fabcefd255',
|
||||
'openspec-bulk-archive-change': '10477399bb07c7ba67f78e315bd68fb1901af8866720545baf4c62a6a679493b',
|
||||
'openspec-verify-change': 'b6dc1b87940be9d6125b834831c8619019aec9a9748995f72bf981b6f08b67f8',
|
||||
'openspec-onboard': 'c1444e026028210efd699110f7e9079bcb486d85ccf27f743213a81cb1084303',
|
||||
'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7',
|
||||
};
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ Old instructions content
|
||||
expect(exists).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('multi-tool support', () => {
|
||||
@@ -1567,7 +1568,7 @@ content
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should display extra workflows note when workflows outside profile exist', async () => {
|
||||
it('should remove workflows outside profile during update sync', async () => {
|
||||
// Set core profile (propose, explore, apply, archive)
|
||||
setMockConfig({
|
||||
featureFlags: {},
|
||||
@@ -1583,19 +1584,28 @@ content
|
||||
// Add a non-core workflow
|
||||
await fs.mkdir(path.join(skillsDir, 'openspec-new-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'openspec-new-change', 'SKILL.md'), 'old');
|
||||
const extraCommandFile = path.join(testDir, '.claude', 'commands', 'opsx', 'new.md');
|
||||
await fs.mkdir(path.dirname(extraCommandFile), { recursive: true });
|
||||
await fs.writeFile(extraCommandFile, 'old');
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should display note about extra workflows
|
||||
// Deselected workflow artifacts should be removed for both delivery surfaces.
|
||||
expect(await FileSystemUtils.fileExists(
|
||||
path.join(skillsDir, 'openspec-new-change', 'SKILL.md')
|
||||
)).toBe(false);
|
||||
expect(await FileSystemUtils.fileExists(extraCommandFile)).toBe(false);
|
||||
|
||||
// Should report deselected workflow cleanup.
|
||||
const calls = consoleSpy.mock.calls.map(call =>
|
||||
call.map(arg => String(arg)).join(' ')
|
||||
);
|
||||
const hasExtraNote = calls.some(call =>
|
||||
call.includes('extra workflows not in profile')
|
||||
const hasDeselectedRemovalNote = calls.some(call =>
|
||||
call.includes('deselected workflows')
|
||||
);
|
||||
expect(hasExtraNote).toBe(true);
|
||||
expect(hasDeselectedRemovalNote).toBe(true);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -1635,6 +1645,7 @@ content
|
||||
|
||||
// Create two unconfigured tool directories
|
||||
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
||||
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
|
||||
await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true });
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
@@ -247,6 +247,111 @@ Then authenticated`;
|
||||
expect(report.summary.errors).toBeGreaterThan(0);
|
||||
expect(report.issues.some(i => i.message.includes('Purpose'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should error on delta headers inside a main spec', async () => {
|
||||
const specContent = `# Test Specification
|
||||
|
||||
## Purpose
|
||||
This specification validates that stray delta headers are rejected in main specs.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: A
|
||||
The system SHALL do A.
|
||||
|
||||
#### Scenario: A works
|
||||
- **WHEN** foo
|
||||
- **THEN** bar
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: B
|
||||
The system SHALL do B.
|
||||
|
||||
#### Scenario: B works
|
||||
- **WHEN** baz
|
||||
- **THEN** qux`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const report = await new Validator().validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(false);
|
||||
expect(
|
||||
report.issues.some(i => i.level === 'ERROR' && i.message.includes('Main spec contains delta header'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should error on requirement headers that appear after the Requirements section ends', async () => {
|
||||
const specContent = `# Test Specification
|
||||
|
||||
## Purpose
|
||||
This specification validates that hidden requirements are rejected even without delta headers.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: A
|
||||
The system SHALL do A.
|
||||
|
||||
#### Scenario: A works
|
||||
- **WHEN** foo
|
||||
- **THEN** bar
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Requirement: B
|
||||
The system SHALL do B.
|
||||
|
||||
#### Scenario: B works
|
||||
- **WHEN** baz
|
||||
- **THEN** qux`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const report = await new Validator().validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(false);
|
||||
expect(
|
||||
report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore delta header examples inside fenced code blocks', async () => {
|
||||
const specContent = `# Test Specification
|
||||
|
||||
## Purpose
|
||||
This specification documents delta syntax without being flagged for quoted examples.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Explain delta syntax
|
||||
The system SHALL allow documentation specs to quote delta headers inside fenced code blocks.
|
||||
|
||||
\`\`\`markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Example
|
||||
The system SHALL ...
|
||||
\`\`\`
|
||||
|
||||
#### Scenario: reader follows the example
|
||||
- **WHEN** a reader reviews the documentation
|
||||
- **THEN** the quoted delta header remains an example only`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const report = await new Validator().validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.issues.some(i => i.message.includes('Main spec contains delta header'))).toBe(false);
|
||||
expect(report.issues.some(i => i.message.includes('appears outside the main ## Requirements section'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChange', () => {
|
||||
|
||||
@@ -2,14 +2,19 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MarkdownParser } from '../../src/core/parsers/markdown-parser.js';
|
||||
import {
|
||||
findMainSpecStructureIssues,
|
||||
stripFencedCodeBlocksPreservingLines,
|
||||
} from '../../src/core/parsers/spec-structure.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const specsRoot = path.join(projectRoot, 'openspec', 'specs');
|
||||
|
||||
const DELTA_HEADER_PATTERN = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/m;
|
||||
const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created by archiving change .*?\. Update Purpose after archive\./;
|
||||
const REQUIREMENT_HEADER_PATTERN = /^###\s+Requirement:/gm;
|
||||
|
||||
async function getSpecFiles(): Promise<string[]> {
|
||||
const entries = await fs.readdir(specsRoot, { withFileTypes: true });
|
||||
@@ -30,22 +35,29 @@ async function getSpecFiles(): Promise<string[]> {
|
||||
}
|
||||
|
||||
describe('source-of-truth specs normalization', () => {
|
||||
it('enforces required sections and bans archive placeholders/delta headers', async () => {
|
||||
it('enforces required sections and bans hidden requirements, placeholders, and delta headers', async () => {
|
||||
const files = await getSpecFiles();
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file, 'utf8');
|
||||
const relativeFile = path.relative(projectRoot, file);
|
||||
const structureIssues = findMainSpecStructureIssues(content);
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec(path.basename(path.dirname(file)));
|
||||
const rawRequirementCount =
|
||||
stripFencedCodeBlocksPreservingLines(content).match(REQUIREMENT_HEADER_PATTERN)?.length ?? 0;
|
||||
|
||||
expect(content, `${relativeFile} must include ## Purpose`).toMatch(/^## Purpose$/m);
|
||||
expect(content, `${relativeFile} must include ## Requirements`).toMatch(/^## Requirements$/m);
|
||||
expect(content, `${relativeFile} must not include archive placeholder purpose text`).not.toMatch(
|
||||
PURPOSE_PLACEHOLDER_PATTERN
|
||||
);
|
||||
expect(content, `${relativeFile} must not include delta headers in source-of-truth specs`).not.toMatch(
|
||||
DELTA_HEADER_PATTERN
|
||||
);
|
||||
expect(structureIssues, `${relativeFile} must not contain hidden requirements or delta headers`).toHaveLength(0);
|
||||
expect(
|
||||
spec.requirements.length,
|
||||
`${relativeFile} parsed requirement count must match visible requirement headers`
|
||||
).toBe(rawRequirementCount);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+138
-25
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
@@ -13,35 +14,60 @@ import {
|
||||
|
||||
describe('telemetry/config', () => {
|
||||
let tempDir: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
function restoreEnv(env: NodeJS.ProcessEnv): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, env);
|
||||
}
|
||||
|
||||
function defaultConfigDir(): string {
|
||||
return os.platform() === 'win32'
|
||||
? path.join(tempDir, 'appdata', 'openspec')
|
||||
: path.join(tempDir, '.config', 'openspec');
|
||||
}
|
||||
|
||||
function defaultConfigPath(): string {
|
||||
return path.join(defaultConfigDir(), 'config.json');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for tests
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Mock HOME/USERPROFILE to point to temp dir
|
||||
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
originalEnv = { ...process.env };
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
process.env.APPDATA = path.join(tempDir, 'appdata');
|
||||
process.env.HOME = tempDir;
|
||||
process.env.USERPROFILE = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore HOME/USERPROFILE
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
// Restore environment
|
||||
restoreEnv(originalEnv);
|
||||
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getConfigPath', () => {
|
||||
it('should return path to config.json in .config/openspec', () => {
|
||||
it('should return path to config.json in the default config directory', () => {
|
||||
const result = getConfigPath();
|
||||
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
|
||||
expect(result).toBe(defaultConfigPath());
|
||||
});
|
||||
|
||||
it('should use XDG_CONFIG_HOME when set', () => {
|
||||
const xdgConfigHome = path.join(tempDir, 'xdg-config');
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
const result = getConfigPath();
|
||||
|
||||
expect(result).toBe(path.join(xdgConfigHome, 'openspec', 'config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +78,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should load valid config from file', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
@@ -65,8 +91,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should return empty object for invalid JSON', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, '{ invalid json }');
|
||||
@@ -74,11 +100,86 @@ describe('telemetry/config', () => {
|
||||
const config = await readConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should migrate telemetry from legacy path when XDG_CONFIG_HOME is set', async () => {
|
||||
const xdgConfigHome = path.join(tempDir, 'xdg-config');
|
||||
const legacyConfigDir = path.join(tempDir, '.config', 'openspec');
|
||||
const legacyConfigPath = path.join(legacyConfigDir, 'config.json');
|
||||
const newConfigPath = path.join(xdgConfigHome, 'openspec', 'config.json');
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
fs.mkdirSync(legacyConfigDir, { recursive: true });
|
||||
fs.writeFileSync(legacyConfigPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'legacy-id', noticeSeen: true },
|
||||
}));
|
||||
|
||||
const config = await readConfig();
|
||||
|
||||
expect(config.telemetry).toEqual({ anonymousId: 'legacy-id', noticeSeen: true });
|
||||
expect(JSON.parse(fs.readFileSync(newConfigPath, 'utf-8')).telemetry).toEqual({
|
||||
anonymousId: 'legacy-id',
|
||||
noticeSeen: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not overwrite invalid new config during legacy migration', async () => {
|
||||
const xdgConfigHome = path.join(tempDir, 'xdg-config');
|
||||
const legacyConfigDir = path.join(tempDir, '.config', 'openspec');
|
||||
const legacyConfigPath = path.join(legacyConfigDir, 'config.json');
|
||||
const newConfigDir = path.join(xdgConfigHome, 'openspec');
|
||||
const newConfigPath = path.join(newConfigDir, 'config.json');
|
||||
const invalidJson = '{ invalid json }';
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
fs.mkdirSync(legacyConfigDir, { recursive: true });
|
||||
fs.writeFileSync(legacyConfigPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'legacy-id', noticeSeen: true },
|
||||
}));
|
||||
|
||||
fs.mkdirSync(newConfigDir, { recursive: true });
|
||||
fs.writeFileSync(newConfigPath, invalidJson);
|
||||
|
||||
const config = await readConfig();
|
||||
|
||||
expect(config.telemetry).toEqual({ anonymousId: 'legacy-id', noticeSeen: true });
|
||||
expect(fs.readFileSync(newConfigPath, 'utf-8')).toBe(invalidJson);
|
||||
});
|
||||
|
||||
it('should fill only missing telemetry fields from legacy config', async () => {
|
||||
const xdgConfigHome = path.join(tempDir, 'xdg-config');
|
||||
const legacyConfigDir = path.join(tempDir, '.config', 'openspec');
|
||||
const legacyConfigPath = path.join(legacyConfigDir, 'config.json');
|
||||
const newConfigDir = path.join(xdgConfigHome, 'openspec');
|
||||
const newConfigPath = path.join(newConfigDir, 'config.json');
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
fs.mkdirSync(legacyConfigDir, { recursive: true });
|
||||
fs.writeFileSync(legacyConfigPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'legacy-id', noticeSeen: true },
|
||||
legacyOnly: 'ignored',
|
||||
}));
|
||||
|
||||
fs.mkdirSync(newConfigDir, { recursive: true });
|
||||
fs.writeFileSync(newConfigPath, JSON.stringify({
|
||||
featureFlags: { existing: true },
|
||||
telemetry: { anonymousId: 'new-id' },
|
||||
}));
|
||||
|
||||
const config = await readConfig();
|
||||
|
||||
expect(config.featureFlags).toEqual({ existing: true });
|
||||
expect(config.telemetry).toEqual({ anonymousId: 'new-id', noticeSeen: true });
|
||||
expect((config as Record<string, unknown>).legacyOnly).toBeUndefined();
|
||||
expect(JSON.parse(fs.readFileSync(newConfigPath, 'utf-8')).telemetry).toEqual({
|
||||
anonymousId: 'new-id',
|
||||
noticeSeen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeConfig', () => {
|
||||
it('should create directory if it does not exist', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configDir = defaultConfigDir();
|
||||
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
@@ -86,7 +187,19 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should write config to file', async () => {
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('test-123');
|
||||
});
|
||||
|
||||
it('should write config to XDG_CONFIG_HOME when set', async () => {
|
||||
const xdgConfigHome = path.join(tempDir, 'xdg-config');
|
||||
const configPath = path.join(xdgConfigHome, 'openspec', 'config.json');
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
|
||||
|
||||
@@ -96,8 +209,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should preserve existing fields when updating', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
// Create initial config with other fields
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -116,8 +229,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should deep merge telemetry fields', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
// Create initial config
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -142,8 +255,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should return telemetry section from config', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
@@ -159,15 +272,15 @@ describe('telemetry/config', () => {
|
||||
it('should create telemetry config when none exists', async () => {
|
||||
await updateTelemetryConfig({ anonymousId: 'new-id' });
|
||||
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
const configPath = defaultConfigPath();
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('new-id');
|
||||
});
|
||||
|
||||
it('should merge with existing telemetry config', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('telemetry/index', () => {
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn<typeof globalThis, 'fetch'>>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test using UUID
|
||||
@@ -39,9 +40,10 @@ describe('telemetry/index', () => {
|
||||
|
||||
// Spy on console.log for notice tests
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
// Restore original env
|
||||
process.env = originalEnv;
|
||||
|
||||
@@ -52,6 +54,8 @@ describe('telemetry/index', () => {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
await shutdown();
|
||||
|
||||
// Restore all mocks
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -115,6 +119,86 @@ describe('telemetry/index', () => {
|
||||
|
||||
expect(PostHog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should construct PostHog with bounded silent-failure settings', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
expect(PostHog).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
host: 'https://edge.openspec.dev',
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
fetchRetryCount: 0,
|
||||
requestTimeout: 1000,
|
||||
preloadFeatureFlags: false,
|
||||
disableRemoteConfig: true,
|
||||
disableSurveys: true,
|
||||
fetch: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a synthetic success response when fetch throws a network error', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
|
||||
fetchSpy.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return a synthetic success response when fetch aborts', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
|
||||
fetchSpy.mockRejectedValueOnce(new DOMException('This operation was aborted', 'AbortError'));
|
||||
|
||||
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return a synthetic success response for non-2xx responses', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
|
||||
fetchSpy.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
|
||||
|
||||
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should pass through successful responses from fetch', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
|
||||
const expectedResponse = new Response(null, { status: 200 });
|
||||
fetchSpy.mockResolvedValueOnce(expectedResponse);
|
||||
|
||||
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
|
||||
|
||||
expect(response).toBe(expectedResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as nodeFs from 'fs';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -92,6 +93,22 @@ describe('FileSystemUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalizeExistingPath', () => {
|
||||
it('should prefer the native realpath resolver when available', async () => {
|
||||
const filePath = path.join(testDir, 'canonical.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const nativeSpy = vi.spyOn(nodeFs.realpathSync, 'native');
|
||||
|
||||
const resolved = FileSystemUtils.canonicalizeExistingPath(filePath);
|
||||
|
||||
expect(nativeSpy).toHaveBeenCalledWith(filePath);
|
||||
expect(resolved).toBe(nodeFs.realpathSync.native(filePath));
|
||||
|
||||
nativeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeFile', () => {
|
||||
it('should write content to file', async () => {
|
||||
const filePath = path.join(testDir, 'output.txt');
|
||||
|
||||
Reference in New Issue
Block a user