mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8faf504363 | ||
|
|
17d1e5db3f | ||
|
|
3f5a66d3e4 | ||
|
|
c08fbc1ba0 | ||
|
|
938d03be9a | ||
|
|
19ccaabfc7 | ||
|
|
2e382b9898 | ||
|
|
b5a7d096f0 | ||
|
|
c54079a0cd | ||
|
|
1050e57ae4 | ||
|
|
17d7e59343 | ||
|
|
4758c5c68d | ||
|
|
c4b0826da7 | ||
|
|
537e6078b7 | ||
|
|
5439ab0833 | ||
|
|
9b6a763eb8 | ||
|
|
d32e50fe36 | ||
|
|
8386b91a71 | ||
|
|
8f9c3c7d0b | ||
|
|
9cdb0743f2 | ||
|
|
4e93d7a881 | ||
|
|
c4b6be41c1 | ||
|
|
a66580735c | ||
|
|
fb1d37e56e | ||
|
|
cf0de5e569 |
+1
-1
@@ -149,4 +149,4 @@ CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
# Pnpm
|
||||
.pnpm-store/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c08fbc1: Add new AI tool integrations and enhancements:
|
||||
|
||||
- **feat(iflow-cli)**: Add iFlow-cli integration with slash command support and documentation
|
||||
- **feat(init)**: Add IDE restart instruction after init to inform users about slash command availability
|
||||
**feat(antigravity)**: Add Antigravity slash command support
|
||||
- **fix**: Generate TOML commands for Qwen Code (fixes #293)
|
||||
- Clarify scaffold proposal documentation and enhance proposal guidelines
|
||||
- Update proposal guidelines to emphasize design-first approach before implementation
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4758c5c: Add support for new AI tools with native slash command integration
|
||||
|
||||
- **Gemini CLI**: Add native TOML-based slash command support for Gemini CLI with `.gemini/commands/openspec/` integration
|
||||
- **RooCode**: Add RooCode integration with configurator, slash commands, and templates
|
||||
- **Cline**: Fix Cline to use workflows instead of rules for slash commands (`.clinerules/workflows/` paths)
|
||||
- **Documentation**: Update documentation to reflect new integrations and workflow changes
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8386b91: Add support for new AI assistants and configuration improvements
|
||||
|
||||
- feat: add Qwen Code support with slash command integration
|
||||
- feat: add $ARGUMENTS support to apply slash command for dynamic variable passing
|
||||
- feat: add Qoder CLI support to configuration and documentation
|
||||
- feat: add CoStrict AI assistant support
|
||||
- fix: recreate missing openspec template files in extend mode
|
||||
- fix: prevent false 'already configured' detection for tools
|
||||
- fix: use change-id as fallback title instead of "Untitled Change"
|
||||
- docs: add guidance for populating project-level context
|
||||
- docs: add Crush to supported AI tools in README
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -92,17 +92,24 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
|------|----------|
|
||||
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
|
||||
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
|
||||
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
|
||||
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
|
||||
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
|
||||
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
|
||||
| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) |
|
||||
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
|
||||
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
|
||||
| **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) |
|
||||
| **Antigravity** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.agent/workflows/`) |
|
||||
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
|
||||
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
|
||||
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
|
||||
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
|
||||
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
|
||||
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
|
||||
| **iFlow (iflow-cli)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) |
|
||||
|
||||
|
||||
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
|
||||
@@ -112,7 +119,7 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`.
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
| Amp • Jules • Gemini CLI • Others |
|
||||
| Amp • Jules • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
@@ -143,7 +150,7 @@ openspec init
|
||||
```
|
||||
|
||||
**What happens during initialization:**
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
|
||||
- A new `openspec/` directory structure is created in your project
|
||||
|
||||
@@ -153,6 +160,17 @@ openspec init
|
||||
- If your coding assistant doesn't surface the new slash commands right away, restart it. Slash commands are loaded at startup,
|
||||
so a fresh launch ensures they appear
|
||||
|
||||
### Optional: Populate Project Context
|
||||
|
||||
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
|
||||
|
||||
```text
|
||||
Populate your project context:
|
||||
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"
|
||||
```
|
||||
|
||||
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
|
||||
|
||||
### Create Your First Change
|
||||
|
||||
Here's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.
|
||||
@@ -218,7 +236,7 @@ Or run the command yourself in terminal:
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
```
|
||||
|
||||
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder, RooCode) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
|
||||
## Command Reference
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
## Why
|
||||
Google is rolling out Antigravity, a Windsurf-derived IDE that discovers workflows from `.agent/workflows/*.md`. Today OpenSpec can only scaffold slash commands for Windsurf directories, so Antigravity users cannot run the proposal/apply/archive flows from the IDE.
|
||||
|
||||
## What Changes
|
||||
- Add Antigravity as a selectable native tool in `openspec init` so it creates `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter containing only a `description` field plus the standard OpenSpec-managed body.
|
||||
- Ensure `openspec update` refreshes the body of any existing Antigravity workflows inside `.agent/workflows/` without creating missing files, mirroring the Windsurf behavior.
|
||||
- Share e2e/template coverage confirming the generator writes the proper directory, filename casing, and frontmatter format so Antigravity picks up the workflows.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `specs/cli-init`, `specs/cli-update`
|
||||
- Expected code: CLI init/update tool registries, slash-command templates, associated tests
|
||||
@@ -0,0 +1,9 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Antigravity
|
||||
- **WHEN** the user selects Antigravity during initialization
|
||||
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
|
||||
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
|
||||
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
|
||||
@@ -0,0 +1,8 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
|
||||
|
||||
#### Scenario: Updating slash commands for Antigravity
|
||||
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
|
||||
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
|
||||
@@ -0,0 +1,12 @@
|
||||
## 1. CLI init support
|
||||
- [x] 1.1 Surface Antigravity in the native-tool picker (interactive + `--tools`) so it toggles alongside other IDEs.
|
||||
- [x] 1.2 Generate `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter restricted to a single `description` field for each stage and wrap the body in OpenSpec markers.
|
||||
- [x] 1.3 Confirm workspace scaffolding covers missing directory creation and re-run scenarios so repeated init refreshes the managed block.
|
||||
|
||||
## 2. CLI update support
|
||||
- [x] 2.1 Detect existing Antigravity workflow files during `openspec update` and refresh only the managed body, skipping creation when files are missing.
|
||||
- [x] 2.2 Ensure update logic preserves the `description` frontmatter block exactly as written by init, including case and spacing, and refreshes body templates alongside other tools.
|
||||
|
||||
## 3. Templates and tests
|
||||
- [x] 3.1 Add shared template entries for Antigravity that reuse the Windsurf copy but target `.agent/workflows` plus the description-only frontmatter requirement.
|
||||
- [x] 3.2 Expand automated coverage (unit or integration) verifying init and update produce the expected file paths and frontmatter + body markers for Antigravity.
|
||||
@@ -2,9 +2,9 @@
|
||||
Manual setup for new changes leads to formatting mistakes in spec deltas and slows agents who must recreate the same file skeletons for every proposal. A built-in scaffold command will generate compliant templates so assistants can focus on the change content instead of structure.
|
||||
|
||||
## What Changes
|
||||
- Add an `openspec scaffold <change-id>` CLI command that creates a change directory with validated `proposal.md`, `tasks.md`, and spec delta templates.
|
||||
- Update CLI documentation and quick-reference guidance so agents discover the scaffold workflow before drafting files manually.
|
||||
- Add automated coverage (unit/integ tests) to ensure the command respects existing naming rules and generated Markdown passes validation.
|
||||
- Add an `openspec scaffold <change-id>` CLI command that creates a change directory (if it does not already exist) with validated `proposal.md`, `tasks.md`, and spec delta templates.
|
||||
- Update CLI documentation and quick-reference guidance so agents discover the scaffold workflow before drafting files manually, including reminders on when to create spec deltas.
|
||||
- Add automated coverage (unit/integ tests) to ensure the command respects naming rules, copies templates correctly, fails for existing directories, and produces output that passes `openspec validate --strict` untouched.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `specs/cli-scaffold`
|
||||
|
||||
@@ -9,13 +9,13 @@ The CLI SHALL expose an `openspec scaffold <change-id>` command that validates t
|
||||
- **AND** exit with code 0 after successful scaffolding
|
||||
|
||||
### Requirement: Change Directory Structure
|
||||
The scaffold command SHALL create the standard change workspace with proposal, tasks, optional design, and delta directories laid out according to OpenSpec conventions.
|
||||
The scaffold command SHALL create the standard change workspace (if it does not already exist) with proposal, tasks, optional design, and `specs/` directories laid out according to OpenSpec conventions.
|
||||
|
||||
#### Scenario: Generating change workspace
|
||||
- **WHEN** scaffolding a new change with id `add-user-notifications`
|
||||
- **THEN** create `openspec/changes/add-user-notifications/`
|
||||
- **AND** generate `proposal.md`, `tasks.md`, and `design.md` (commented placeholder content) in that directory when missing
|
||||
- **AND** create `openspec/changes/add-user-notifications/specs/` ready for capability-specific deltas
|
||||
- **THEN** create `openspec/changes/add-user-notifications/` if it does not exist
|
||||
- **AND** copy the default template bundle (proposal, tasks, design placeholders) into that directory in a single operation
|
||||
- **AND** create an empty `openspec/changes/add-user-notifications/specs/` directory ready for capability-specific deltas that will be authored later
|
||||
|
||||
### Requirement: Template Content Guidance
|
||||
The scaffold command SHALL populate generated Markdown files with OpenSpec-compliant templates so authors can copy, edit, and pass validation without reformatting.
|
||||
@@ -25,15 +25,7 @@ The scaffold command SHALL populate generated Markdown files with OpenSpec-compl
|
||||
- **THEN** include the `## Why`, `## What Changes`, and `## Impact` headings with placeholder guidance text
|
||||
- **AND** ensure `tasks.md` starts with `## 1. Implementation` and numbered checklist items using `- [ ]` syntax
|
||||
- **AND** annotate optional sections (like `design.md`) with inline TODO comments so users understand when to keep or delete them
|
||||
|
||||
### Requirement: Delta Spec Creation
|
||||
The scaffold command SHALL create at least one capability delta file with correctly formatted requirement and scenario placeholders that guide authors to enter the actual behavior.
|
||||
|
||||
#### Scenario: Creating spec delta skeleton
|
||||
- **WHEN** scaffolding a change and the capability `cli-scaffold` is provided interactively or via flags
|
||||
- **THEN** generate `openspec/changes/add-user-notifications/specs/cli-scaffold/spec.md`
|
||||
- **AND** include `## ADDED Requirements` with at least one `### Requirement:` block and matching `#### Scenario:` entries that remind the author to replace placeholder text
|
||||
- **AND** ensure the generated delta passes `openspec validate add-user-notifications --strict` until the author edits it
|
||||
- **AND** include a short reminder inside `specs/README.md` (or similar) instructing authors to add deltas once they know the affected capability
|
||||
|
||||
### Requirement: Idempotent Execution
|
||||
The scaffold command SHALL be safe to rerun, preserving user edits while filling in any missing managed sections.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
## 1. CLI scaffolding command
|
||||
- [ ] 1.1 Register an `openspec scaffold` command in the CLI entrypoint with `change-id` argument validation.
|
||||
- [ ] 1.2 Implement generator logic that creates the change directory structure plus default `proposal.md`, `tasks.md`, and delta spec skeletons without overwriting existing populated files.
|
||||
- [ ] 1.2 Implement generator logic that copies the default change template bundle (`proposal.md`, `tasks.md`, optional `design.md`, `specs/README.md`) into `openspec/changes/<id>/`, creating the directory tree in a single pass.
|
||||
- [ ] 1.3 Detect when `openspec/changes/<id>/` already exists and exit with a clear error instead of overwriting user files.
|
||||
|
||||
## 2. Templates and documentation
|
||||
- [ ] 2.1 Surface copy/paste templates and scaffold usage in the top-level quick reference for `openspec/AGENTS.md`.
|
||||
- [ ] 2.2 Refresh other CLI docs (`docs/`, README) to mention the scaffold workflow and link to instructions.
|
||||
- [ ] 2.1 Update `openspec/AGENTS.md` quick reference so agents see `openspec scaffold` before drafting files manually.
|
||||
- [ ] 2.2 Refresh CLI docs/README/help text to mention the scaffold workflow, template bundle contents, and when to add spec deltas manually.
|
||||
|
||||
## 3. Test coverage
|
||||
- [ ] 3.1 Add unit tests covering name validation, file generation, and idempotent reruns.
|
||||
- [ ] 3.2 Add integration coverage ensuring generated files pass `openspec validate --strict` without manual edits.
|
||||
- [ ] 3.1 Add unit tests covering name validation, template copying, and existing-directory failures.
|
||||
- [ ] 3.2 Add integration coverage ensuring a freshly scaffolded change (without deltas) passes `openspec validate --strict` until the author customizes it.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The Cline implementation was architecturally incorrect. According to Cline's official documentation, Cline uses workflows for on-demand automation and rules for behavioral guidelines. The OpenSpec slash commands are procedural workflows (scaffold → implement → archive), not behavioral rules, so they should be placed in `.clinerules/workflows/` instead of `.clinerules/`.
|
||||
|
||||
## What Changes
|
||||
- Update ClineSlashCommandConfigurator to use `.clinerules/workflows/` paths instead of `.clinerules/` paths
|
||||
- Update all tests to expect the correct workflow file locations
|
||||
- Update README.md documentation to reflect workflows instead of rules
|
||||
- **BREAKING**: Existing Cline users will need to re-run `openspec init` to get the corrected workflow files
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-init (corrected Cline workflow paths)
|
||||
- Affected code: `src/core/configurators/slash/cline.ts`, test files, README.md
|
||||
- Modified files: `.clinerules/workflows/openspec-*.md` (moved from `.clinerules/openspec-*.md`)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Delta for CLI Init
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Update ClineSlashCommandConfigurator
|
||||
- [x] Change FILE_PATHS in `src/core/configurators/slash/cline.ts` from `.clinerules/openspec-*.md` to `.clinerules/workflows/openspec-*.md`
|
||||
|
||||
## 2. Update Tests
|
||||
- [x] Update "should refresh existing Cline rule files" test in `test/core/update.test.ts` to use workflow paths
|
||||
- [x] Update "should create Cline rule files with templates" test in `test/core/init.test.ts` to use workflow paths
|
||||
|
||||
## 3. Update Documentation
|
||||
- [x] Update README.md table to show "Workflows in `.clinerules/workflows/` directory" for Cline
|
||||
|
||||
## 4. Validate Changes
|
||||
- [x] Ensure all tests pass with the new paths
|
||||
- [x] Verify the change follows OpenSpec conventions
|
||||
@@ -76,6 +76,12 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring iFlow CLI
|
||||
|
||||
- **WHEN** iFlow CLI is selected
|
||||
- **THEN** create or update `IFLOW.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
@@ -118,6 +124,12 @@ The command SHALL provide clear, actionable next steps upon successful initializ
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
|
||||
|
||||
#### Scenario: Displaying restart instruction
|
||||
- **WHEN** initialization completes successfully and tools were created or refreshed
|
||||
- **THEN** display a prominent restart instruction before the "Next steps" section
|
||||
- **AND** inform users that slash commands are loaded at startup
|
||||
- **AND** instruct users to restart their coding assistant to ensure /openspec commands appear
|
||||
|
||||
### Requirement: Exit Codes
|
||||
|
||||
The command SHALL use consistent exit codes to indicate different failure modes.
|
||||
@@ -231,6 +243,29 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Gemini CLI
|
||||
- **WHEN** the user selects Gemini CLI during initialization
|
||||
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
|
||||
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
|
||||
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
|
||||
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
|
||||
|
||||
#### Scenario: Generating slash commands for iFlow CLI
|
||||
- **WHEN** the user selects iFlow CLI during initialization
|
||||
- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command
|
||||
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for RooCode
|
||||
- **WHEN** the user selects RooCode during initialization
|
||||
- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter
|
||||
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
### Requirement: Non-Interactive Mode
|
||||
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
|
||||
|
||||
|
||||
@@ -117,6 +117,19 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** update only the OpenSpec-managed block between markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Gemini CLI
|
||||
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
|
||||
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
|
||||
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
|
||||
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
|
||||
|
||||
#### Scenario: Updating slash commands for iFlow CLI
|
||||
- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields
|
||||
- **AND** update only the OpenSpec-managed block between markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.13.0",
|
||||
"version": "0.16.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Generated
+5
-5
@@ -37,8 +37,8 @@ importers:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4)
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
|
||||
@@ -1115,8 +1115,8 @@ packages:
|
||||
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2223,7 +2223,7 @@ snapshots:
|
||||
|
||||
type-fest@0.21.3: {}
|
||||
|
||||
typescript@5.9.2: {}
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
# RFC 0001 Appendix: Research, Decisions & Background
|
||||
|
||||
> This document accompanies [RFC 0001: OpenSpec Workspaces](./0001-openspec-workspaces.md).
|
||||
> It contains the research, Q&A, decision rationale, and design exploration that informed the main RFC.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#executive-summary)
|
||||
2. [Background & Motivation](#background--motivation)
|
||||
3. [Key Decisions (V1)](#key-decisions-v1)
|
||||
4. [Research: Cross-Platform Storage](#research-cross-platform-storage)
|
||||
5. [Q&A: Design Clarifications](#qa-design-clarifications)
|
||||
6. [Risk Analysis](#risk-analysis)
|
||||
7. [Alternatives Considered](#alternatives-considered)
|
||||
8. [Future Considerations](#future-considerations)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
OpenSpec is being split into two concerns:
|
||||
|
||||
1. **Specs** (source of truth) — remain in-repo at `/openspec/specs/`
|
||||
2. **Change proposals** (planning context) — move to a centralized user-scoped directory at `~/.config/openspec/`
|
||||
|
||||
This enables:
|
||||
- Multi-repo planning without duplicating proposals
|
||||
- Simplified change lifecycle (no archival step)
|
||||
- Path to customizable planning workflows
|
||||
- Better agent context management
|
||||
|
||||
---
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
### Problems Being Solved
|
||||
|
||||
1. **Multi-repo coordination**: Teams working across multiple repos currently must duplicate change proposals in each repo, leading to drift and coordination overhead.
|
||||
|
||||
2. **Convoluted archival process**: The current workflow requires agents to make exact copies of specs, then archive changes after applying. This is error-prone and adds friction.
|
||||
|
||||
3. **Limited customization**: The tightly-coupled structure makes it hard to customize the planning process or add features.
|
||||
|
||||
### Benefits of the New Approach
|
||||
|
||||
| Benefit | Single-Repo Users | Multi-Repo Teams |
|
||||
|---------|-------------------|------------------|
|
||||
| Simpler workflow | No archival step | Single change spans repos |
|
||||
| Better PRs | Spec + code diff together | Coordinated changes |
|
||||
| Agent context | Clear active change | Cross-repo awareness |
|
||||
| Customization | Planning templates | Shared workflows |
|
||||
|
||||
### Why Split Specs and Changes?
|
||||
|
||||
- **Specs are code** — they belong with the codebase, versioned in git, reviewed in PRs
|
||||
- **Changes are ephemeral** — they're planning artifacts that don't need to live in the repo permanently
|
||||
- **Cross-repo changes need a home** — can't pick one repo arbitrarily; centralized location is neutral ground
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions (V1)
|
||||
|
||||
### 1. Workspace/Index Location
|
||||
|
||||
**Decision**: `~/.config/openspec/index.json` (XDG-style)
|
||||
|
||||
**Rationale**: XDG spec is the modern standard for CLI tools, has clear cross-platform mappings, and libraries exist in every language.
|
||||
|
||||
**RepoId format**: `{primary-remote-url}@{default-branch}`
|
||||
- Supports multiple local clone paths for the same repo
|
||||
- SSH/HTTPS variants normalize to same ID
|
||||
- Repos without remotes fall back to local path hash
|
||||
|
||||
### 2. Active Context Projection
|
||||
|
||||
**Decision**: Write a git-ignored shadow manifest `./.openspec-context.json` in each attached repo.
|
||||
|
||||
**V1 Schema**:
|
||||
```
|
||||
schemaVersion, generatedAt, repoId, workspaceId, branch,
|
||||
activeChangeId|null, changeTitle, status,
|
||||
specRoots, tasks array, warnings,
|
||||
sourceHash/sourceVersion
|
||||
```
|
||||
|
||||
**Rationale**: Agents can discover context without CLI calls; file is lightweight and regenerated on demand.
|
||||
|
||||
### 3. Refresh Policy
|
||||
|
||||
**Decision**: Regenerate the manifest on any context-touching command (`openspec status/context/set-active/attach`) if missing or branch mismatch detected. Force refresh via `openspec context --refresh`.
|
||||
|
||||
**V1 Simplification**: No age checks; git-less repos skip branch checks.
|
||||
|
||||
### 4. Drift Detection
|
||||
|
||||
**Decision**: `openspec verify` auto-runs on `status/context`, surfaces drift as **warnings only** (non-blocking).
|
||||
|
||||
**Rationale**: Start with visibility, not enforcement. Let users understand drift before making it blocking.
|
||||
|
||||
### 5. Task Storage
|
||||
|
||||
**Decision**: Stay as Markdown checkboxes in V1.
|
||||
|
||||
**Future option**: Normalize to structured format (JSONL/SQLite) for better agent integration, but not required for V1.
|
||||
|
||||
### 6. Cross-Repo Specs
|
||||
|
||||
**Decision**: Defer to future version with dedicated "specs repo" as canonical home for cross-repo capabilities.
|
||||
|
||||
**Rationale**: This is complex and not needed for V1. Single-repo and simple multi-repo cases work without it.
|
||||
|
||||
### 7. Active Change Detection
|
||||
|
||||
**Decision**: Use git branch to determine which change is active.
|
||||
|
||||
**How it works**:
|
||||
- When user creates a change, they optionally create a feature branch
|
||||
- OpenSpec detects current branch and maps it to corresponding change
|
||||
- If on main/master, user can manually select which change to work on
|
||||
|
||||
**Open questions** (deferred):
|
||||
- Where is branch→change mapping stored?
|
||||
- Handling multiple changes for same branch?
|
||||
- Branch renames?
|
||||
|
||||
### 8. Agent Integration Strategy
|
||||
|
||||
**Decision**: Support multiple modes (hybrid approach).
|
||||
|
||||
| Mode | Target | How it works |
|
||||
|------|--------|--------------|
|
||||
| SDK mode | Claude Code, etc. | OpenSpec invokes agent, injects context, verifies completion |
|
||||
| CLI mode | Cursor, etc. | Agent calls CLI commands, best-effort tracking |
|
||||
| Manual mode | Any | User manually syncs, OpenSpec provides verification |
|
||||
|
||||
**Rationale**: Provide best experience where possible while still working with any agent.
|
||||
|
||||
### 9. Spec Sync Strategy
|
||||
|
||||
**Decision**: Push-based updates as ideal path, `openspec verify` for manual sync/drift detection.
|
||||
|
||||
**Push-based** (ideal): All changes go through OpenSpec commands which update both the proposal and trigger code changes.
|
||||
|
||||
**Pull-based** (fallback): `openspec verify` command compares spec-deltas to actual spec files and reports what's out of sync.
|
||||
|
||||
**Automatic sync** (future): File watchers or git hooks, but start with manual for V1.
|
||||
|
||||
---
|
||||
|
||||
## Research: Cross-Platform Storage
|
||||
|
||||
### Location Options Compared
|
||||
|
||||
| Approach | macOS | Linux | Windows | Used by |
|
||||
|----------|-------|-------|---------|---------|
|
||||
| XDG spec | `~/.config/openspec` | `~/.config/openspec` | `%APPDATA%\openspec` | Many CLI tools |
|
||||
| Home dotfile | `~/.openspec` | `~/.openspec` | `%USERPROFILE%\.openspec` | npm, cargo, rustup |
|
||||
| App Support | `~/Library/Application Support/openspec` | `~/.local/share/openspec` | `%LOCALAPPDATA%\openspec` | VS Code, Electron apps |
|
||||
|
||||
### Recommendation
|
||||
|
||||
Use XDG-style (`~/.config/openspec`) because:
|
||||
- Modern standard for CLI tools
|
||||
- Clear cross-platform mappings
|
||||
- Libraries exist in every language (`dirs` in Rust, `appdirs` in Python, `env-paths` in Node)
|
||||
- Keeps home directory cleaner than dotfiles
|
||||
|
||||
**Note**: `~/.openspec` is hidden by default (dot prefix) which conflicts with "open in editor" goal. Consider `~/openspec` if visibility matters, but XDG is more conventional.
|
||||
|
||||
---
|
||||
|
||||
## Q&A: Design Clarifications
|
||||
|
||||
### Location and Structure
|
||||
|
||||
**Q1: Where exactly does `.openspec` live?**
|
||||
|
||||
A: `~/.config/openspec` (XDG-style). For V1, assume change proposals only exist locally. Cloud/git-based sync for team sharing is deferred.
|
||||
|
||||
**Q2: What's the structure inside `.openspec`?**
|
||||
|
||||
A: File-based structure so teams can open the folder in their code editor:
|
||||
```
|
||||
~/.config/openspec/
|
||||
├─ index.json # repo/workspace index
|
||||
└─ workspaces/<workspace-id>/
|
||||
├─ workspace.json # attached repos, defaults
|
||||
└─ changes/<change-id>/
|
||||
├─ proposal.md # overview, design choices
|
||||
├─ tasks.md # task breakdown with checkboxes
|
||||
└─ specs/ # spec deltas
|
||||
```
|
||||
|
||||
**Q3: How are repos "attached" to a workspace?**
|
||||
|
||||
A: Configuration reference (not symlinks). User selects a folder/repository locally. Does not need to be git-enabled, but git provides branch detection benefits.
|
||||
|
||||
### Multi-Repo Mechanics
|
||||
|
||||
**Q4: How does a single change span multiple repos?**
|
||||
|
||||
A: Expand existing spec-deltas concept across repos. Task phases can be split between repos (e.g., "Phase 1: API repo", "Phase 2: Frontend repo").
|
||||
|
||||
**Q5: How do PRs work across repos?**
|
||||
|
||||
A: Left to the developer. OpenSpec helps create changes in each repo; PR management is manual. This is intentional — automated cross-repo transactions are complex and error-prone.
|
||||
|
||||
**Q6: How do you handle version coordination?**
|
||||
|
||||
A: Planning is based on local version of repos (or main/master). Explicit version dependencies are out of scope for V1.
|
||||
|
||||
**Q7: Where do cross-repo specs live?**
|
||||
|
||||
A: Deferred. Initial thinking: a dedicated "specs repo" that OpenSpec manages. For V1, keep specs in individual repos.
|
||||
|
||||
### Agent Integration
|
||||
|
||||
**Q8: How do agents discover the relevant context?**
|
||||
|
||||
A: Agent calls OpenSpec CLI from the repository. CLI identifies workspace and returns relevant context. Alternatively, read `.openspec-context.json` manifest directly.
|
||||
|
||||
**Q9: Do agents need CLI commands to access context?**
|
||||
|
||||
A: CLI is the primary method. The manifest (`.openspec-context.json`) provides a cached view to reduce CLI calls. Trade-off: manifest can be stale, but refresh is cheap.
|
||||
|
||||
**Q10: What if an agent modifies specs directly?**
|
||||
|
||||
A: Changes live in `~/.config/openspec`, specs live in repos. Agent can modify specs directly (they're just files). `openspec verify` detects drift between expected and actual state.
|
||||
|
||||
### Workflow and State
|
||||
|
||||
**Q11: What's the lifecycle of a change?**
|
||||
|
||||
A: `Draft → In Progress → Completed`
|
||||
|
||||
There may be sub-states in drafting (research, planning, breakdown). This should be configurable for users.
|
||||
|
||||
**Q12: What does the "work log" contain?**
|
||||
|
||||
A: Agent-generated record of work done. Less important for V1; deferred.
|
||||
|
||||
**Q13: How does "active change" context work?**
|
||||
|
||||
A: Branch-based detection is the primary method. Open question: how to handle non-git repos or when working on main. Stored state vs. git reconstruction needs more thought.
|
||||
|
||||
**Q14: How do you resume work on a change?**
|
||||
|
||||
A: Agent reads the change, looks at which tasks are ticked off, reviews any work log, and picks up from there.
|
||||
|
||||
### Task Management
|
||||
|
||||
**Q15: Why is task management "unknown" if it's core to planning?**
|
||||
|
||||
A: Goal is minimal V1, not complete solution. Markdown checkboxes work today. Agent forgetfulness is a known issue but not blocking for V1.
|
||||
|
||||
**Q16: What's wrong with current task tracking?**
|
||||
|
||||
A: Agents forget to tick checkboxes after doing work. This is an agent behavior problem, not purely a data model problem. Solutions involve better prompting, verification, or agent-side tooling.
|
||||
|
||||
**Q17: Should task state live in `.openspec` or in-repo?**
|
||||
|
||||
A: Central (`~/.config/openspec`) for simplicity.
|
||||
|
||||
### Migration and Compatibility
|
||||
|
||||
**Q18: How do existing users migrate?**
|
||||
|
||||
A: Automatic migration on CLI update. Detect existing `/openspec/changes`, copy to workspace store, write manifests.
|
||||
|
||||
**Q19: Are you removing the in-repo changes workflow?**
|
||||
|
||||
A: Yes, eventually. One flow to maintain. May keep legacy layout as read-only during transition.
|
||||
|
||||
---
|
||||
|
||||
## Risk Analysis
|
||||
|
||||
### High Risk
|
||||
|
||||
#### Agent Context Accessibility (Partially Mitigated)
|
||||
|
||||
Moving change context outside the repo is the biggest risk. Currently, agents work because all context is co-located in `/openspec/`.
|
||||
|
||||
**Concerns**:
|
||||
- Agents lose direct filesystem access to change proposals
|
||||
- CLI fetch adds friction and indirection
|
||||
- Not all agent systems can invoke CLI commands mid-task
|
||||
- Mental model breaks: "where I'm coding" vs. "where my plan lives"
|
||||
|
||||
**Mitigation**: Hybrid agent integration (SDK/CLI/Manual modes), shadow manifest for quick reads.
|
||||
|
||||
### Medium-High Risk
|
||||
|
||||
#### Multi-Repo Coordination Complexity
|
||||
|
||||
Multi-repo is the main selling point but introduces complexity:
|
||||
- No atomic cross-repo git transactions
|
||||
- PR coordination is manual and error-prone
|
||||
- Version dependencies need explicit modeling
|
||||
- CI/CD becomes more complex
|
||||
|
||||
**Mitigation**: V1 keeps it simple — no automated PR orchestration, no version enforcement. Let users handle coordination manually.
|
||||
|
||||
#### Cross-Repo Spec Ownership
|
||||
|
||||
Where do specs live that span multiple repos?
|
||||
|
||||
**Options considered**:
|
||||
| Option | Problem |
|
||||
|--------|---------|
|
||||
| Duplicated in each repo | Sync problems, unclear authority |
|
||||
| In one "primary" repo | Arbitrary, hard to discover |
|
||||
| In centralized `.openspec` | Split location for specs |
|
||||
| In separate "specs repo" | Another repo to maintain |
|
||||
|
||||
**Decision**: Defer to future "specs repo" concept. V1 keeps specs in individual repos.
|
||||
|
||||
### Medium Risk
|
||||
|
||||
#### Task Management Undefined
|
||||
|
||||
Task management is central but left undefined. This affects:
|
||||
- Data model for tasks
|
||||
- Agent integration
|
||||
- Work log concept
|
||||
|
||||
**Mitigation**: Start with Markdown checkboxes. Iterate based on real usage.
|
||||
|
||||
#### Location and Portability (Partially Mitigated)
|
||||
|
||||
User-level directory creates challenges:
|
||||
- CI/CD access
|
||||
- Team collaboration
|
||||
- Machine portability
|
||||
- Discoverability
|
||||
|
||||
**Mitigation**: Cross-platform location research done. V1 is local-only; team sync deferred.
|
||||
|
||||
#### State Synchronization (Partially Mitigated)
|
||||
|
||||
Specs in-repo, changes out-of-repo can drift:
|
||||
- Direct spec edits bypass change tracking
|
||||
- Stale `.openspec` state
|
||||
- Orphaned changes when repos deleted
|
||||
|
||||
**Mitigation**: `openspec verify` for drift detection. Automatic sync is future work.
|
||||
|
||||
### Low-Medium Risk
|
||||
|
||||
#### Backwards Compatibility
|
||||
|
||||
Existing users have workflows around `/openspec/changes/`.
|
||||
|
||||
**Mitigation**: Clear migration path, automatic migration on update, optional read-only legacy support during transition.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Keep Everything In-Repo
|
||||
|
||||
**Pros**: Simple, portable, git-versioned, agent-friendly
|
||||
**Cons**: Can't support multi-repo, archival is messy
|
||||
|
||||
**Why rejected**: Multi-repo is a key goal; this doesn't solve it.
|
||||
|
||||
### Monorepo-Only Support
|
||||
|
||||
**Pros**: Simpler than true multi-repo, single git history
|
||||
**Cons**: Many teams don't use monorepos, doesn't help existing multi-repo setups
|
||||
|
||||
**Why rejected**: Too limiting; multi-repo is common in practice.
|
||||
|
||||
### Git Submodules for Changes
|
||||
|
||||
**Pros**: Git-native, versioned, portable
|
||||
**Cons**: Submodules are notoriously painful, adds complexity
|
||||
|
||||
**Why rejected**: Complexity outweighs benefits.
|
||||
|
||||
### Cloud-First Storage
|
||||
|
||||
**Pros**: Team sync built-in, no local state issues
|
||||
**Cons**: Requires account/auth, internet dependency, privacy concerns
|
||||
|
||||
**Why rejected**: Local-first is simpler for V1; cloud can be added later.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Post-V1 Features
|
||||
|
||||
1. **Team sync**: Git-based or cloud-based workspace sharing
|
||||
2. **CI/CD integration**: Environment overrides, workspace exports
|
||||
3. **Structured tasks**: JSONL or SQLite for better agent integration
|
||||
4. **Cross-repo specs**: Dedicated specs repo as canonical home
|
||||
5. **Work logs**: Agent-generated session records
|
||||
6. **Branch→change mapping**: More robust active change detection
|
||||
7. **API/SDK**: Programmatic access beyond CLI
|
||||
|
||||
### Open Design Questions
|
||||
|
||||
- How granular is drift detection?
|
||||
- Should verification block PR creation or just warn?
|
||||
- How to handle branch renames?
|
||||
- What if multiple changes exist for the same branch?
|
||||
- How to represent cross-repo spec ownership without a dedicated specs repo?
|
||||
|
||||
### Agent Integration Evolution
|
||||
|
||||
Current thinking on improving agent task completion:
|
||||
1. Better prompting (explicit reminders to tick checkboxes)
|
||||
2. Verification commands (agent calls `openspec verify` before finishing)
|
||||
3. SDK mode (OpenSpec invokes agent, validates completion)
|
||||
4. Work log (track what agent actually did vs. what was planned)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [RFC 0001: OpenSpec Workspaces](./0001-openspec-workspaces.md) — the main RFC
|
||||
- XDG Base Directory Specification
|
||||
- Similar tools: Nx, Turborepo, Lerna (monorepo coordination)
|
||||
@@ -0,0 +1,328 @@
|
||||
# RFC 0001: OpenSpec Workspaces and Off-Repo Change Storage
|
||||
|
||||
**Status:** Draft
|
||||
**Authors/Reviewers:** TBC
|
||||
**Last Updated:** 2024-XX-XX
|
||||
|
||||
> **See also:** [Appendix: Research & Decisions](./0001-openspec-workspaces-appendix.md) for background research, Q&A, and detailed decision rationale.
|
||||
|
||||
## Purpose
|
||||
|
||||
**Problem:** Users working on features that span multiple repos must currently duplicate change proposals in each repo, leading to drift and coordination overhead. Single-repo users also suffer from a convoluted archive step when applying changes.
|
||||
|
||||
**Solution:**
|
||||
- Enable multi-repo planning by moving change proposals out of individual repos while keeping specs in-repo.
|
||||
- Preserve agent usability with a lightweight, discoverable context projection per repo.
|
||||
- Simplify the change lifecycle (apply changes directly, drop archiving) and keep the spec and code diff side-by-side.
|
||||
|
||||
## Goals (V1)
|
||||
|
||||
- Single source of truth for changes lives in a user-scoped workspace store (`~/.config/openspec`).
|
||||
- Specs stay in the repo; only change proposals and workspace metadata move out-of-repo.
|
||||
- Agents and humans can see "what change is active here?" via a small manifest in the repo root.
|
||||
- Lay the groundwork for customizable workflows (hooks, reusable instructions) scoped to a workspace.
|
||||
- Keep task tracking simple (Markdown checkboxes) while allowing future structured storage.
|
||||
- Provide a clear migration path from in-repo `/openspec/changes`.
|
||||
|
||||
## Non-Goals (V1)
|
||||
|
||||
- Team/shared cloud sync (local-only for now).
|
||||
- Automatic cross-repo PR orchestration or transactions.
|
||||
- Rich task model or work log; basic checkboxes only.
|
||||
- Replacing existing specs layout in-repo.
|
||||
- CI/CD integration (workspace store lives in user home; CI pipelines that need change context should use environment overrides or check in workspace exports—deferred to future work).
|
||||
|
||||
## Before and After (Conceptual)
|
||||
|
||||
```
|
||||
Before (today)
|
||||
repo/
|
||||
└─ openspec/
|
||||
├─ specs/
|
||||
└─ changes/<change-id>/
|
||||
├─ proposal.md
|
||||
├─ tasks.md
|
||||
└─ specs/... (deltas)
|
||||
|
||||
After (V1)
|
||||
repo/
|
||||
├─ openspec/specs/ # unchanged spec source of truth
|
||||
├─ .openspec-context.json # git-ignored manifest (shadow)
|
||||
└─ ...code...
|
||||
|
||||
~/.config/openspec/ # user-scoped workspace store
|
||||
├─ index.json # repo/workspace index
|
||||
└─ workspaces/<workspace-id>/
|
||||
├─ workspace.json # attached repos, defaults
|
||||
└─ changes/<change-id>/ # proposals, tasks, deltas
|
||||
├─ proposal.md
|
||||
├─ tasks.md
|
||||
└─ specs/... (deltas)
|
||||
```
|
||||
|
||||
## Proposed Model (High-Level)
|
||||
|
||||
- **Workspace store**: XDG path `~/.config/openspec` holds `index.json` plus per-workspace folders.
|
||||
- **Identifiers**:
|
||||
- `workspaceId`: user-provided slug or auto-generated UUID (e.g., `default`, `my-project`).
|
||||
- `changeId`: user-provided slug derived from change title or auto-generated (e.g., `add-user-auth`).
|
||||
- `repoId`: `{primary-remote-url}@{default-branch}` (normalized: SSH/HTTPS variants resolve to same ID). For repos without remotes, fallback to local path hash.
|
||||
- **Context projection**: generate `./.openspec-context.json` in each attached repo (git-ignored) with:
|
||||
- `schemaVersion`, `generatedAt`, `repoId`, `workspaceId`, `branch`
|
||||
- `activeChangeId|null`, `changeTitle`, `status`
|
||||
- `specRoots`, `tasks` (checkbox state), `warnings`
|
||||
- `sourceHash`/`sourceVersion` for drift detection
|
||||
- **Refresh policy**: regenerate manifest on `openspec status/context/set-active/attach`, or `openspec context --refresh`.
|
||||
- **Drift detection**: `openspec verify` runs on `status/context`, warns (non-blocking) when:
|
||||
- Manifest `sourceHash` (hash of change proposal + tasks) differs from workspace store.
|
||||
- In-repo specs differ from expected state based on applied changes.
|
||||
- Branch in manifest doesn't match current git branch.
|
||||
- **Tasks**: stay as Markdown checkboxes in change folders; structured format is future work.
|
||||
- **Cross-repo specs**: defer to future dedicated "specs repo"; out-of-scope for V1.
|
||||
|
||||
## Workspace Customization & Hooks
|
||||
|
||||
Workspaces double as the unit of customization. Moving changes into `~/.config/openspec` gives us a neutral location to store **per-workspace workflow configuration** that can be projected into every attached repo alongside the manifest. Early capabilities include:
|
||||
|
||||
- **Lifecycle hooks**: Users can define shell commands or scripts (e.g., `./scripts/post-create.sh`) that OpenSpec runs after key events such as `change create`, `change apply`, `task toggle`, or `context --refresh`. Hooks let teams codify "what happens next?" right after a proposal is created—notify Slack, scaffold implementation branches, sync Linear tickets, etc.
|
||||
- **Workflow presets**: Workspace metadata (`workspace.json`) can include reusable instructions/templates that describe the expected flow (e.g., "After proposal, run design review checklist", "Before apply, execute `openspec verify --strict`"). Agents surface these reminders in their startup instructions, and humans can read them from the manifest.
|
||||
- **Composable steps**: Hooks are optional and orderable, so teams can compose bespoke workflows (e.g., `change.create` → run linting hooks, attach repo-specific instructions; `change.apply` → trigger spec sync + notify QA). V1 focuses on definition + discovery—the appendix tracks richer automation (custom task types, SDK integrations) for future versions.
|
||||
|
||||
This section keeps the RFC honest about a core benefit of workspaces: breaking the monolithic flow into smaller, customizable checkpoints without hard-coding them in every repo. Detailed research and long-term options live in the appendix’s "Future Considerations" and "Risk Analysis" tables.
|
||||
|
||||
## ASCII Flow (Agent View)
|
||||
|
||||
```
|
||||
+---------------------------+
|
||||
| ~/.config/openspec |
|
||||
| - index.json |
|
||||
| - workspaces/<ws>/... |
|
||||
+-------------+-------------+
|
||||
|
|
||||
| openspec status/context --refresh
|
||||
v
|
||||
+---------------------------+
|
||||
| repo/ |
|
||||
| - .openspec-context.json | <- lightweight shadow manifest
|
||||
| - openspec/specs/... | <- truth for specs
|
||||
| - code |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
How agents discover and use change context:
|
||||
|
||||
1. **Discover active change**: Agent reads `.openspec-context.json` in repo root (or calls `openspec status --json`).
|
||||
- Manifest provides: `activeChangeId`, `changeTitle`, `status`, `tasks` summary, `warnings`.
|
||||
- If manifest missing or stale, agent calls `openspec context --refresh`.
|
||||
|
||||
2. **Read full change details**: Agent calls `openspec change show <changeId> --json` to get:
|
||||
- Full proposal content (`proposal.md`)
|
||||
- Complete task list with checkbox states (`tasks.md`)
|
||||
- Spec deltas (what specs will change)
|
||||
- Paths to all change artifacts in workspace store
|
||||
|
||||
3. **Update task progress**: Agent calls `openspec task toggle <changeId> <taskIndex>` or edits `tasks.md` directly via workspace path (provided in step 2).
|
||||
|
||||
4. **Resume work**: On session start, agent reads manifest → identifies incomplete tasks → continues from last known state.
|
||||
|
||||
**Fallback for non-CLI agents**: If agent cannot invoke CLI, it reads the manifest for summary context and relies on user to sync task state manually.
|
||||
|
||||
## Workflows (Before → After)
|
||||
|
||||
- **Create change**
|
||||
- *Before*: `openspec change create` writes under `repo/openspec/changes`.
|
||||
- *After*: same command writes under `~/.config/openspec/workspaces/<ws>/changes/<id>`, then projects manifest into repo.
|
||||
|
||||
- **Resume work**
|
||||
- *Before*: read `/openspec/changes/<id>` directly in repo.
|
||||
- *After*: `openspec status` (or manifest) tells active change; agent reads tasks/proposal via CLI or manifest pointers.
|
||||
|
||||
- **Apply spec update**
|
||||
- *Before*: copy deltas into `/openspec/specs` and archive change.
|
||||
- *After*: applying a change updates in-repo specs directly; no archive step. Change record stays in workspace store.
|
||||
|
||||
## CLI Impact (V1)
|
||||
|
||||
- **New/updated commands** (shape, not syntax-final):
|
||||
- `openspec context [--refresh]` → writes `.openspec-context.json`.
|
||||
- `openspec attach <repo-path>` → register repo to workspace, emit manifest.
|
||||
- `openspec status [--json]` → shows active workspace/change, drift warnings.
|
||||
- `openspec verify` → compare change store vs. repo specs/tasks, warn on drift.
|
||||
- `openspec change show <changeId> [--json]` → output full change details (proposal, tasks, spec deltas, artifact paths).
|
||||
- `openspec change apply <changeId>` → applies spec deltas to in-repo specs, updates change status to `completed`, refreshes manifest.
|
||||
- `openspec task toggle <changeId> <taskIndex>` → toggle checkbox state in `tasks.md`, refresh manifest.
|
||||
- Existing `change create/validate` operate on workspace store paths.
|
||||
|
||||
## Data Examples
|
||||
|
||||
- **Manifest sketch (`./.openspec-context.json`):**
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1",
|
||||
"generatedAt": "2024-XX-XXT12:00:00Z",
|
||||
"repoId": "git@github.com:org/api.git@main",
|
||||
"workspaceId": "my-project",
|
||||
"branch": "feature/add-auth",
|
||||
"activeChangeId": "add-user-auth",
|
||||
"changeTitle": "Add user authentication",
|
||||
"status": "in-progress",
|
||||
"specRoots": ["openspec/specs"],
|
||||
"tasks": [
|
||||
{"title": "Define auth spec", "done": true},
|
||||
{"title": "Implement JWT middleware", "done": false}
|
||||
],
|
||||
"warnings": [],
|
||||
"sourceHash": "abc123",
|
||||
"relatedRepos": [
|
||||
{"repoId": "git@github.com:org/frontend.git@main", "role": "consumer"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
*Note: `relatedRepos` shows other repos in the same workspace affected by this change. Each repo gets its own manifest with the same `activeChangeId`.*
|
||||
|
||||
- **Workspace index (`~/.config/openspec/index.json`):**
|
||||
```json
|
||||
{
|
||||
"workspaces": [
|
||||
{
|
||||
"id": "my-project",
|
||||
"repos": [
|
||||
{
|
||||
"repoId": "git@github.com:org/api.git@main",
|
||||
"paths": ["/Users/me/dev/api"]
|
||||
},
|
||||
{
|
||||
"repoId": "git@github.com:org/frontend.git@main",
|
||||
"paths": ["/Users/me/dev/frontend"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Migration (Happy Path)
|
||||
|
||||
- Detect existing `/openspec/changes` and prompt to migrate into the workspace store.
|
||||
- Copy change folders to `~/.config/openspec/workspaces/<ws>/changes/`.
|
||||
- Write manifests into each repo (`.openspec-context.json`) pointing to migrated changes.
|
||||
- Keep in-repo specs untouched; future changes update specs directly.
|
||||
- Optionally keep legacy layout temporarily as read-only until confident.
|
||||
|
||||
## Risks / Tradeoffs
|
||||
|
||||
- **Loss of co-location**: agents need CLI/manifest indirection; mitigated by shadow manifest and simple commands (see Agent Workflow).
|
||||
- **Portability**: local-only workspace; migration between machines requires exporting/importing the workspace folder.
|
||||
- **CI/CD access**: out-of-scope for V1 (see Non-Goals); workaround is environment override or workspace export.
|
||||
- **Drift**: specs live in-repo, changes live out-of-repo; mitigated by `openspec verify` and manifest hashes.
|
||||
- **Back-compat**: legacy `/openspec/changes` requires migration; optionally support read-only compatibility during transition.
|
||||
|
||||
## Open Questions (Post-V1)
|
||||
|
||||
- Where to store branch→change mapping (manifest vs. workspace config) and how to handle branch renames?
|
||||
- Minimal API/SDK needed for agent integrations vs. relying solely on CLI.
|
||||
- Packaging/sync of workspace store for teams (git repo? cloud bucket?).
|
||||
- How to represent cross-repo spec ownership without a dedicated specs repo?
|
||||
|
||||
## Acceptance Checklist (for reviewers)
|
||||
|
||||
- High-level model understandable via bullets + diagrams.
|
||||
- Data shapes for manifest and index are clear enough for implementation.
|
||||
- Migration story covers legacy users.
|
||||
- Risks called out with basic mitigations.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: User Flows
|
||||
|
||||
### A. Single-Repo Setup
|
||||
|
||||
```bash
|
||||
cd ~/dev/my-project
|
||||
openspec init # creates workspace, attaches current repo
|
||||
# → ~/.config/openspec/index.json updated
|
||||
# → .openspec-context.json written (git-ignored)
|
||||
# → openspec/specs/ created if missing
|
||||
```
|
||||
|
||||
That's it. User is ready to create changes.
|
||||
|
||||
---
|
||||
|
||||
### B. Multi-Repo Setup
|
||||
|
||||
```bash
|
||||
# Create workspace and attach first repo
|
||||
cd ~/dev/api
|
||||
openspec init --workspace my-platform
|
||||
|
||||
# Attach additional repos to same workspace
|
||||
cd ~/dev/frontend
|
||||
openspec attach --workspace my-platform
|
||||
|
||||
cd ~/dev/shared-types
|
||||
openspec attach --workspace my-platform
|
||||
```
|
||||
|
||||
All three repos now share `my-platform` workspace. Changes created in any repo can reference specs across all attached repos.
|
||||
|
||||
---
|
||||
|
||||
### C. Creating a Change Proposal
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CLI as openspec CLI
|
||||
participant WS as ~/.config/openspec
|
||||
participant Repo as repo/
|
||||
|
||||
User->>CLI: openspec change create "Add auth"
|
||||
CLI->>WS: Create workspaces/<ws>/changes/add-auth/
|
||||
CLI->>WS: Write proposal.md, tasks.md
|
||||
CLI->>Repo: Write .openspec-context.json
|
||||
CLI->>User: Change "add-auth" created ✓
|
||||
|
||||
Note over User,Repo: User/agent can now edit proposal.md and tasks.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### D. Applying a Change Proposal
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CLI as openspec CLI
|
||||
participant WS as ~/.config/openspec
|
||||
participant Repo as repo/
|
||||
|
||||
User->>CLI: openspec change apply add-auth
|
||||
CLI->>WS: Read changes/add-auth/specs/*.md (deltas)
|
||||
CLI->>Repo: Merge deltas into openspec/specs/
|
||||
CLI->>WS: Update change status → "completed"
|
||||
CLI->>Repo: Refresh .openspec-context.json
|
||||
CLI->>User: Specs updated, change complete ✓
|
||||
|
||||
Note over Repo: User commits spec changes + code together
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### E. After the Work is Done
|
||||
|
||||
Once a change is applied:
|
||||
|
||||
| Location | State |
|
||||
|----------|-------|
|
||||
| `repo/openspec/specs/` | Updated with new/modified specs |
|
||||
| `repo/.openspec-context.json` | `activeChangeId: null` or next change |
|
||||
| `~/.config/openspec/.../changes/add-auth/` | Retained as historical record (status: `completed`) |
|
||||
|
||||
**Next steps for user:**
|
||||
1. `git add . && git commit` — spec updates and code ship together
|
||||
2. Create PR, merge
|
||||
3. Start next change or detach repo from workspace when done
|
||||
+12
-5
@@ -17,18 +17,25 @@ export interface AIToolOption {
|
||||
}
|
||||
|
||||
export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
|
||||
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity' },
|
||||
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
|
||||
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
|
||||
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
|
||||
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
|
||||
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
|
||||
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
|
||||
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
|
||||
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },
|
||||
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
|
||||
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
|
||||
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
|
||||
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
|
||||
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
|
||||
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CostrictConfigurator implements ToolConfigurator {
|
||||
name = 'CoStrict';
|
||||
configFileName = 'COSTRICT.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getCostrictTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from "path";
|
||||
import { ToolConfigurator } from "./base.js";
|
||||
import { FileSystemUtils } from "../../utils/file-system.js";
|
||||
import { TemplateManager } from "../templates/index.js";
|
||||
import { OPENSPEC_MARKERS } from "../config.js";
|
||||
|
||||
export class IflowConfigurator implements ToolConfigurator {
|
||||
name = "iFlow";
|
||||
configFileName = "IFLOW.md";
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* Qoder AI Tool Configurator
|
||||
*
|
||||
* Configures OpenSpec integration for Qoder AI coding assistant.
|
||||
* Creates and manages QODER.md configuration file with OpenSpec instructions.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
export class QoderConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qoder tool */
|
||||
name = 'Qoder';
|
||||
|
||||
/** Configuration file name at project root */
|
||||
configFileName = 'QODER.md';
|
||||
|
||||
/** Indicates tool is available for configuration */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configure Qoder integration for a project
|
||||
*
|
||||
* Creates or updates QODER.md file with OpenSpec instructions.
|
||||
* Uses Claude-compatible template for instruction content.
|
||||
* Wrapped with OpenSpec markers for future updates.
|
||||
*
|
||||
* @param {string} projectPath - Absolute path to project root directory
|
||||
* @param {string} openspecDir - Path to openspec directory (unused but required by interface)
|
||||
* @returns {Promise<void>} Resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
// Construct full path to QODER.md at project root
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
|
||||
// Get Claude-compatible instruction template
|
||||
// This ensures Qoder receives the same high-quality OpenSpec instructions
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
// Write or update file with managed content between markers
|
||||
// This allows future updates to refresh instructions automatically
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Qwen Code configurator for OpenSpec integration.
|
||||
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* QwenConfigurator class provides integration with Qwen Code
|
||||
* by creating and managing the necessary configuration files.
|
||||
* Currently configures the QWEN.md file with OpenSpec instructions.
|
||||
*/
|
||||
export class QwenConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qwen Code tool */
|
||||
name = 'Qwen Code';
|
||||
|
||||
/** Configuration file name for Qwen Code */
|
||||
configFileName = 'QWEN.md';
|
||||
|
||||
/** Availability status for the Qwen Code tool */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configures the Qwen Code integration by creating or updating the QWEN.md file
|
||||
* with OpenSpec instructions and markers.
|
||||
*
|
||||
* @param {string} projectPath - The path to the project root
|
||||
* @param {string} _openspecDir - The path to the openspec directory (unused)
|
||||
* @returns {Promise<void>} A promise that resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { ToolConfigurator } from './base.js';
|
||||
import { ClaudeConfigurator } from './claude.js';
|
||||
import { ClineConfigurator } from './cline.js';
|
||||
import { CodeBuddyConfigurator } from './codebuddy.js';
|
||||
import { CostrictConfigurator } from './costrict.js';
|
||||
import { QoderConfigurator } from './qoder.js';
|
||||
import { IflowConfigurator } from './iflow.js';
|
||||
import { AgentsStandardConfigurator } from './agents.js';
|
||||
import { QwenConfigurator } from './qwen.js';
|
||||
|
||||
export class ToolRegistry {
|
||||
private static tools: Map<string, ToolConfigurator> = new Map();
|
||||
@@ -11,12 +15,20 @@ export class ToolRegistry {
|
||||
const claudeConfigurator = new ClaudeConfigurator();
|
||||
const clineConfigurator = new ClineConfigurator();
|
||||
const codeBuddyConfigurator = new CodeBuddyConfigurator();
|
||||
const costrictConfigurator = new CostrictConfigurator();
|
||||
const qoderConfigurator = new QoderConfigurator();
|
||||
const iflowConfigurator = new IflowConfigurator();
|
||||
const agentsConfigurator = new AgentsStandardConfigurator();
|
||||
const qwenConfigurator = new QwenConfigurator();
|
||||
// Register with the ID that matches the checkbox value
|
||||
this.tools.set('claude', claudeConfigurator);
|
||||
this.tools.set('cline', clineConfigurator);
|
||||
this.tools.set('codebuddy', codeBuddyConfigurator);
|
||||
this.tools.set('costrict', costrictConfigurator);
|
||||
this.tools.set('qoder', qoderConfigurator);
|
||||
this.tools.set('iflow', iflowConfigurator);
|
||||
this.tools.set('agents', agentsConfigurator);
|
||||
this.tools.set('qwen', qwenConfigurator);
|
||||
}
|
||||
|
||||
static register(tool: ToolConfigurator): void {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.agent/workflows/openspec-proposal.md',
|
||||
apply: '.agent/workflows/openspec-apply.md',
|
||||
archive: '.agent/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
export class AntigravitySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'antigravity';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const description = DESCRIPTIONS[id];
|
||||
return `---\ndescription: ${description}\n---`;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.clinerules/openspec-proposal.md',
|
||||
apply: '.clinerules/openspec-apply.md',
|
||||
archive: '.clinerules/openspec-archive.md'
|
||||
proposal: '.clinerules/workflows/openspec-proposal.md',
|
||||
apply: '.clinerules/workflows/openspec-apply.md',
|
||||
archive: '.clinerules/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS = {
|
||||
proposal: '.cospec/openspec/commands/openspec-proposal.md',
|
||||
apply: '.cospec/openspec/commands/openspec-apply.md',
|
||||
archive: '.cospec/openspec/commands/openspec-archive.md',
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
const FRONTMATTER = {
|
||||
proposal: `---
|
||||
description: "Scaffold a new OpenSpec change and validate strictly."
|
||||
argument-hint: feature description or request
|
||||
---`,
|
||||
apply: `---
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: change-id
|
||||
---`
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'costrict';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TomlSlashCommandConfigurator } from './toml-base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.gemini/commands/openspec/proposal.toml',
|
||||
apply: '.gemini/commands/openspec/apply.toml',
|
||||
archive: '.gemini/commands/openspec/archive.toml'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
|
||||
readonly toolId = 'gemini';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getDescription(id: SlashCommandId): string {
|
||||
return DESCRIPTIONS[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.iflow/commands/openspec-proposal.md',
|
||||
apply: '.iflow/commands/openspec-apply.md',
|
||||
archive: '.iflow/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: /openspec-proposal
|
||||
id: openspec-proposal
|
||||
category: OpenSpec
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---`,
|
||||
apply: `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
archive: `---
|
||||
name: /openspec-archive
|
||||
id: openspec-archive
|
||||
category: OpenSpec
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`
|
||||
};
|
||||
|
||||
export class IflowSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'iflow';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
agent: build
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
@@ -20,11 +19,14 @@ The user has requested the following change proposal. Use the openspec instructi
|
||||
</UserRequest>
|
||||
`,
|
||||
apply: `---
|
||||
agent: build
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
---
|
||||
The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
archive: `---
|
||||
agent: build
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
<ChangeId>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* File paths for Qoder slash commands
|
||||
* Maps each OpenSpec workflow stage to its command file location
|
||||
* Commands are stored in .qoder/commands/openspec/ directory
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
// Create and validate new change proposals
|
||||
proposal: '.qoder/commands/openspec/proposal.md',
|
||||
|
||||
// Implement approved changes with task tracking
|
||||
apply: '.qoder/commands/openspec/apply.md',
|
||||
|
||||
// Archive completed changes and update specs
|
||||
archive: '.qoder/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
/**
|
||||
* YAML frontmatter for Qoder slash commands
|
||||
* Defines metadata displayed in Qoder's command palette
|
||||
* Each command is categorized and tagged for easy discovery
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
/**
|
||||
* Qoder Slash Command Configurator
|
||||
*
|
||||
* Manages OpenSpec slash commands for Qoder AI assistant.
|
||||
* Creates three workflow commands: proposal, apply, and archive.
|
||||
* Uses colon-separated command format (/openspec:proposal).
|
||||
*
|
||||
* @extends {SlashCommandConfigurator}
|
||||
*/
|
||||
export class QoderSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
/** Unique identifier for Qoder tool */
|
||||
readonly toolId = 'qoder';
|
||||
|
||||
/** Indicates slash commands are available for this tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Get relative file path for a slash command
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} Relative path from project root to command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YAML frontmatter for a slash command
|
||||
*
|
||||
* Frontmatter defines how the command appears in Qoder's UI,
|
||||
* including display name, description, and categorization.
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} YAML frontmatter block with command metadata
|
||||
*/
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Qwen slash command configurator for OpenSpec integration.
|
||||
* This class handles the generation of Qwen-specific slash command files
|
||||
* in the .qwen/commands directory structure.
|
||||
*
|
||||
* @implements {SlashCommandConfigurator}
|
||||
*/
|
||||
import { TomlSlashCommandConfigurator } from './toml-base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
|
||||
* @type {Record<SlashCommandId, string>}
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.qwen/commands/openspec-proposal.toml',
|
||||
apply: '.qwen/commands/openspec-apply.toml',
|
||||
archive: '.qwen/commands/openspec-archive.toml'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
/**
|
||||
* QwenSlashCommandConfigurator class provides integration with Qwen Code
|
||||
* by creating the necessary slash command files in the .qwen/commands directory.
|
||||
*
|
||||
* The slash commands include:
|
||||
* - /openspec-proposal: Create an OpenSpec change proposal
|
||||
* - /openspec-apply: Apply an approved OpenSpec change
|
||||
* - /openspec-archive: Archive a deployed OpenSpec change
|
||||
*/
|
||||
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
|
||||
/** Unique identifier for the Qwen tool */
|
||||
readonly toolId = 'qwen';
|
||||
|
||||
/** Availability status for the Qwen tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Returns the relative file path for a given slash command ID.
|
||||
* @param {SlashCommandId} id - The slash command identifier
|
||||
* @returns {string} The relative path to the command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getDescription(id: SlashCommandId): string {
|
||||
return DESCRIPTIONS[id];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { ClaudeSlashCommandConfigurator } from './claude.js';
|
||||
import { CodeBuddySlashCommandConfigurator } from './codebuddy.js';
|
||||
import { QoderSlashCommandConfigurator } from './qoder.js';
|
||||
import { CursorSlashCommandConfigurator } from './cursor.js';
|
||||
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
|
||||
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
|
||||
@@ -9,9 +10,15 @@ import { CodexSlashCommandConfigurator } from './codex.js';
|
||||
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
|
||||
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
|
||||
import { FactorySlashCommandConfigurator } from './factory.js';
|
||||
import { GeminiSlashCommandConfigurator } from './gemini.js';
|
||||
import { AuggieSlashCommandConfigurator } from './auggie.js';
|
||||
import { ClineSlashCommandConfigurator } from './cline.js';
|
||||
import { CrushSlashCommandConfigurator } from './crush.js';
|
||||
import { CostrictSlashCommandConfigurator } from './costrict.js';
|
||||
import { QwenSlashCommandConfigurator } from './qwen.js';
|
||||
import { RooCodeSlashCommandConfigurator } from './roocode.js';
|
||||
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
|
||||
import { IflowSlashCommandConfigurator } from './iflow.js';
|
||||
|
||||
export class SlashCommandRegistry {
|
||||
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
|
||||
@@ -19,6 +26,7 @@ export class SlashCommandRegistry {
|
||||
static {
|
||||
const claude = new ClaudeSlashCommandConfigurator();
|
||||
const codeBuddy = new CodeBuddySlashCommandConfigurator();
|
||||
const qoder = new QoderSlashCommandConfigurator();
|
||||
const cursor = new CursorSlashCommandConfigurator();
|
||||
const windsurf = new WindsurfSlashCommandConfigurator();
|
||||
const kilocode = new KiloCodeSlashCommandConfigurator();
|
||||
@@ -27,12 +35,19 @@ export class SlashCommandRegistry {
|
||||
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
|
||||
const amazonQ = new AmazonQSlashCommandConfigurator();
|
||||
const factory = new FactorySlashCommandConfigurator();
|
||||
const gemini = new GeminiSlashCommandConfigurator();
|
||||
const auggie = new AuggieSlashCommandConfigurator();
|
||||
const cline = new ClineSlashCommandConfigurator();
|
||||
const crush = new CrushSlashCommandConfigurator();
|
||||
const costrict = new CostrictSlashCommandConfigurator();
|
||||
const qwen = new QwenSlashCommandConfigurator();
|
||||
const roocode = new RooCodeSlashCommandConfigurator();
|
||||
const antigravity = new AntigravitySlashCommandConfigurator();
|
||||
const iflow = new IflowSlashCommandConfigurator();
|
||||
|
||||
this.configurators.set(claude.toolId, claude);
|
||||
this.configurators.set(codeBuddy.toolId, codeBuddy);
|
||||
this.configurators.set(qoder.toolId, qoder);
|
||||
this.configurators.set(cursor.toolId, cursor);
|
||||
this.configurators.set(windsurf.toolId, windsurf);
|
||||
this.configurators.set(kilocode.toolId, kilocode);
|
||||
@@ -41,9 +56,15 @@ export class SlashCommandRegistry {
|
||||
this.configurators.set(githubCopilot.toolId, githubCopilot);
|
||||
this.configurators.set(amazonQ.toolId, amazonQ);
|
||||
this.configurators.set(factory.toolId, factory);
|
||||
this.configurators.set(gemini.toolId, gemini);
|
||||
this.configurators.set(auggie.toolId, auggie);
|
||||
this.configurators.set(cline.toolId, cline);
|
||||
this.configurators.set(crush.toolId, crush);
|
||||
this.configurators.set(costrict.toolId, costrict);
|
||||
this.configurators.set(qwen.toolId, qwen);
|
||||
this.configurators.set(roocode.toolId, roocode);
|
||||
this.configurators.set(antigravity.toolId, antigravity);
|
||||
this.configurators.set(iflow.toolId, iflow);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const NEW_FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.roo/commands/openspec-proposal.md',
|
||||
apply: '.roo/commands/openspec-apply.md',
|
||||
archive: '.roo/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class RooCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'roocode';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return NEW_FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../../config.js';
|
||||
|
||||
export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
protected getFrontmatter(_id: SlashCommandId): string | undefined {
|
||||
// TOML doesn't use separate frontmatter - it's all in one structure
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected abstract getDescription(id: SlashCommandId): string;
|
||||
|
||||
// Override to generate TOML format with markers inside the prompt field
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const body = this.getBody(target.id);
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
await this.updateBody(filePath, body);
|
||||
} else {
|
||||
const tomlContent = this.generateTOML(target.id, body);
|
||||
await FileSystemUtils.writeFile(filePath, tomlContent);
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
private generateTOML(id: SlashCommandId, body: string): string {
|
||||
const description = this.getDescription(id);
|
||||
|
||||
// TOML format with triple-quoted string for multi-line prompt
|
||||
// Markers are inside the prompt value
|
||||
return `description = "${description}"
|
||||
|
||||
prompt = """
|
||||
${OPENSPEC_MARKERS.start}
|
||||
${body}
|
||||
${OPENSPEC_MARKERS.end}
|
||||
"""
|
||||
`;
|
||||
}
|
||||
|
||||
// Override updateBody to handle TOML format
|
||||
protected async updateBody(filePath: string, body: string): Promise<void> {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
throw new Error(`Missing OpenSpec markers in ${filePath}`);
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
|
||||
const after = content.slice(endIndex);
|
||||
const updatedContent = `${before}\n${body}\n${after}`;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, updatedContent);
|
||||
}
|
||||
}
|
||||
+104
-21
@@ -21,6 +21,7 @@ import {
|
||||
AI_TOOLS,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
OPENSPEC_MARKERS,
|
||||
} from './config.js';
|
||||
import { PALETTE } from './styles/palette.js';
|
||||
|
||||
@@ -388,7 +389,7 @@ export class InitCommand {
|
||||
|
||||
// Validation happens silently in the background
|
||||
const extendMode = await this.validate(projectPath, openspecPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath, extendMode);
|
||||
|
||||
this.renderBanner(extendMode);
|
||||
|
||||
@@ -427,9 +428,11 @@ export class InitCommand {
|
||||
} else {
|
||||
ora({ stream: process.stdout }).info(
|
||||
PALETTE.midGray(
|
||||
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
|
||||
'ℹ OpenSpec already initialized. Checking for missing files...'
|
||||
)
|
||||
);
|
||||
await this.createDirectoryStructure(openspecPath);
|
||||
await this.ensureTemplateFiles(openspecPath, config);
|
||||
}
|
||||
|
||||
// Step 2: Configure AI tools
|
||||
@@ -627,35 +630,78 @@ export class InitCommand {
|
||||
}
|
||||
|
||||
private async getExistingToolStates(
|
||||
projectPath: string
|
||||
projectPath: string,
|
||||
extendMode: boolean
|
||||
): Promise<Record<string, boolean>> {
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const tool of AI_TOOLS) {
|
||||
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
|
||||
// Fresh initialization - no tools configured yet
|
||||
if (!extendMode) {
|
||||
return Object.fromEntries(AI_TOOLS.map(t => [t.value, false]));
|
||||
}
|
||||
return states;
|
||||
|
||||
// Extend mode - check all tools in parallel for better performance
|
||||
const entries = await Promise.all(
|
||||
AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const)
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
private async isToolConfigured(
|
||||
projectPath: string,
|
||||
toolId: string
|
||||
): Promise<boolean> {
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (
|
||||
configFile &&
|
||||
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
|
||||
)
|
||||
return true;
|
||||
// A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers.
|
||||
// For tools with both config files and slash commands, BOTH must have markers.
|
||||
// For slash commands, at least one file with markers is sufficient (not all required).
|
||||
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (!slashConfigurator) return false;
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
const absolute = slashConfigurator.resolveAbsolutePath(
|
||||
projectPath,
|
||||
target.id
|
||||
);
|
||||
if (await FileSystemUtils.fileExists(absolute)) return true;
|
||||
// Helper to check if a file exists and contains OpenSpec markers
|
||||
const fileHasMarkers = async (absolutePath: string): Promise<boolean> => {
|
||||
try {
|
||||
const content = await FileSystemUtils.readFile(absolutePath);
|
||||
return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let hasConfigFile = false;
|
||||
let hasSlashCommands = false;
|
||||
|
||||
// Check if the tool has a config file with OpenSpec markers
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (configFile) {
|
||||
const configPath = path.join(projectPath, configFile);
|
||||
hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath));
|
||||
}
|
||||
|
||||
// Check if any slash command file exists with OpenSpec markers
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (slashConfigurator) {
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id);
|
||||
if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) {
|
||||
hasSlashCommands = true;
|
||||
break; // At least one file with markers is sufficient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool is only configured if BOTH exist with markers
|
||||
// OR if the tool has no config file requirement (slash commands only)
|
||||
// OR if the tool has no slash commands requirement (config file only)
|
||||
const hasConfigFileRequirement = configFile !== undefined;
|
||||
const hasSlashCommandRequirement = slashConfigurator !== undefined;
|
||||
|
||||
if (hasConfigFileRequirement && hasSlashCommandRequirement) {
|
||||
// Both are required - both must be present with markers
|
||||
return hasConfigFile && hasSlashCommands;
|
||||
} else if (hasConfigFileRequirement) {
|
||||
// Only config file required
|
||||
return hasConfigFile;
|
||||
} else if (hasSlashCommandRequirement) {
|
||||
// Only slash commands required
|
||||
return hasSlashCommands;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -675,6 +721,21 @@ export class InitCommand {
|
||||
private async generateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
await this.writeTemplateFiles(openspecPath, config, false);
|
||||
}
|
||||
|
||||
private async ensureTemplateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
await this.writeTemplateFiles(openspecPath, config, true);
|
||||
}
|
||||
|
||||
private async writeTemplateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig,
|
||||
skipExisting: boolean
|
||||
): Promise<void> {
|
||||
const context: ProjectContext = {
|
||||
// Could be enhanced with prompts for project details
|
||||
@@ -684,6 +745,12 @@ export class InitCommand {
|
||||
|
||||
for (const template of templates) {
|
||||
const filePath = path.join(openspecPath, template.path);
|
||||
|
||||
// Skip if file exists and we're in skipExisting mode
|
||||
if (skipExisting && (await FileSystemUtils.fileExists(filePath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content =
|
||||
typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
@@ -795,6 +862,22 @@ export class InitCommand {
|
||||
)
|
||||
);
|
||||
|
||||
// Show restart instruction if any tools were configured
|
||||
if (created.length > 0 || refreshed.length > 0) {
|
||||
console.log();
|
||||
console.log(PALETTE.white('Important: Restart your IDE'));
|
||||
console.log(
|
||||
PALETTE.midGray(
|
||||
'Slash commands are loaded at startup. Please restart your coding assistant'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.midGray(
|
||||
'to ensure the new /openspec commands appear in your command palette.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the selected tool name(s) for display
|
||||
const toolName = this.formatToolNames(selectedTools);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
|
||||
@@ -2,6 +2,7 @@ import { agentsTemplate } from './agents-template.js';
|
||||
import { projectTemplate, ProjectContext } from './project-template.js';
|
||||
import { claudeTemplate } from './claude-template.js';
|
||||
import { clineTemplate } from './cline-template.js';
|
||||
import { costrictTemplate } from './costrict-template.js';
|
||||
import { agentsRootStubTemplate } from './agents-root-stub.js';
|
||||
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
|
||||
|
||||
@@ -32,6 +33,10 @@ export class TemplateManager {
|
||||
return clineTemplate;
|
||||
}
|
||||
|
||||
static getCostrictTemplate(): string {
|
||||
return costrictTemplate;
|
||||
}
|
||||
|
||||
static getAgentsStandardTemplate(): string {
|
||||
return agentsRootStubTemplate;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ const baseGuardrails = `**Guardrails**
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to \`openspec/AGENTS.md\` (located inside the \`openspec/\` directory—run \`ls openspec\` or \`openspec update\` if you don't see it) if you need additional OpenSpec conventions or clarifications.`;
|
||||
|
||||
const proposalGuardrails = `${baseGuardrails}\n- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.`;
|
||||
const proposalGuardrails = `${baseGuardrails}\n- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
|
||||
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.`;
|
||||
|
||||
const proposalSteps = `**Steps**
|
||||
1. Review \`openspec/project.md\`, run \`openspec list\` and \`openspec list --specs\`, and inspect related code or docs (e.g., via \`rg\`/\`ls\`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||
@@ -16,6 +17,7 @@ const proposalSteps = `**Steps**
|
||||
6. Draft \`tasks.md\` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||
7. Validate with \`openspec validate <id> --strict\` and resolve every issue before sharing the proposal.`;
|
||||
|
||||
|
||||
const proposalReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` or \`openspec show <spec> --type spec\` to inspect details when validation fails.
|
||||
- Search existing requirements with \`rg -n "Requirement:|Scenario:" openspec/specs\` before writing new ones.
|
||||
|
||||
+620
-8
@@ -50,7 +50,7 @@ describe('InitCommand', () => {
|
||||
process.env.CODEX_HOME = path.join(testDir, '.codex');
|
||||
|
||||
// Mock console.log to suppress output during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => { });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -213,6 +213,50 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
|
||||
});
|
||||
|
||||
it('should create Antigravity workflows when Antigravity is selected', async () => {
|
||||
queueSelections('antigravity', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const agProposal = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-proposal.md'
|
||||
);
|
||||
const agApply = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-apply.md'
|
||||
);
|
||||
const agArchive = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(agProposal)).toBe(true);
|
||||
expect(await fileExists(agApply)).toBe(true);
|
||||
expect(await fileExists(agArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(agProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).not.toContain('auto_execution_mode');
|
||||
|
||||
const applyContent = await fs.readFile(agApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
expect(applyContent).not.toContain('auto_execution_mode');
|
||||
|
||||
const archiveContent = await fs.readFile(agArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
|
||||
expect(archiveContent).not.toContain('auto_execution_mode');
|
||||
});
|
||||
|
||||
it('should always create AGENTS.md in project root', async () => {
|
||||
queueSelections(DONE);
|
||||
|
||||
@@ -305,6 +349,126 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Gemini CLI TOML files when selected', async () => {
|
||||
queueSelections('gemini', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
const geminiApply = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/apply.toml'
|
||||
);
|
||||
const geminiArchive = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/archive.toml'
|
||||
);
|
||||
|
||||
expect(await fileExists(geminiProposal)).toBe(true);
|
||||
expect(await fileExists(geminiApply)).toBe(true);
|
||||
expect(await fileExists(geminiArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('prompt = """');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const applyContent = await fs.readFile(geminiApply, 'utf-8');
|
||||
expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(geminiArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing Gemini CLI TOML files with refreshed content', async () => {
|
||||
queueSelections('gemini', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
|
||||
// Modify the file to simulate user customization
|
||||
const originalContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
const modifiedContent = originalContent.replace(
|
||||
'<!-- OPENSPEC:START -->',
|
||||
'<!-- OPENSPEC:START -->\nCustom instruction added by user\n'
|
||||
);
|
||||
await fs.writeFile(geminiProposal, modifiedContent);
|
||||
|
||||
// Run init again to test update/refresh path
|
||||
queueSelections('gemini', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('**Guardrails**');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).not.toContain('Custom instruction added by user');
|
||||
});
|
||||
|
||||
it('should create IFlow CLI slash command files with templates', async () => {
|
||||
queueSelections('iflow', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const iflowProposal = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-proposal.md'
|
||||
);
|
||||
const iflowApply = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-apply.md'
|
||||
);
|
||||
const iflowArchive = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(iflowProposal)).toBe(true);
|
||||
expect(await fileExists(iflowApply)).toBe(true);
|
||||
expect(await fileExists(iflowArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(iflowProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const applyContent = await fs.readFile(iflowApply, 'utf-8');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(iflowArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing IFLOW.md with markers', async () => {
|
||||
queueSelections('iflow', DONE);
|
||||
|
||||
const iflowPath = path.join(testDir, 'IFLOW.md');
|
||||
const existingContent = '# My IFLOW Instructions\nCustom instructions here';
|
||||
await fs.writeFile(iflowPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(iflowPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create OpenCode slash command files with templates', async () => {
|
||||
queueSelections('opencode', DONE);
|
||||
|
||||
@@ -328,43 +492,103 @@ describe('InitCommand', () => {
|
||||
expect(await fileExists(openCodeArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(openCodeProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('agent: build');
|
||||
expect(proposalContent).not.toContain('agent:');
|
||||
expect(proposalContent).toContain(
|
||||
'description: Scaffold a new OpenSpec change and validate strictly.'
|
||||
);
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(openCodeApply, 'utf-8');
|
||||
expect(applyContent).toContain('agent: build');
|
||||
expect(applyContent).not.toContain('agent:');
|
||||
expect(applyContent).toContain(
|
||||
'description: Implement an approved OpenSpec change and keep tasks in sync.'
|
||||
);
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(openCodeArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('agent: build');
|
||||
expect(archiveContent).not.toContain('agent:');
|
||||
expect(archiveContent).toContain(
|
||||
'description: Archive a deployed OpenSpec change and update specs.'
|
||||
);
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Cline rule files with templates', async () => {
|
||||
it('should create Qwen configuration and slash command files with templates', async () => {
|
||||
queueSelections('qwen', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qwenConfigPath = path.join(testDir, 'QWEN.md');
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-proposal.toml'
|
||||
);
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.toml'
|
||||
);
|
||||
const archivePath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-archive.toml'
|
||||
);
|
||||
|
||||
expect(await fileExists(qwenConfigPath)).toBe(true);
|
||||
expect(await fileExists(proposalPath)).toBe(true);
|
||||
expect(await fileExists(applyPath)).toBe(true);
|
||||
expect(await fileExists(archivePath)).toBe(true);
|
||||
|
||||
const qwenConfigContent = await fs.readFile(qwenConfigPath, 'utf-8');
|
||||
expect(qwenConfigContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(qwenConfigContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
|
||||
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('prompt = """');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(applyPath, 'utf-8');
|
||||
expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(archivePath, 'utf-8');
|
||||
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing QWEN.md with markers', async () => {
|
||||
queueSelections('qwen', DONE);
|
||||
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
const existingContent = '# My Qwen Instructions\nCustom instructions here';
|
||||
await fs.writeFile(qwenPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create Cline workflow files with templates', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clineProposal = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-proposal.md'
|
||||
'.clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
const clineApply = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-apply.md'
|
||||
'.clinerules/workflows/openspec-apply.md'
|
||||
);
|
||||
const clineArchive = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-archive.md'
|
||||
'.clinerules/workflows/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(clineProposal)).toBe(true);
|
||||
@@ -579,6 +803,44 @@ describe('InitCommand', () => {
|
||||
await expect(initCommand.execute(testDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should recreate deleted openspec/AGENTS.md in extend mode', async () => {
|
||||
await testFileRecreationInExtendMode(
|
||||
testDir,
|
||||
initCommand,
|
||||
'openspec/AGENTS.md',
|
||||
'OpenSpec Instructions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should recreate deleted openspec/project.md in extend mode', async () => {
|
||||
await testFileRecreationInExtendMode(
|
||||
testDir,
|
||||
initCommand,
|
||||
'openspec/project.md',
|
||||
'Project Context'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing template files in extend mode', async () => {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
|
||||
// First init
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');
|
||||
const customContent = '# My Custom AGENTS Content\nDo not overwrite this!';
|
||||
|
||||
// Modify the file with custom content
|
||||
await fs.writeFile(agentsPath, customContent);
|
||||
|
||||
// Run init again - should NOT overwrite
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const content = await fs.readFile(agentsPath, 'utf-8');
|
||||
expect(content).toBe(customContent);
|
||||
expect(content).not.toContain('OpenSpec Instructions');
|
||||
});
|
||||
|
||||
it('should handle non-existent target directory', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
@@ -650,6 +912,18 @@ describe('InitCommand', () => {
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Qwen as already configured during extend mode', async () => {
|
||||
queueSelections('qwen', DONE, 'qwen', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const qwenChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'qwen'
|
||||
);
|
||||
expect(qwenChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should preselect Kilo Code when workflows already exist', async () => {
|
||||
queueSelections('kilocode', DONE, 'kilocode', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
@@ -672,6 +946,18 @@ describe('InitCommand', () => {
|
||||
expect(wsChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Antigravity as already configured during extend mode', async () => {
|
||||
queueSelections('antigravity', DONE, 'antigravity', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const antigravityChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'antigravity'
|
||||
);
|
||||
expect(antigravityChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Codex as already configured during extend mode', async () => {
|
||||
queueSelections('codex', DONE, 'codex', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
@@ -957,6 +1243,225 @@ describe('InitCommand', () => {
|
||||
);
|
||||
expect(crushChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CoStrict slash command files with templates', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const costrictProposal = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
const costrictApply = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-apply.md'
|
||||
);
|
||||
const costrictArchive = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(costrictProposal)).toBe(true);
|
||||
expect(await fileExists(costrictApply)).toBe(true);
|
||||
expect(await fileExists(costrictArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(costrictProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('argument-hint: feature description or request');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(costrictApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('argument-hint: change-id');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(costrictArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('argument-hint: change-id');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark CoStrict as already configured during extend mode', async () => {
|
||||
queueSelections('costrict', DONE, 'costrict', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const costrictChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'costrict'
|
||||
);
|
||||
expect(costrictChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create RooCode slash command files with templates', async () => {
|
||||
queueSelections('roocode', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const rooProposal = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-proposal.md'
|
||||
);
|
||||
const rooApply = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-apply.md'
|
||||
);
|
||||
const rooArchive = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(rooProposal)).toBe(true);
|
||||
expect(await fileExists(rooApply)).toBe(true);
|
||||
expect(await fileExists(rooArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(rooProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('# OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(rooApply, 'utf-8');
|
||||
expect(applyContent).toContain('# OpenSpec: Apply');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(rooArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('# OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark RooCode as already configured during extend mode', async () => {
|
||||
queueSelections('roocode', DONE, 'roocode', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const rooChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'roocode'
|
||||
);
|
||||
expect(rooChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create Qoder slash command files with templates', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qoderProposal = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
const qoderApply = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/apply.md'
|
||||
);
|
||||
const qoderArchive = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(qoderProposal)).toBe(true);
|
||||
expect(await fileExists(qoderApply)).toBe(true);
|
||||
expect(await fileExists(qoderArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(qoderProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('name: OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('category: OpenSpec');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(qoderApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('name: OpenSpec: Apply');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(qoderArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark Qoder as already configured during extend mode', async () => {
|
||||
queueSelections('qoder', DONE, 'qoder', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const qoderChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'qoder'
|
||||
);
|
||||
expect(qoderChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create COSTRICT.md when CoStrict is selected', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
expect(await fileExists(costrictPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should create QODER.md when Qoder is selected', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qoderPath = path.join(testDir, 'QODER.md');
|
||||
expect(await fileExists(qoderPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
it('should update existing COSTRICT.md with markers', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
const existingContent =
|
||||
'# My CoStrict Instructions\nCustom instructions here';
|
||||
await fs.writeFile(costrictPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('# My CoStrict Instructions');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should update existing QODER.md with markers', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
const qoderPath = path.join(testDir, 'QODER.md');
|
||||
const existingContent =
|
||||
'# My Qoder Instructions\nCustom instructions here';
|
||||
await fs.writeFile(qoderPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
@@ -1051,6 +1556,87 @@ describe('InitCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('already configured detection', () => {
|
||||
it('should NOT show tools as already configured in fresh project with existing CLAUDE.md', async () => {
|
||||
// Simulate user having their own CLAUDE.md before running openspec init
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(claudePath, '# My Custom Claude Instructions\n');
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// In the first run (non-interactive mode via queueSelections),
|
||||
// the prompt is called with configured: false for claude
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const claudeChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT show tools as already configured in fresh project with existing slash commands', async () => {
|
||||
// Simulate user having their own custom slash commands
|
||||
const customCommandDir = path.join(testDir, '.claude/commands/custom');
|
||||
await fs.mkdir(customCommandDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(customCommandDir, 'mycommand.md'),
|
||||
'# My Custom Command\n'
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const claudeChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should show tools as already configured in extend mode', async () => {
|
||||
// First initialization
|
||||
queueSelections('claude', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// Second initialization (extend mode)
|
||||
queueSelections('cursor', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondCallArgs = mockPrompt.mock.calls[1][0];
|
||||
const claudeChoice = secondCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT show already configured for Codex in fresh init even with global prompts', async () => {
|
||||
// Create global Codex prompts (simulating previous installation)
|
||||
const codexPromptsDir = path.join(testDir, '.codex/prompts');
|
||||
await fs.mkdir(codexPromptsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexPromptsDir, 'openspec-proposal.md'),
|
||||
'# Existing prompt\n'
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const codexChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'codex'
|
||||
);
|
||||
|
||||
// In fresh init, even global tools should not show as configured
|
||||
expect(codexChoice.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should provide helpful error for insufficient permissions', async () => {
|
||||
// This is tricky to test cross-platform, but we can test the error message
|
||||
@@ -1079,6 +1665,32 @@ describe('InitCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function testFileRecreationInExtendMode(
|
||||
testDir: string,
|
||||
initCommand: InitCommand,
|
||||
relativePath: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
|
||||
// First init
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const filePath = path.join(testDir, relativePath);
|
||||
expect(await fileExists(filePath)).toBe(true);
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(filePath);
|
||||
expect(await fileExists(filePath)).toBe(false);
|
||||
|
||||
// Run init again - should recreate the file
|
||||
await initCommand.execute(testDir);
|
||||
expect(await fileExists(filePath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content).toContain(expectedContent);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
|
||||
+582
-3
@@ -73,6 +73,41 @@ More content after.`;
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should update only existing QWEN.md file', async () => {
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
const initialContent = `# Qwen Instructions
|
||||
|
||||
Some existing content.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More notes here.`;
|
||||
await fs.writeFile(qwenPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing content.');
|
||||
expect(updatedContent).toContain('More notes here.');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: QWEN.md');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Claude slash command files', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
@@ -114,6 +149,83 @@ Old slash content
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Qwen slash command files', async () => {
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.toml'
|
||||
);
|
||||
await fs.mkdir(path.dirname(applyPath), { recursive: true });
|
||||
const initialContent = `description = "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
|
||||
prompt = """
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->
|
||||
"""
|
||||
`;
|
||||
await fs.writeFile(applyPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(applyPath, 'utf-8');
|
||||
expect(updated).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(updated).toContain('prompt = """');
|
||||
expect(updated).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updated).toContain('Work through tasks sequentially');
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .qwen/commands/openspec-apply.toml'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing Qwen slash command files on update', async () => {
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.toml'
|
||||
);
|
||||
|
||||
await fs.mkdir(path.dirname(applyPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
applyPath,
|
||||
`description = "Old description"
|
||||
|
||||
prompt = """
|
||||
<!-- OPENSPEC:START -->
|
||||
Old content
|
||||
<!-- OPENSPEC:END -->
|
||||
"""
|
||||
`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updatedApply = await fs.readFile(applyPath, 'utf-8');
|
||||
expect(updatedApply).toContain('Work through tasks sequentially');
|
||||
expect(updatedApply).not.toContain('Old content');
|
||||
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-proposal.toml'
|
||||
);
|
||||
const archivePath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-archive.toml'
|
||||
);
|
||||
|
||||
await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(archivePath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should not create CLAUDE.md if it does not exist', async () => {
|
||||
// Ensure CLAUDE.md does not exist
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
@@ -126,6 +238,12 @@ Old slash content
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should not create QWEN.md if it does not exist', async () => {
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
await updateCommand.execute(testDir);
|
||||
await expect(FileSystemUtils.fileExists(qwenPath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should update only existing CLINE.md file', async () => {
|
||||
// Create CLINE.md file with initial content
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
@@ -176,10 +294,10 @@ More rules after.`;
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Cline rule files', async () => {
|
||||
it('should refresh existing Cline workflow files', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-proposal.md'
|
||||
'.clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
const initialContent = `# OpenSpec: Proposal
|
||||
@@ -209,7 +327,7 @@ Old slash content
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .clinerules/openspec-proposal.md'
|
||||
'Updated slash commands: .clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -345,6 +463,38 @@ Old body
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Antigravity workflows', async () => {
|
||||
const agPath = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-apply.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(agPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(agPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(agPath, 'utf-8');
|
||||
expect(updated).toContain('Work through tasks sequentially');
|
||||
expect(updated).not.toContain('Old body');
|
||||
expect(updated).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(updated).not.toContain('auto_execution_mode: 3');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .agent/workflows/openspec-apply.md'
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Codex prompts', async () => {
|
||||
const codexPath = path.join(
|
||||
testDir,
|
||||
@@ -466,6 +616,101 @@ Old body
|
||||
await expect(FileSystemUtils.fileExists(ghArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Gemini CLI TOML files without creating new ones', async () => {
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
await fs.mkdir(path.dirname(geminiProposal), { recursive: true });
|
||||
const initialContent = `description = "Scaffold a new OpenSpec change and validate strictly."
|
||||
|
||||
prompt = """
|
||||
<!-- OPENSPEC:START -->
|
||||
Old Gemini body
|
||||
<!-- OPENSPEC:END -->
|
||||
"""
|
||||
`;
|
||||
await fs.writeFile(geminiProposal, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(updated).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(updated).toContain('prompt = """');
|
||||
expect(updated).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updated).not.toContain('Old Gemini body');
|
||||
|
||||
const geminiApply = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/apply.toml'
|
||||
);
|
||||
const geminiArchive = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/archive.toml'
|
||||
);
|
||||
|
||||
await expect(FileSystemUtils.fileExists(geminiApply)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(geminiArchive)).resolves.toBe(false);
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing IFLOW slash commands', async () => {
|
||||
const iflowProposal = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(iflowProposal), { recursive: true });
|
||||
const initialContent = `description: Scaffold a new OpenSpec change and validate strictly."
|
||||
|
||||
prompt = """
|
||||
<!-- OPENSPEC:START -->
|
||||
Old IFlow body
|
||||
<!-- OPENSPEC:END -->
|
||||
"""
|
||||
`;
|
||||
await fs.writeFile(iflowProposal, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(iflowProposal, 'utf-8');
|
||||
expect(updated).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(updated).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updated).not.toContain('Old IFlow body');
|
||||
|
||||
const iflowApply = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-apply.md'
|
||||
);
|
||||
const iflowArchive = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
await expect(FileSystemUtils.fileExists(iflowApply)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(iflowArchive)).resolves.toBe(false);
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .iflow/commands/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Factory slash commands', async () => {
|
||||
const factoryPath = path.join(
|
||||
testDir,
|
||||
@@ -819,6 +1064,340 @@ Old body
|
||||
await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing CoStrict slash command files', async () => {
|
||||
const costrictPath = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(costrictPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
description: "Old description"
|
||||
argument-hint: old-hint
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(costrictPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(costrictPath, 'utf-8');
|
||||
// For slash commands, only the content between OpenSpec markers is updated
|
||||
expect(updated).toContain('description: "Old description"');
|
||||
expect(updated).toContain('argument-hint: old-hint');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Qoder slash command files', async () => {
|
||||
const qoderPath = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(qoderPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old slash content
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(qoderPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing RooCode slash command files', async () => {
|
||||
const rooPath = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(rooPath), { recursive: true });
|
||||
const initialContent = `# OpenSpec: Proposal
|
||||
|
||||
Old description
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(rooPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(rooPath, 'utf-8');
|
||||
// For RooCode, the header is Markdown, preserve it and update only managed block
|
||||
expect(updated).toContain('# OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .roo/commands/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing RooCode slash command files on update', async () => {
|
||||
const rooApply = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(rooApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
rooApply,
|
||||
`# OpenSpec: Apply
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const rooProposal = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-proposal.md'
|
||||
);
|
||||
const rooArchive = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(rooProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(rooArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should not create missing CoStrict slash command files on update', async () => {
|
||||
const costrictApply = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(costrictApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
costrictApply,
|
||||
`---
|
||||
description: "Old"
|
||||
argument-hint: old
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const costrictProposal = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
const costrictArchive = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(costrictProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(costrictArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should not create missing Qoder slash command files on update', async () => {
|
||||
const qoderApply = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(qoderApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
qoderApply,
|
||||
`---
|
||||
name: OpenSpec: Apply
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const qoderProposal = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
const qoderArchive = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(qoderProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(qoderArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should update only existing COSTRICT.md file', async () => {
|
||||
// Create COSTRICT.md file with initial content
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
const initialContent = `# CoStrict Instructions
|
||||
|
||||
Some existing CoStrict instructions here.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More instructions after.`;
|
||||
await fs.writeFile(costrictPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that COSTRICT.md was updated
|
||||
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing CoStrict instructions here');
|
||||
expect(updatedContent).toContain('More instructions after');
|
||||
|
||||
// Check console output
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: COSTRICT.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
it('should not create COSTRICT.md if it does not exist', async () => {
|
||||
// Ensure COSTRICT.md does not exist
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that COSTRICT.md was not created
|
||||
const fileExists = await FileSystemUtils.fileExists(costrictPath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve CoStrict content outside markers during update', async () => {
|
||||
const costrictPath = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(costrictPath), { recursive: true });
|
||||
const initialContent = `## Custom Intro Title\nSome intro text\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->\n\nFooter stays`;
|
||||
await fs.writeFile(costrictPath, initialContent);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updated).toContain('## Custom Intro Title');
|
||||
expect(updated).toContain('Footer stays');
|
||||
expect(updated).not.toContain('Old body');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
|
||||
});
|
||||
|
||||
it('should handle configurator errors gracefully for CoStrict', async () => {
|
||||
// Create COSTRICT.md file but make it read-only to cause an error
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
await fs.writeFile(
|
||||
costrictPath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
const errorSpy = vi.spyOn(console, 'error');
|
||||
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
|
||||
const writeSpy = vi
|
||||
.spyOn(FileSystemUtils, 'writeFile')
|
||||
.mockImplementation(async (filePath, content) => {
|
||||
if (filePath.endsWith('COSTRICT.md')) {
|
||||
throw new Error('EACCES: permission denied, open');
|
||||
}
|
||||
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
|
||||
// Execute update command - should not throw
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report the failure
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Failed to update: COSTRICT.md');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should preserve Windsurf content outside markers during update', async () => {
|
||||
const wsPath = path.join(
|
||||
testDir,
|
||||
|
||||
Reference in New Issue
Block a user