Compare commits

...
Author SHA1 Message Date
TabishB 4a723999c4 fix: prefer native realpath for canonical paths 2026-04-14 17:24:04 +10:00
TabishB 8945ed21ca fix: canonicalize workflow artifact paths 2026-04-14 17:01:41 +10:00
Alfred c0f29044f9 docs: clarify initiative-first workspace model (#969)
* docs: split workspace initiatives from repo-local changes

* docs: align roadmap and explore ux with initiatives
2026-04-13 12:20:24 +00:00
Tabish Bidiwale 7fe45ca330 Fix apply instructions for glob artifact outputs (#967)
* Fix glob artifact resolution in apply instructions

* Enforce file-only literal artifact outputs
2026-04-12 14:35:11 +00:00
Tabish Bidiwale c8e2072e3a fix: detect hidden requirements in main specs (#966)
* fix: detect hidden main spec requirements

* fix: tighten fenced code parsing
2026-04-12 13:59:10 +00:00
Tabish Bidiwale cd5e49346f docs: expand workspace planning explorations (#965)
* docs: add workspace ux explorations

* docs: update workspace architecture direction
2026-04-12 13:17:17 +00:00
Tabish Bidiwale a18d992fa1 fix: suppress ora spinner output when --json flag is used (#960)
When --json is passed, ora spinners wrote progress text to stderr, which
broke JSON parsing for AI agents that combine stdout+stderr. Conditionally
skip spinner creation in status, instructions, and templates commands.

Closes #957
2026-04-12 03:27:55 +00:00
Tabish Bidiwale 4df6a4889b fix: silence telemetry network errors in firewalled environments (#959)
* fix: silence telemetry network errors in firewalled environments

Wrap PostHog fetch with safeTelemetryFetch that catches all network
errors and non-2xx responses, returning a synthetic 204 so PostHog
never throws PostHogFetchNetworkError. Disable retries, remote config,
surveys, and feature flag preloading to eliminate extra network calls.
Add 1s request timeout. Surface telemetry opt-out docs earlier in
README, installation, and CLI reference.

Closes #895

* fix: clear CI env var in telemetry fetch tests

GitHub Actions sets CI=true which disables telemetry, causing PostHog
to never be instantiated and the fetch wrapper tests to fail.

* docs: add telemetry env vars to Environment Variables table

Addresses CodeRabbit review comment.

* docs: remove unnecessary firewall telemetry warnings

Telemetry now fails silently, so users don't need to proactively
disable it. The env vars are still documented in the reference table.

* docs: restore original config get/set examples in cli.md

These examples document how config works, not telemetry opt-out.
2026-04-12 03:21:11 +00:00
openspec-release-bot[bot]andgithub-actions[bot] 9b5007dbc3 Version Packages (#953)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-11 15:43:55 +00:00
Tabish Bidiwale cce787ec40 chore: add changeset for v1.3.0 (#952)
* Add changeset for new tool integrations and bug fixes

* Update changeset with IBM Bob support and pi.dev fix
2026-04-11 15:39:42 +00:00
94d651de8c feat: add support for IBM Bob coding assistant (#886)
* test: add comprehensive tests for Bob Shell adapter

- Add 7 tests covering toolId, file paths, formatting, and edge cases
- Include Bob Shell adapter in cross-platform path handling tests
- All 89 adapter tests now passing
- Ensures Bob Shell adapter works correctly with all 11 workflows

* feat: add Bob Shell adapter support

- Implement Bob Shell command adapter with YAML frontmatter
- Register adapter in CommandAdapterRegistry
- Export adapter from adapters/index.ts
- Add Bob Shell to AI_TOOLS configuration
- Generates commands in .bob/commands/opsx-<id>.md format
- Supports all 11 workflows via custom profile system

* docs: add Bob Shell to supported tools documentation

- Add Bob Shell to README.md supported tools list
- Update docs/supported-tools.md with Bob Shell entry
- Document .bob/commands/opsx-<id>.md command path pattern
- Note that Bob Shell uses commands, not Agent Skills spec

* docs: add Bob Shell support proposal and design documentation

- Add comprehensive proposal for Bob Shell integration
- Document command structure and file format
- Include implementation plan and success criteria
- Preserve change documentation for future reference

* chore: update dependencies and gitignore

- Update package-lock.json with latest dependencies
- Add .bob/ to gitignore (test output directory)

* chore: update gitignore to exclude .bob directory

* openspec change not needed for project, should be kept local

* Update reference from Bob Shell to IBM Bob Shell

* fix: transform command references and add argument-hint for Bob adapter

Bob derives command names from filenames (opsx-apply.md → /opsx-apply),
so body text referencing /opsx:apply is incorrect. Apply the same
transformToHyphenCommands rewrite that opencode.ts uses. Also add
argument-hint frontmatter to match peer adapters (auggie, codebuddy, etc).

---------

Co-authored-by: TabishB <tabishbidiwale@gmail.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-04-11 15:31:45 +00:00
Tabish Bidiwale 040e382d64 test: fix windows powershell ci (#951) 2026-04-11 15:17:00 +00:00
Tabish Bidiwale caafd7c9bf fix: pi.dev command reference transforms and template args passing (#950)
* fix: pi.dev prompt naming and template args passing (#912)

Fix two pi.dev integration bugs:
- Use colon-based filenames (opsx:explore.md) so CLI commands render as
  /opsx:explore instead of /opsx-explore
- Inject $@ into template body so user arguments are passed through

Adds getLegacyFilePaths to ToolCommandAdapter for migration-safe cleanup
of old hyphenated files during init/update.

* fix: pi.dev command references and template args passing (#912)

- Transform /opsx: references to /opsx- in Pi command bodies and skills,
  matching the hyphenated filename convention (same approach as OpenCode)
- Inject $@ into template body so user arguments are passed through

Pi uses the filename (minus .md) as the slash command name, so
opsx-propose.md becomes /opsx-propose. This keeps filenames
cross-platform safe while ensuring command references in the body
match the actual command names.
2026-04-11 15:00:46 +00:00
Tabish Bidiwale 144528257d fix: make completion install opt-in, fix PowerShell encoding corruption (#949)
* fix: make shell completion install opt-in and fix PowerShell profile encoding corruption (#948)

The postinstall hook silently modified users' shell profiles and corrupted
UTF-16 LE PowerShell profiles by forcing all reads/writes through UTF-8.
Now postinstall only prints a tip, and the PowerShell installer preserves
file encoding via BOM detection on read/write.

* Address review: skip profile on any read error, log warnings, clean up UTF-16 BE handling

- configureProfile: skip profile on any non-ENOENT error instead of falling
  through with empty content (could overwrite real profile)
- removeProfileConfig: log warning on unexpected read errors instead of
  silently swallowing
- detectEncoding: throw directly for UTF-16 BE instead of using sentinel value
- Add test for UTF-16 BE profile rejection
2026-04-11 13:58:24 +00:00
Irina ChichikovaandTabishB af0b3418d0 Add support for Junie from JetBrains tool and command generation (#853)
* Add support for Junie from JetBrains tool and command generation

* Expand Junie support to include `opsx` file patterns and update documentation accordingly

---------

Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-04-10 01:01:38 +00:00
7fd5417ed0 style: Fix formatting of user facing and agent facing diagrams and markdown tables (#892)
* style: Fix formatting of user facing and agent facing diagrams and markdown tables

* test: update template parity hashes for formatting changes

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-04-09 07:26:45 +00:00
mrack688andMarck 5ac1e12b83 feat: add Lingma IDE support to configuration (#864)
feat: add Lingma IDE support to configuration
feat: add Lingma IDE support to configuration

Co-authored-by: Marck <6992917@qq.com>
2026-04-09 06:25:27 +00:00
Gregor Albrecht fd7ad273c7 Fix formatting in concepts.md (#882) 2026-04-09 03:34:41 +00:00
Harry James Hall ea6f380fea feat: add ForgeCode tool support (#941) 2026-04-09 03:13:30 +00:00
XingxingandClaude Opus 4.6 765df47ad3 fix(init): prevent false GitHub Copilot auto-detection from bare .github/ directory (#917)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 03:13:04 +00:00
Alfred 64d476f8b9 ci: add merge_group support for merge queue (#918) 2026-04-05 06:56:59 +00:00
afdca0d5da fix(status): exit gracefully when no changes exist (#759)
* fix(status): exit gracefully when no changes exist (#714)

Extract `getAvailableChanges` as a public function from `validateChangeExists`
and use it in `statusCommand` to detect the no-changes case early. Returns a
friendly message (text and JSON modes) with exit code 0 instead of a fatal error.

Generated with Claude Code using claude-opus-4-6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix design risk description and proposal accuracy

Address CodeRabbit review feedback:
- Fix contradictory risk description in design.md (double-read happens
  when changes exist, not when they don't)
- Clarify in proposal.md that validateChangeExists was internally
  refactored to delegate to getAvailableChanges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(status): narrow catch in getAvailableChanges to ENOENT only

Return [] only when the changes directory doesn't exist (ENOENT).
Rethrow other errors (EACCES, etc.) so real filesystem issues
surface instead of being silently masked as "no changes".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-02-27 00:52:22 -08:00
61eb999f7c fix(opencode): use plural commands/ directory to match OpenCode convention (#760)
* fix(opencode): use plural `commands/` directory to match OpenCode convention

The OpenCode adapter was using `.opencode/command/` (singular) but OpenCode's
official documentation specifies `.opencode/commands/` (plural). This aligns
with every other adapter in the codebase. Legacy cleanup updated to detect
old singular-path artifacts. Fixes #748.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(legacy): detect both opsx-* and openspec-* patterns, auto-cleanup in CI

- Extend LegacySlashCommandPattern.pattern to accept string | string[]
- OpenCode legacy entry now detects both opsx-*.md and openspec-*.md
- Auto-cleanup legacy artifacts in non-interactive mode instead of
  aborting with exit 1 (safe: slash commands are OpenSpec-managed,
  config cleanup only removes markers)
- Add 7 tests (6 legacy detection + 1 non-interactive init)
- Update spec with array pattern support and auto-cleanup scenario

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update task description to reflect dual-pattern support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-02-27 00:08:22 -08:00
3d3bf96061 docs: fix openspec status examples in cli.md (#761)
* docs: fix `openspec status` examples in cli.md to match actual CLI output

The text and JSON output examples for the status command used incorrect
field names, indicators, and structure. Updated to match real CLI output,
validated against a test project with partial artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove spec change and changeset for docs-only fix

Per reviewer feedback — docs fixes don't need a spec change or
version bump.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-02-26 23:37:28 -08:00
JabinGP d199dfa407 docs: fix docs/concepts nested code-block format (#763) 2026-02-26 10:15:27 +00:00
Tabish Bidiwale d7d186088e docs: realign defaults, profile workflows, and tool references (#746)
* docs: realign defaults, workflows, and tool references

* docs: resolve Trae wording and opsx diagram alignment

* chore: ignore codex workspace directory
2026-02-23 18:27:23 -08:00
openspec-release-bot[bot]andgithub-actions[bot] 6a3a1263fe Version Packages (#751)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-23 15:43:08 -08:00
Tabish Bidiwale 1e94443a35 Add changeset for profiles, Pi, Kiro, and bug fixes (#747) 2026-02-23 15:37:30 -08:00
Tabish Bidiwale a0608d0bab Sync update to prune deselected workflows (#741) 2026-02-22 05:35:01 -08:00
86 changed files with 5841 additions and 575 deletions
+7 -5
View File
@@ -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: |
+6
View File
@@ -153,3 +153,9 @@ result
# OpenCode
.opencode/
opencode.json
# Codex
.codex/
# Bob
.bob/
+43
View File
@@ -1,5 +1,48 @@
# @fission-ai/openspec
## 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
+8 -9
View File
@@ -36,7 +36,7 @@ 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.
@@ -46,17 +46,14 @@ Our philosophy:
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 +98,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
+181 -5
View File
@@ -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.
+367
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+7 -7
View File
@@ -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.
+13 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.1.1",
"version": "1.3.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+8 -72
View File
@@ -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`);
}
}
+1 -1
View File
@@ -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
+19 -77
View File
@@ -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();
}
+21 -18
View File
@@ -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>`
+25 -3
View File
@@ -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;
}
}
+7 -4
View File
@@ -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;
}
}
+1
View File
@@ -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 {
+10 -5
View File
@@ -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);
+43
View File
@@ -0,0 +1,43 @@
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[] {
const fullPattern = path.join(changeDir, generates);
if (!isGlobPattern(generates)) {
try {
return fs.statSync(fullPattern).isFile()
? [FileSystemUtils.canonicalizeExistingPath(fullPattern)]
: [];
} catch {
return [];
}
}
const normalizedPattern = FileSystemUtils.toPosixPath(fullPattern);
const matches = fg
.sync(normalizedPattern, { onlyFiles: 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;
}
+2 -29
View File
@@ -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);
}
+16 -2
View File
@@ -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 {
+22 -1
View File
@@ -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)}
`;
},
};
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
+13 -4
View File
@@ -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;
}
}
}
+55 -2
View File
@@ -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;
}
}
}
+117
View File
@@ -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
);
}
+25 -5
View File
@@ -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);
+11
View File
@@ -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>();
+4 -4
View File
@@ -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 |
+25 -25
View File
@@ -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?"
+24 -24
View File
@@ -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
View File
@@ -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);
}
+10
View File
@@ -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({
+20
View File
@@ -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;
+21 -1
View File
@@ -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('\\');
}
+46
View File
@@ -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 });
+82
View File
@@ -110,6 +110,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 +132,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 +257,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 +311,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 +408,76 @@ describe('artifact-workflow CLI commands', () => {
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
const json = JSON.parse(result.stdout);
const expectedProposalPath = await fs.realpath(path.join(changesDir, 'json-apply', 'proposal.md'));
const expectedSpecPath = await fs.realpath(
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 = await fs.realpath(specPath);
expect(applyJson.state).toBe('ready');
expect(applyJson.missingArtifacts).toBeUndefined();
expect(applyJson.contextFiles).toEqual({
specs: [resolvedSpecPath],
});
});
it('shows schema instruction from apply block', async () => {
+62
View File
@@ -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);
+106
View File
@@ -0,0 +1,106 @@
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 { artifactOutputExists, resolveArtifactOutputs } from '../../../src/core/artifact-graph/outputs.js';
describe('artifact-graph/outputs', () => {
let tempDir: string;
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([fs.realpathSync(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([fs.realpathSync(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([fs.realpathSync(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([fs.realpathSync(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([
fs.realpathSync(aPath),
fs.realpathSync(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([
fs.realpathSync(proposalPath),
]);
expect(resolveArtifactOutputs(aliasChangeDir, 'specs/*/spec.md')).toEqual([
fs.realpathSync(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);
});
});
+61
View File
@@ -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');
});
});
});
+90 -2
View File
@@ -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 = {}
+20
View File
@@ -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']);
+83
View File
@@ -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
+65 -1
View File
@@ -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',
};
+16 -5
View File
@@ -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');
+105
View File
@@ -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', () => {
+17 -5
View File
@@ -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);
}
});
});
+85 -1
View File
@@ -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', () => {
+18 -1
View File
@@ -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');