Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 31c78a5e38 docs: add multi-language output guide
Document how to configure OpenSpec to generate artifacts in languages
other than English using the existing context field in config.yaml.

Includes examples for Portuguese, Spanish, Chinese, Japanese, French,
and German, plus tips for handling technical terminology.
2026-01-20 00:55:26 -08:00
Tabish Bidiwale 8332a09811 fix(ci): use workflow_dispatch for polish release notes (#533)
* fix(ci): use workflow_dispatch for polish release notes

The claude-code-action doesn't support the `release` event type.
Switch to workflow_dispatch which is supported, and have
release-prepare trigger it after publishing.

* fix: get tag from package.json instead of gh release list
2026-01-20 00:36:24 -08:00
Tabish Bidiwale d61a49f6d5 fix(changelog): convert markdown headers to bold text for proper formatting (#532)
Changeset descriptions that use markdown headers (### New Features) get
nested inside list items, causing poor rendering. This converts all
affected entries to use **Bold Text** instead, which renders correctly.

Also adds a brief summary line to each entry for better readability.
2026-01-20 00:09:49 -08:00
openspec-release-bot[bot]andgithub-actions[bot] 7d1237f00d Version Packages (#531)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-20 00:01:09 -08:00
Tabish Bidiwale 33466b1e2a Add changeset for project config and schema commands (#530) 2026-01-19 23:58:09 -08:00
Tabish Bidiwale 6c8c778043 fix(config): handle null rules field in project config (#529)
* feat(config): add project-level configuration via openspec/config.yaml

Adds openspec/config.yaml support for project-level customization without
forking schemas. Teams can now:

- Set a default schema (used when --schema flag not provided)
- Inject project context into all artifact instructions
- Add per-artifact rules (e.g., proposal rules, specs rules)

Key changes:
- New `src/core/project-config.ts` with Zod schema and resilient parsing
- New `src/core/config-prompts.ts` for interactive config creation
- Updated schema resolution order: CLI → change metadata → config → default
- Updated instruction generation to inject <context> and <rules> XML sections
- Integrated config creation prompts into `artifact-experimental-setup`

Schema resolution precedence:
1. --schema CLI flag (explicit override)
2. .openspec.yaml in change directory (change-specific)
3. openspec/config.yaml schema field (project default)
4. "spec-driven" (hardcoded fallback)

* test(config): add e2e tests and performance benchmarks for project config

- Add project config integration tests in artifact-workflow.test.ts:
  - Test new change uses schema from config
  - Test CLI schema overrides config schema
  - Test context and rules injection in instructions
  - Test backwards compatibility without config file
  - Test immediate reflection of config changes

- Add performance benchmark tests in project-config.test.ts:
  - Typical config (1KB): <20ms target
  - Large config (50KB): <50ms target
  - Repeated reads consistency check
  - Missing config fast-path test

- Add project configuration documentation in experimental-workflow.md:
  - Config fields reference (schema, context, rules)
  - Schema precedence explanation
  - Artifact IDs by schema
  - Troubleshooting guide

- Mark all tasks complete in tasks.md

* refactor(config): remove benchmark tests, document decision in source

Remove performance benchmark tests from project-config.test.ts and
document the results directly in the source code instead. Benchmarks
showed config reads are fast enough (~0.5ms typical) that caching
is unnecessary.

* fix(config): resolve three issues in project config feature

- Remove nested <template> tags: generateInstructions() no longer wraps
  template content since printInstructionsText() already handles XML
  structure
- Fix schema message accuracy: newChangeCommand() now uses the resolved
  schema returned from createChange() instead of hardcoded fallback
- Add TTY check: artifact-experimental-setup skips interactive prompts
  in non-TTY environments (CI, automation) to prevent hangs

* fix(config): handle null rules field in project config

Add null check when parsing rules field since YAML `rules:` with no
value parses to null, and `typeof null === 'object'` in JavaScript.
Without this check, Object.entries(null) would throw an error.
2026-01-19 23:49:11 -08:00
Tabish Bidiwale 43b01ad374 docs: update workflow docs and mark schema commands as experimental (#526)
* docs: update workflow docs for schema management CLI

Update documentation to reflect implemented schema management features:
- Document schema CLI commands (which, validate, fork, init)
- Update gap summary to show completed phases (PR #522, #525)
- Improve custom schema examples with actual CLI usage
- Update resolution order documentation

* feat(cli): mark schema commands as experimental

Add [experimental] tag to help description and runtime warning
for schema management commands to indicate they may change.
2026-01-19 20:41:15 -08:00
Tabish Bidiwale 3cdcdfca8e feat(cli): add schema management commands (#525)
Add `openspec schema` command group with subcommands for managing
workflow schemas:

- `schema which [name]` - Show where a schema resolves from with
  shadow detection across project/user/package locations
- `schema validate [name]` - Validate schema structure, templates,
  and dependency graph
- `schema fork <source> [name]` - Copy an existing schema to project
  for customization
- `schema init <name>` - Create a new project-local schema with
  interactive or CLI-driven configuration

All commands support `--json` output for scripting. The init command
supports interactive prompts for description and artifact selection.

Implements the schema-management-cli change proposal.
2026-01-19 19:42:37 -08:00
Tabish Bidiwale 32fc19a60d fix: Windows path compatibility in resolver tests (#524)
- Use path.join() in test expectations instead of hardcoded forward slashes
- Add openspec/config.yaml with cross-platform requirements to prevent
  similar issues in future proposals

The tests were failing on Windows because path.join() uses backslashes
on Windows, but the test expectations hardcoded forward slashes.
2026-01-19 18:42:00 -08:00
Tabish Bidiwale 84f372517f change(schema-management-cli): proposal for schema management commands (#523)
* change(schema-management-cli): add proposal for schema management commands

Propose new CLI commands to improve the UX of creating and managing project schemas:

- `openspec schema init <name>` - Interactive wizard to scaffold new schemas
- `openspec schema fork <source> [name]` - Copy existing schema for customization
- `openspec schema validate [name]` - Validate schema structure before runtime
- `openspec schema which <name>` - Debug schema resolution path

* change(schema-management-cli): add design, tasks, and specs artifacts

Add implementation artifacts for the schema management CLI feature:
- Design document with decisions and rationale
- Task breakdown for implementation
- Specs for schema init, fork, validate, and which commands
2026-01-19 18:22:44 -08:00
Tabish Bidiwale adda63e17a feat(resolver): add project-local schema support (#522)
Add 3-level schema resolution: project-local → user override → package built-in.

- Add `getProjectSchemasDir(projectRoot)` to resolve project schemas at `./openspec/schemas/<name>/`
- Extend `SchemaInfo.source` type to include `'project'`
- Update `getSchemaDir()`, `resolveSchema()`, `listSchemas()`, `listSchemasWithInfo()` with optional `projectRoot` parameter
- Update CLI commands to display schema source labels (project/user/package)
- Add `projectRoot` to `ChangeContext` interface for proper resolution throughout workflow
- Add 17 new tests covering project-local schema resolution

This enables projects to define custom schemas that override user and package schemas,
while maintaining backward compatibility when projectRoot is not provided.
2026-01-19 18:01:35 -08:00
Tabish Bidiwale 90d05b7115 docs: add project-config demo guide (#521)
Add a quick-reference demo guide for the project-config feature
(openspec/config.yaml). This consolidates the demo walkthrough
into a standalone document that's easier to use when presenting
the feature.

Includes:
- Summary of what project config does
- 6 numbered demo scenarios
- Quick all-in-one demo script
- Key points to emphasize
2026-01-19 16:56:12 -08:00
Tabish Bidiwale 20714c1c28 feat(config): add project-level configuration via openspec/config.yaml (#499)
* feat(config): add project-level configuration via openspec/config.yaml

Adds openspec/config.yaml support for project-level customization without
forking schemas. Teams can now:

- Set a default schema (used when --schema flag not provided)
- Inject project context into all artifact instructions
- Add per-artifact rules (e.g., proposal rules, specs rules)

Key changes:
- New `src/core/project-config.ts` with Zod schema and resilient parsing
- New `src/core/config-prompts.ts` for interactive config creation
- Updated schema resolution order: CLI → change metadata → config → default
- Updated instruction generation to inject <context> and <rules> XML sections
- Integrated config creation prompts into `artifact-experimental-setup`

Schema resolution precedence:
1. --schema CLI flag (explicit override)
2. .openspec.yaml in change directory (change-specific)
3. openspec/config.yaml schema field (project default)
4. "spec-driven" (hardcoded fallback)

* test(config): add e2e tests and performance benchmarks for project config

- Add project config integration tests in artifact-workflow.test.ts:
  - Test new change uses schema from config
  - Test CLI schema overrides config schema
  - Test context and rules injection in instructions
  - Test backwards compatibility without config file
  - Test immediate reflection of config changes

- Add performance benchmark tests in project-config.test.ts:
  - Typical config (1KB): <20ms target
  - Large config (50KB): <50ms target
  - Repeated reads consistency check
  - Missing config fast-path test

- Add project configuration documentation in experimental-workflow.md:
  - Config fields reference (schema, context, rules)
  - Schema precedence explanation
  - Artifact IDs by schema
  - Troubleshooting guide

- Mark all tasks complete in tasks.md

* refactor(config): remove benchmark tests, document decision in source

Remove performance benchmark tests from project-config.test.ts and
document the results directly in the source code instead. Benchmarks
showed config reads are fast enough (~0.5ms typical) that caching
is unnecessary.

* fix(config): resolve three issues in project config feature

- Remove nested <template> tags: generateInstructions() no longer wraps
  template content since printInstructionsText() already handles XML
  structure
- Fix schema message accuracy: newChangeCommand() now uses the resolved
  schema returned from createChange() instead of hardcoded fallback
- Add TTY check: artifact-experimental-setup skips interactive prompts
  in non-TTY environments (CI, automation) to prevent hangs
2026-01-19 16:39:43 -08:00
Tabish Bidiwale 2e51ae26d3 fix: auto-trigger polish release notes on release publish (#519)
* perf: add path filtering to Nix validation CI job

Skip Nix flake validation when no Nix-related files change. This speeds
up CI for PRs that only touch source code, docs, or tests.

Files that trigger Nix validation:
- flake.nix, flake.lock
- package.json, pnpm-lock.yaml
- scripts/update-flake.sh
- .github/workflows/ci.yml

Uses dorny/paths-filter@v3 for change detection. Required-checks jobs
updated to handle skipped status correctly.

* fix: auto-trigger polish release notes on release publish

The workflow was only set up for manual dispatch, requiring someone to
remember to run it after each release. This change adds an automatic
trigger on `release: [published]` events while keeping the manual
trigger as a fallback.

The Claude Code Action supports any GitHub event when using the `prompt`
parameter for custom automations.
2026-01-19 01:28:45 -08:00
Tabish Bidiwale dbd4ed7bfb perf: add path filtering to Nix validation CI job (#518)
Skip Nix flake validation when no Nix-related files change. This speeds
up CI for PRs that only touch source code, docs, or tests.

Files that trigger Nix validation:
- flake.nix, flake.lock
- package.json, pnpm-lock.yaml
- scripts/update-flake.sh
- .github/workflows/ci.yml

Uses dorny/paths-filter@v3 for change detection. Required-checks jobs
updated to handle skipped status correctly.
2026-01-19 01:22:33 -08:00
openspec-release-bot[bot]andgithub-actions[bot] 473093f885 Version Packages (#517)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-19 00:59:49 -08:00
Tabish Bidiwale b5a884748b Add changeset for v0.21 release (#516) 2026-01-19 00:56:17 -08:00
Tabish Bidiwale 690c75225c fix: prevent implementation during explore mode (#515)
Add explicit instructions to both the explore skill and slash command:
- Add IMPORTANT notice clarifying explore is for thinking, not implementing
- Add "Open threads, not interrogations" stance for user-driven exploration
- Add "Don't implement" guardrail to both templates

Creating OpenSpec artifacts is still allowed since that captures thinking.
2026-01-19 00:28:45 -08:00
Tabish Bidiwale dd53fb7736 OPSX apply: infer target change (#513)
* opsx: infer change for apply

* opsx: prompt when apply ambiguous

* opsx: use AskUserQuestion when ambiguous

* Simplify opsx:apply change selection instructions

Update all change selection instructions across all opsx commands
(apply, continue, sync, archive, verify, ff) to use consistent wording:

"If omitted, check if it can be inferred from conversation context.
If vague or ambiguous you MUST prompt for available changes."

For apply specifically, also simplifies Step 1 from ~180 to ~60 words
while preserving the same behavior: infer from conversation, auto-select
if single change, prompt via AskUserQuestion if ambiguous, always announce.

Removes micromanagement details (validation commands, recommendation
markers, presentation specifics) and trusts the LLM to figure out
reasonable defaults.
2026-01-18 19:44:56 -08:00
Tabish Bidiwale 2a441c472d Refine opsx archive sync assessment (#514)
* Refine opsx archive sync assessment

* Simplify opsx archive sync instructions

Replace verbose algorithmic instructions with intent-focused guidance.
Trust the agent to figure out comparison logic rather than specifying
the exact algorithm for each delta type (ADDED, MODIFIED, etc).

Reduces section 4 from ~31 lines to ~13 lines per template.

* Add explicit paths for delta spec locations

Make it clear where agents should look for delta specs and main specs:
- Delta specs: openspec/changes/<name>/specs/
- Main specs: openspec/specs/<capability>/spec.md
2026-01-18 19:35:04 -08:00
Pim SnelandTabish Bidiwale ed4d965208 feat: add nix flake support (sorry for this duplicate) (#459)
* add nix flake support

* feat: add Nix flake maintenance automation

* Add Nix Flake CI Validation

* fix updatescript, update flake

* make update-script compatible with macos

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-16 13:00:11 -08:00
Tabish Bidiwale c86985d6ec feat: add feedback command for submitting user feedback (#509)
* feat: add feedback command for submitting user feedback

Implement `openspec feedback` command that creates GitHub Issues using the gh CLI.
Includes graceful fallback to manual submission when gh is not available or
not authenticated.

Features:
- Automatic gh CLI detection and authentication check
- Graceful fallback with pre-filled issue URLs for manual submission
- Automatic metadata inclusion (version, platform, timestamp)
- /feedback skill for agent-assisted feedback with context enrichment
- Comprehensive test coverage with mocked gh CLI calls

* fix: address PR review comments for feedback command

- Add Windows compatibility: use 'where gh' on Windows, 'which gh' on Unix/macOS
- Fix shell injection vulnerability: replace execSync with execFileSync and argument arrays
- Fix British English phrasing: "in future" → "in the future"
- Reduce code duplication: extract formatTitle/formatBody to single location
- Update tests to verify execFileSync usage and cross-platform command detection
- Add spec scenarios for safe command execution and cross-platform support
2026-01-14 15:48:29 -08:00
Tabish Bidiwale bf4bc2426f fix: add auto-approval for file writes in polish-release-notes workflow (#505)
The workflow was failing because Claude Code requested permission to write files
(release-title.txt and polished-notes.md) but there was no interactive user to approve.

Added claude_args: "--allowedTools Write,Read" to pre-approve file operations
in automation mode.
2026-01-13 23:09:37 -08:00
Tabish Bidiwale c57e421cc2 fix: update polish-release-notes workflow to use correct Claude Code action parameters (#504)
The workflow was failing due to two issues:
1. Using unsupported 'release' event trigger - Claude Code action doesn't support this event type
2. Using deprecated 'direct_prompt' parameter instead of 'prompt'

Changes:
- Switch from 'release' trigger to 'workflow_dispatch' for manual triggering
- Replace 'direct_prompt' with 'prompt' parameter (v1.0 breaking change)
- Update all tag references from github.event.release.tag_name to inputs.tag_name

The workflow now needs to be manually triggered from GitHub Actions UI after a release is published.
2026-01-13 23:02:22 -08:00
openspec-release-bot[bot]andgithub-actions[bot] ed2e832066 Version Packages (#503)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-13 22:50:11 -08:00
Tabish Bidiwale 9db74aa5ac chore: add changeset for opsx:verify skill and bug fixes (#502)
* Add changeset for opsx:verify skill and bug fixes

* Update changeset to follow template format
2026-01-13 22:45:23 -08:00
Tabish Bidiwale b5b7248610 feat(skills): implement /opsx:verify skill for validating change implementations (#501)
Add comprehensive verification skill that checks implementation completeness, correctness, and coherence against change artifacts. The skill validates task completion, spec coverage, requirement implementation, and design adherence.
2026-01-13 22:38:49 -08:00
Tabish Bidiwale 322bfd455a fix(vitest): cap worker parallelism to prevent process storms (#500)
Vitest v3 defaults to `pool: "forks"` and scales worker processes with
CPU count. This repo's tests spawn many Node processes (CLI invocations,
temp FS operations), which can cause runaway CPU/memory usage when
combined with high parallelism.

Changes:
- Explicitly set pool to 'forks' (tests rely on process isolation)
- Add resolveMaxWorkers() to cap workers at min(4, availableCPUs)
- Allow VITEST_MAX_WORKERS env override for CI/automation tuning
2026-01-13 21:41:25 -08:00
Tabish Bidiwale 08c349369a docs: add MAINTAINERS.md with core maintainers and advisors (#495) 2026-01-13 21:05:28 -08:00
Tabish Bidiwale 40afee643e chore(openspec): add feedback command change proposal (#496)
Add change proposal for `openspec feedback` CLI command that enables
users and agents to submit feedback via GitHub Issues.

Key features:
- Simple `openspec feedback <message>` command
- GitHub Device OAuth for authentication
- `/feedback` skill for agent-assisted feedback with context enrichment
- Anonymization of sensitive data before submission
- User confirmation required before submitting
2026-01-13 15:39:55 -08:00
Tabish Bidiwale 05023dab43 feat(skills): add /opsx:verify change proposal (#497)
Add change proposal for a new verification skill that validates
implementation matches change artifacts (specs, tasks, design).

The skill verifies three dimensions:
- Completeness: all tasks done, all specs addressed
- Correctness: implementation matches specs and scenarios
- Coherence: follows design decisions and project patterns

Produces prioritized report with actionable fix suggestions.
2026-01-13 15:26:31 -08:00
Tabish Bidiwale d7a928b4e9 fix(agents): add --no-interactive to validate commands in agent workflows (#494)
AI agents following OpenSpec workflows would hit interactive prompts when
running `openspec validate` because the commands in AGENTS.md and slash
command templates didn't include the --no-interactive flag.

This caused hangs in LLM tool execution since agents run in pseudo-TTY
environments where process.stdin.isTTY returns true, triggering
interactive mode.

Fixes #492
2026-01-13 14:50:43 -08:00
Nob ShinjoandTabish Bidiwale 07dd634986 fix(powershell-generator): remove trailing comma from last entry (#485)
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-13 13:50:11 -08:00
Tabish Bidiwale 36078b1947 chore: improve release notes pipeline (#481)
* chore: improve changelog generation with GitHub integration

- Switch to @changesets/changelog-github for PR/author links in CHANGELOG.md
- Add comprehensive changeset README with template and contributor guidance
- Remove release:local script (CI-only releases)

* ci: add AI-powered release notes polishing

Transforms raw changelog into user-friendly release notes when a
GitHub Release is published. Uses Claude Code Action to:
- Generate concise release title (e.g., "v0.18.0 - OPSX Workflow")
- Rewrite changelog as developer-friendly release notes
- Remove noise (commit hashes, PR numbers, internal changes)

Requires CLAUDE_CODE_OAUTH_TOKEN secret (from claude setup-token).
2026-01-10 22:33:43 -08:00
Tabish Bidiwale d0e1b076c2 chore: trigger release workflow for v0.19.0 (#480) 2026-01-10 18:20:30 -08:00
Tabish Bidiwale 2fbda520de ci: remove auto-merge to fix release triggering (#479)
GitHub's auto-merge feature uses an internal token that cannot trigger
workflows. Even with manual approval, the merge performed by auto-merge
doesn't trigger the release workflow.

Removing auto-merge means:
1. Version PR is created with CI running
2. User manually merges the PR (clicking "Merge")
3. The merge triggers the release workflow
4. Package is published

This aligns with industry standard practice for changesets.
2026-01-10 18:18:09 -08:00
github-actions[bot] 5633556b6d Version Packages (#475)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-10 18:04:55 -08:00
Tabish Bidiwale 2bb0ed36c5 ci: pass GitHub App token to checkout for CI triggering (#478)
Move the GitHub App token generation before checkout and pass it
to actions/checkout. This configures git to use the App's identity
for all operations, allowing pushes to trigger CI workflows.

Removes commitMode: github-api which doesn't support executable files.
2026-01-10 18:01:05 -08:00
Tabish Bidiwale 06097f9cb7 ci: add commitMode github-api to trigger CI on version PR (#477)
* ci: use GitHub App token to trigger CI on version PR

Replace GITHUB_TOKEN with a GitHub App token so that the version PR
can trigger CI workflows. GITHUB_TOKEN cannot trigger workflows by
design (to prevent infinite loops).

Requires APP_ID variable and APP_PRIVATE_KEY secret to be configured.

* ci: upgrade create-github-app-token to v2

* ci: add commitMode github-api to trigger CI on version PR
2026-01-10 17:50:59 -08:00
Tabish Bidiwale 8f5a526396 ci: use GitHub App token to trigger CI on version PR (#476)
* ci: use GitHub App token to trigger CI on version PR

Replace GITHUB_TOKEN with a GitHub App token so that the version PR
can trigger CI workflows. GITHUB_TOKEN cannot trigger workflows by
design (to prevent infinite loops).

Requires APP_ID variable and APP_PRIVATE_KEY secret to be configured.

* ci: upgrade create-github-app-token to v2
2026-01-10 17:38:57 -08:00
Tabish Bidiwale eb152eb2ca ci: auto-merge version PR to streamline releases (#474)
* Add changeset for Continue support, shell completions, and explore command

* ci: auto-merge version PR to streamline releases

Enable auto-merge on the changesets version PR so it merges
automatically once CI passes. This reduces the release process
from 2 manual PR merges to effectively 1.

Requires enabling "Allow auto-merge" in repository settings
and branch protection rules on main.
2026-01-10 16:22:25 -08:00
e987a5a327 Add Continue support (#402)
* OpenSpec 支持 Continue 插件

* add update

* Update spec.md

* fix: correct Continue frontmatter format and README ordering

- Fix Continue frontmatter to use proper YAML format with opening `---`
  and required `invokable: true` field for slash command availability
- Fix README table ordering: Continue should be after Codex alphabetically
- Remove unused TemplateManager import from continue.ts
- Fix trailing whitespace in update.test.ts
- Fix double blank line in init.test.ts
- Fix extra blank line in cli-init/spec.md
- Update tests to verify correct frontmatter format

---------

Co-authored-by: ajuanli <ajuanli@tencent.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2026-01-10 15:54:09 -08:00
Tabish Bidiwale 4971cda812 fix: use actionCommand for telemetry command tracking (#472)
The preAction hook receives (thisCommand, actionCommand) where thisCommand
is the root program and actionCommand is the actual subcommand being run.
Using thisCommand was incorrectly tracking the root command instead of
the actual subcommand executed by the user.
2026-01-10 15:10:12 -08:00
Tabish Bidiwale 4715138927 fix: set USERPROFILE for Windows compatibility in telemetry tests (#469)
On Windows, os.homedir() uses the USERPROFILE environment variable
instead of HOME. The telemetry config tests were only mocking HOME,
causing them to fail on Windows CI.
2026-01-09 23:36:54 -08:00
99 changed files with 11286 additions and 247 deletions
+1
View File
@@ -0,0 +1 @@
-P ubuntu-latest=catthehacker/ubuntu:act-latest
+93 -4
View File
@@ -1,6 +1,95 @@
This directory is managed by Changesets.
# Changesets
- Add a changeset locally with `pnpm changeset`.
- The CI "Release (prepare)" workflow opens/updates a Version Packages PR.
- Publishing happens from a GitHub Release via the "Publish to npm" workflow.
This directory is managed by [Changesets](https://github.com/changesets/changesets).
## Quick Start
```bash
pnpm changeset
```
Follow the prompts to select version bump type and describe your changes.
## Workflow
1. **Add a changeset** — Run `pnpm changeset` locally before or after your PR
2. **Version PR** — CI opens/updates a "Version Packages" PR when changesets merge to main
3. **Release** — Merging the Version PR triggers npm publish and GitHub Release
> **Note:** Contributors only need to run `pnpm changeset`. Versioning (`changeset version`) and publishing happen automatically in CI.
## Template
Use this structure for your changeset content:
```markdown
---
"@fission-ai/openspec": patch
---
### New Features
- **Feature name** — What users can now do
### Bug Fixes
- Fixed issue where X happened when Y
### Breaking Changes
- `oldMethod()` has been removed, use `newMethod()` instead
### Deprecations
- `legacyOption` is deprecated and will be removed in v2.0
### Other
- Internal refactoring of X for better performance
```
Include only the sections relevant to your change.
## Version Bump Guide
| Type | When to use | Example |
|------|-------------|---------|
| `patch` | Bug fixes, small improvements | Fixed crash when config missing |
| `minor` | New features, non-breaking additions | Added `--verbose` flag |
| `major` | Breaking changes, removed features | Renamed `init` to `setup` |
## When to Create a Changeset
**Create one for:**
- New features or commands
- Bug fixes that affect users
- Breaking changes or deprecations
- Performance improvements users would notice
**Skip for:**
- Documentation-only changes
- Test additions/fixes
- Internal refactoring with no user impact
- CI/tooling changes
## Writing Good Descriptions
**Do:** Write for users, not developers
```markdown
- **Shell completions** — Tab completion now available for Bash, Fish, and PowerShell
```
**Don't:** Write implementation details
```markdown
- Added ShellCompletionGenerator class with Bash/Fish/PowerShell subclasses
```
**Do:** Explain the impact
```markdown
- Fixed config loading to respect `XDG_CONFIG_HOME` on Linux
```
**Don't:** Just reference the fix
```markdown
- Fixed #123
```
+4 -1
View File
@@ -1,6 +1,9 @@
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"changelog": [
"@changesets/changelog-github",
{ "repo": "Fission-AI/OpenSpec" }
],
"commit": false,
"fixed": [],
"linked": [],
+20
View File
@@ -0,0 +1,20 @@
# Github Workflows
## Testing CI Locally
Test GitHub Actions workflows locally using [act](https://nektosact.com/):
```bash
# Test all PR checks
act pull_request
# Test specific job
act pull_request -j nix-flake-validate
# Dry run to see what would execute
act pull_request --dryrun
```
The `.actrc` file configures act to use the appropriate Docker image.
+101 -2
View File
@@ -15,6 +15,29 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect which files changed to enable path-based filtering
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
nix: ${{ steps.filter.outputs.nix }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check for Nix-related changes
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
nix:
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'pnpm-lock.yaml'
- 'scripts/update-flake.sh'
- '.github/workflows/ci.yml'
test_pr:
name: Test
runs-on: ubuntu-latest
@@ -156,6 +179,66 @@ jobs:
exit 1
fi
nix-flake-validate:
name: Nix Flake Validation
runs-on: ubuntu-latest
timeout-minutes: 10
needs: changes
if: needs.changes.outputs.nix == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v13
- name: Setup Nix cache
uses: DeterminateSystems/magic-nix-cache-action@v8
- name: Build with Nix
run: nix build
- name: Verify build output
run: |
if [ ! -e "result" ]; then
echo "Error: Nix build output 'result' symlink not found"
exit 1
fi
if [ ! -f "result/bin/openspec" ]; then
echo "Error: openspec binary not found in build output"
exit 1
fi
echo "✅ Build output verified"
- name: Test binary execution
run: |
VERSION=$(nix run . -- --version)
echo "OpenSpec version: $VERSION"
if [ -z "$VERSION" ]; then
echo "Error: Version command returned empty output"
exit 1
fi
echo "✅ Binary execution successful"
- name: Validate update script
run: |
echo "Testing update-flake.sh script..."
bash scripts/update-flake.sh
echo "✅ Update script executed successfully"
- name: Check flake.nix modifications
run: |
if git diff --quiet flake.nix; then
echo "⚠️ Warning: flake.nix was not modified by update script"
else
echo "✅ flake.nix was updated by script"
git diff flake.nix
fi
- name: Restore flake.nix
if: always()
run: git checkout -- flake.nix || true
validate-changesets:
name: Validate Changesets
runs-on: ubuntu-latest
@@ -191,7 +274,7 @@ jobs:
required-checks-pr:
name: All checks passed
runs-on: ubuntu-latest
needs: [test_pr, lint]
needs: [test_pr, lint, nix-flake-validate]
if: always() && github.event_name == 'pull_request'
steps:
- name: Verify all checks passed
@@ -204,12 +287,20 @@ jobs:
echo "Lint job failed"
exit 1
fi
# Nix validation may be skipped if no Nix-related files changed
if [[ "${{ needs.nix-flake-validate.result }}" != "success" && "${{ needs.nix-flake-validate.result }}" != "skipped" ]]; then
echo "Nix flake validation job failed"
exit 1
fi
if [[ "${{ needs.nix-flake-validate.result }}" == "skipped" ]]; then
echo "Nix flake validation skipped (no Nix-related changes)"
fi
echo "All required checks passed!"
required-checks-main:
name: All checks passed
runs-on: ubuntu-latest
needs: [test_matrix, lint]
needs: [test_matrix, lint, nix-flake-validate]
if: always() && github.event_name != 'pull_request'
steps:
- name: Verify all checks passed
@@ -222,4 +313,12 @@ jobs:
echo "Lint job failed"
exit 1
fi
# Nix validation may be skipped if no Nix-related files changed
if [[ "${{ needs.nix-flake-validate.result }}" != "success" && "${{ needs.nix-flake-validate.result }}" != "skipped" ]]; then
echo "Nix flake validation job failed"
exit 1
fi
if [[ "${{ needs.nix-flake-validate.result }}" == "skipped" ]]; then
echo "Nix flake validation skipped (no Nix-related changes)"
fi
echo "All required checks passed!"
+146
View File
@@ -0,0 +1,146 @@
name: Polish Release Notes
# Uses Claude to transform raw changelog into polished release notes.
# Triggered automatically by release-prepare after publishing, or manually.
on:
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag to polish (e.g., v0.18.0)'
required: true
type: string
env:
TAG_NAME: ${{ inputs.tag_name }}
permissions:
contents: write
jobs:
polish:
# Only run on the main repo, not forks
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get current release body
id: get-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "${{ env.TAG_NAME }}" --json body -q '.body' > current-notes.md
echo "Fetched release notes for ${{ env.TAG_NAME }}"
- name: Transform release notes with Claude
uses: anthropics/claude-code-action@v1
id: claude
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_args: "--allowedTools Write,Read"
prompt: |
Transform the changelog in `current-notes.md` into release notes for OpenSpec ${{ env.TAG_NAME }}.
## Voice
OpenSpec is a developer tool. Write like you're talking to a peer:
- Direct and practical, not marketing copy
- Focus on what changed and why it matters
- Skip the hype, keep it real
## Output
Create two files:
### 1. `release-title.txt`
A short title in this format:
```
${{ env.TAG_NAME }} - [1-4 words describing the release]
```
Examples:
- `v0.18.0 - OPSX Experimental Workflow`
- `v0.16.0 - Antigravity, iFlow Support`
- `v0.15.0 - Gemini CLI, RooCode`
Rules for title:
- Lead with the most notable addition
- 1-4 words after the dash, no fluff
- If multiple features, comma-separate the top 2
- For bugfix-only releases, use something like `v0.17.2 - Pre-commit Hook Fix`
### 2. `polished-notes.md`
```markdown
## What's New in ${{ env.TAG_NAME }}
[One sentence: what's the theme of this release?]
### New
- **Feature name** - What it does and why you'd use it
### Improved
- **Area** - What got better
### Fixed
- What was broken, now works
```
Omit empty sections.
## Rules
1. Write for developers using OpenSpec with AI coding assistants
2. Remove commit hashes (like `eb152eb:`), PR numbers, and changesets wrappers (`### Minor Changes`)
3. Lead with what users can do, not implementation details
4. One to two sentences per item, max
5. Use **bold** for feature/area names
6. Skip internal changes (CI, refactors, tests) unless they affect users
7. If the input is already well-formatted, just clean up structure and remove noise
## Example
Before:
```
### Minor Changes
- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system
**New Commands:**
- `/opsx:ff` - Fast-forward through artifact creation
```
After (polished-notes.md):
```
### New
- **Fast-forward mode** - Generate all planning artifacts at once with `/opsx:ff`. Useful when you already know what you're building.
```
After (release-title.txt):
```
v0.18.0 - OPSX Experimental Workflow
```
Write both files. No other output.
- name: Update release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ env.TAG_NAME }}"
if [ -f "polished-notes.md" ] && [ -f "release-title.txt" ]; then
TITLE=$(cat release-title.txt)
gh release edit "$TAG" --title "$TITLE" --notes-file polished-notes.md
echo "Updated: $TITLE"
elif [ -f "polished-notes.md" ]; then
gh release edit "$TAG" --notes-file polished-notes.md
echo "Updated notes (title unchanged)"
else
echo "No changes generated, keeping original"
fi
+24 -1
View File
@@ -18,9 +18,20 @@ jobs:
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
# Generate GitHub App token first - used for checkout and changesets
# This allows git operations to trigger CI workflows on the version PR
# (GITHUB_TOKEN cannot trigger workflows by design)
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v4
with:
@@ -36,6 +47,7 @@ jobs:
# Opens/updates the Version Packages PR; publishes when the Version PR merges
- name: Create/Update Version PR
id: changesets
uses: changesets/action@v1
with:
title: 'chore(release): version packages'
@@ -44,5 +56,16 @@ jobs:
# so package.json already contains the bumped version.
publish: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# npm authentication handled via OIDC trusted publishing (no token needed)
# Trigger release notes polishing after a release is published
- name: Polish release notes
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
# Get version from package.json (just bumped by changesets)
TAG="v$(jq -r .version package.json)"
echo "Triggering polish workflow for $TAG"
gh workflow run polish-release-notes.yml -f tag_name="$TAG"
+1
View File
@@ -148,3 +148,4 @@ CLAUDE.md
# Pnpm
.pnpm-store/
result
+81 -3
View File
@@ -1,5 +1,79 @@
# @fission-ai/openspec
## 0.22.0
### Minor Changes
- [#530](https://github.com/Fission-AI/OpenSpec/pull/530) [`33466b1`](https://github.com/Fission-AI/OpenSpec/commit/33466b1e2a6798bdd6d0e19149173585b0612e6f) Thanks [@TabishB](https://github.com/TabishB)! - Add project-level configuration, project-local schemas, and schema management commands
**New Features**
- **Project-level configuration** — Configure OpenSpec behavior per-project via `openspec/config.yaml`, including custom rules injection, context files, and schema resolution settings
- **Project-local schemas** — Define custom artifact schemas within your project's `openspec/schemas/` directory for project-specific workflows
- **Schema management commands** — New `openspec schema` commands (`list`, `show`, `export`, `validate`) for inspecting and managing artifact schemas (experimental)
**Bug Fixes**
- Fixed config loading to handle null `rules` field in project configuration
## 0.21.0
### Minor Changes
- [#516](https://github.com/Fission-AI/OpenSpec/pull/516) [`b5a8847`](https://github.com/Fission-AI/OpenSpec/commit/b5a884748be6156a7bb140b4941cfec4f20a9fc8) Thanks [@TabishB](https://github.com/TabishB)! - Add feedback command and Nix flake support
**New Features**
- **Feedback command** — Submit feedback directly from the CLI with `openspec feedback`, which creates GitHub Issues with automatic metadata inclusion and graceful fallback for manual submission
- **Nix flake support** — Install and develop openspec using Nix with the new `flake.nix`, including automated flake maintenance and CI validation
**Bug Fixes**
- **Explore mode guardrails** — Explore mode now explicitly prevents implementation, keeping the focus on thinking and discovery while still allowing artifact creation
**Other**
- Improved change inference in `opsx apply` — automatically detects the target change from conversation context or prompts when ambiguous
- Streamlined archive sync assessment with clearer delta spec location guidance
## 0.20.0
### Minor Changes
- [#502](https://github.com/Fission-AI/OpenSpec/pull/502) [`9db74aa`](https://github.com/Fission-AI/OpenSpec/commit/9db74aa5ac6547efadaed795217cfa17444f2004) Thanks [@TabishB](https://github.com/TabishB)! - Add `/opsx:verify` command and fix vitest process storms
**New Features**
- **`/opsx:verify` command** — Validate that change implementations match their specifications
**Bug Fixes**
- Fixed vitest process storms by capping worker parallelism
- Fixed agent workflows to use non-interactive mode for validation commands
- Fixed PowerShell completions generator to remove trailing commas
## 0.19.0
### Minor Changes
- eb152eb: Add Continue IDE support, shell completions, and `/opsx:explore` command
**New Features**
- **Continue IDE support** – OpenSpec now generates slash commands for [Continue](https://continue.dev/), expanding editor integration options alongside Cursor, Windsurf, Claude Code, and others
- **Shell completions for Bash, Fish, and PowerShell** – Run `openspec completion install` to set up tab completion in your preferred shell
- **`/opsx:explore` command** – A new thinking partner mode for exploring ideas and investigating problems before committing to changes
- **Codebuddy slash command improvements** – Updated frontmatter format for better compatibility
**Bug Fixes**
- Shell completions now correctly offer parent-level flags (like `--help`) when a command has subcommands
- Fixed Windows compatibility issues in tests
**Other**
- Added optional anonymous usage statistics to help understand how OpenSpec is used. This is **opt-out** by default – set `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` to disable. Only command names and version are collected; no arguments, file paths, or content. Automatically disabled in CI environments.
## 0.18.0
### Minor Changes
@@ -51,13 +125,15 @@
### Minor Changes
- 2e71835: ### New Features
- 2e71835: Add `openspec config` command and Oh-my-zsh completions
**New Features**
- Add `openspec config` command for managing global configuration settings
- Implement global config directory with XDG Base Directory specification support
- Add Oh-my-zsh shell completions support for enhanced CLI experience
### Bug Fixes
**Bug Fixes**
- Fix hang in pre-commit hooks by using dynamic imports
- Respect XDG_CONFIG_HOME environment variable on all platforms
@@ -65,7 +141,7 @@
- Align cli-completion spec with implementation
- Remove hardcoded agent field from slash commands
### Documentation
**Documentation**
- Alphabetize AI tools list in README and make it collapsible
@@ -86,6 +162,8 @@
### Minor Changes
- Add Continue slash command support so `openspec init` can generate `.continue/prompts/openspec-*.prompt` files with MARKDOWN frontmatter and `$ARGUMENTS` placeholder, and refresh them on `openspec update`.
- 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
+17
View File
@@ -0,0 +1,17 @@
# Maintainers
People who maintain and guide OpenSpec.
## Core Maintainers
| Name | GitHub | Role |
|------|--------|------|
| Tabish Bidiwale | [@TabishB](https://github.com/TabishB) | Lead maintainer |
## Advisors
Advisors help shape technical direction and provide guidance to the project.
| Name | GitHub | Focus |
|------|--------|-------|
| Hari Krishnan | [@harikrishnan83](https://github.com/harikrishnan83) | Technical direction |
+43
View File
@@ -103,6 +103,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
@@ -139,6 +140,8 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`.
#### Step 1: Install the CLI globally
**Option A: Using npm**
```bash
npm install -g @fission-ai/openspec@latest
```
@@ -148,6 +151,39 @@ Verify installation:
openspec --version
```
**Option B: Using Nix (NixOS and Nix package manager)**
Run OpenSpec directly without installation:
```bash
nix run github:Fission-AI/OpenSpec -- init
```
Or install to your profile:
```bash
nix profile install github:Fission-AI/OpenSpec
```
Or add to your development environment in `flake.nix`:
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
openspec.url = "github:Fission-AI/OpenSpec";
};
outputs = { nixpkgs, openspec, ... }: {
devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell {
buildInputs = [ openspec.packages.x86_64-linux.default ];
};
};
}
```
Verify installation:
```bash
openspec --version
```
#### Step 2: Initialize OpenSpec in your project
Navigate to your project directory:
@@ -427,6 +463,13 @@ We collect only command names and version to understand usage patterns. No argum
- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`
- Conventional commits (one-line): `type(scope): subject`
<details>
<summary><strong>Maintainers & Advisors</strong></summary>
See [MAINTAINERS.md](MAINTAINERS.md) for the list of core maintainers and advisors who help guide the project.
</details>
## License
MIT
+146 -21
View File
@@ -78,6 +78,98 @@ openspec artifact-experimental-setup
This creates skills in `.claude/skills/` that Claude Code auto-detects.
During setup, you'll be prompted to create a **project config** (`openspec/config.yaml`). This is optional but recommended.
## Project Configuration
Project config lets you set defaults and inject project-specific context into all artifacts.
### Creating Config
Config is created during `artifact-experimental-setup`, or manually:
```yaml
# openspec/config.yaml
schema: spec-driven
context: |
Tech stack: TypeScript, React, Node.js
API conventions: RESTful, JSON responses
Testing: Vitest for unit tests, Playwright for e2e
Style: ESLint with Prettier, strict TypeScript
rules:
proposal:
- Include rollback plan
- Identify affected teams
specs:
- Use Given/When/Then format for scenarios
design:
- Include sequence diagrams for complex flows
```
### Config Fields
| Field | Type | Description |
|-------|------|-------------|
| `schema` | string | Default schema for new changes (e.g., `spec-driven`, `tdd`) |
| `context` | string | Project context injected into all artifact instructions |
| `rules` | object | Per-artifact rules, keyed by artifact ID |
### How It Works
**Schema precedence** (highest to lowest):
1. CLI flag (`--schema tdd`)
2. Change metadata (`.openspec.yaml` in change directory)
3. Project config (`openspec/config.yaml`)
4. Default (`spec-driven`)
**Context injection:**
- Context is prepended to every artifact's instructions
- Wrapped in `<context>...</context>` tags
- Helps AI understand your project's conventions
**Rules injection:**
- Rules are only injected for matching artifacts
- Wrapped in `<rules>...</rules>` tags
- Appear after context, before the template
### Artifact IDs by Schema
**spec-driven** (default):
- `proposal` — Change proposal
- `specs` — Specifications
- `design` — Technical design
- `tasks` — Implementation tasks
**tdd**:
- `spec` — Feature specification
- `tests` — Test file
- `implementation` — Implementation code
- `docs` — Documentation
### Config Validation
- Unknown artifact IDs in `rules` generate warnings
- Schema names are validated against available schemas
- Context has a 50KB size limit
- Invalid YAML is reported with line numbers
### Troubleshooting
**"Unknown artifact ID in rules: X"**
- Check artifact IDs match your schema (see list above)
- Run `openspec schemas --json` to see artifact IDs for each schema
**Config not being applied:**
- Ensure file is at `openspec/config.yaml` (not `.yml`)
- Check YAML syntax with a validator
- Config changes take effect immediately (no restart needed)
**Context too large:**
- Context is limited to 50KB
- Summarize or link to external docs instead
## Commands
| Command | What it does |
@@ -119,7 +211,7 @@ Creates all planning artifacts at once. Use when you have a clear picture of wha
```
/opsx:apply
```
Works through tasks, checking them off as you go. **Key difference:** if you discover issues during implementation, you can update your specs, design, or tasks — then continue. No phase gates.
Works through tasks, checking them off as you go. **Key difference:** if you discover issues during implementation, you can update your specs, design, or tasks — then continue. No phase gates. If you're juggling multiple changes, you can run `/opsx:apply <name>`; otherwise it should infer from the conversation and prompt you to choose if it can’t tell.
### Finish up
```
@@ -473,35 +565,53 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
### Custom Schemas
Create your own workflow by adding a schema to `~/.local/share/openspec/schemas/`:
Create custom workflows using the schema management commands:
```bash
# Create a new schema from scratch (interactive)
openspec schema init my-workflow
# Or fork an existing schema as a starting point
openspec schema fork spec-driven my-workflow
# Validate your schema structure
openspec schema validate my-workflow
# See where a schema resolves from (useful for debugging)
openspec schema which my-workflow
```
~/.local/share/openspec/schemas/research-first/
Schemas are stored in `openspec/schemas/` (project-local, version controlled) or `~/.local/share/openspec/schemas/` (user global).
**Schema structure:**
```
openspec/schemas/research-first/
├── schema.yaml
└── templates/
├── research.md
├── proposal.md
└── tasks.md
```
schema.yaml:
┌─────────────────────────────────────────────────────────────────┐
│ name: research-first │
│ artifacts: │
│ - id: research # Added before proposal │
│ generates: research.md │
│ requires: [] │
│ │
│ - id: proposal │
│ generates: proposal.md │
│ requires: [research] # Now depends on research │
│ │
│ - id: tasks │
│ generates: tasks.md │
│ requires: [proposal] │
└─────────────────────────────────────────────────────────────────┘
**Example schema.yaml:**
```yaml
name: research-first
artifacts:
- id: research # Added before proposal
generates: research.md
requires: []
Dependency Graph:
- id: proposal
generates: proposal.md
requires: [research] # Now depends on research
- id: tasks
generates: tasks.md
requires: [proposal]
```
**Dependency Graph:**
```
research ──► proposal ──► tasks
```
@@ -523,7 +633,22 @@ Schemas define what artifacts exist and their dependencies. Currently available:
- **spec-driven** (default): proposal → specs → design → tasks
- **tdd**: tests → implementation → docs
Run `openspec schemas` to see available schemas.
```bash
# List available schemas
openspec schemas
# See all schemas with their resolution sources
openspec schema which --all
# Create a new schema interactively
openspec schema init my-workflow
# Fork an existing schema for customization
openspec schema fork spec-driven my-workflow
# Validate schema structure before use
openspec schema validate my-workflow
```
## Tips
+217
View File
@@ -0,0 +1,217 @@
# Multi-Language Output Guide
Configure OpenSpec to generate artifacts in languages other than English.
## Overview
OpenSpec can output proposals, specs, designs, and tasks in any language by adding language instructions to your project's `context` configuration. This approach uses the existing config system without requiring any code changes.
## Quick Setup
### If you already have a config file
Add a language instruction to your existing `openspec/config.yaml`:
```yaml
schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
Use Portuguese technical terminology where appropriate.
# Your other project context below...
Tech stack: TypeScript, React, Node.js
```
That's it. All generated artifacts will now be in Portuguese.
### If you don't have a config file yet
You can create one interactively or manually:
**Option 1: Interactive setup**
```bash
openspec artifact-experimental-setup
```
This will guide you through creating `openspec/config.yaml` with schema selection, context, and rules.
**Option 2: Manual creation**
Create the file `openspec/config.yaml` in your project root:
```bash
mkdir -p openspec
cat > openspec/config.yaml << 'EOF'
schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
EOF
```
## Language Examples
### Portuguese (Brazil)
```yaml
context: |
Language: Portuguese (pt-BR)
All artifacts (proposals, specs, designs, tasks) must be written in Brazilian Portuguese.
Use Brazilian Portuguese conventions for technical terminology.
```
### Spanish
```yaml
context: |
Idioma: Español
Todos los artefactos deben escribirse en español.
Utilizar terminología técnica en español cuando sea posible.
```
### Chinese (Simplified)
```yaml
context: |
语言:中文(简体)
所有产出物(提案、规格、设计、任务)必须用简体中文撰写。
技术术语可以保留英文原文,但说明应使用中文。
```
### Japanese
```yaml
context: |
言語:日本語
すべての成果物は日本語で作成してください。
技術用語は必要に応じて英語を併記可能です。
```
### French
```yaml
context: |
Langue : Français
Tous les artefacts doivent être rédigés en français.
Utiliser la terminologie technique française lorsque possible.
```
### German
```yaml
context: |
Sprache: Deutsch
Alle Artefakte müssen auf Deutsch verfasst werden.
Technische Fachbegriffe können auf Englisch beibehalten werden.
```
## How It Works
The `context` field in `openspec/config.yaml` is injected into every artifact's instructions as an XML block:
```xml
<context>
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
...
</context>
<template>
[Schema's template content]
</template>
```
The AI assistant sees this context when generating any artifact and follows the language instruction.
## Tips
### Be Explicit About Scope
Specify which parts should be in the target language:
```yaml
context: |
Language: Spanish
Write all prose, descriptions, and explanations in Spanish.
Code comments may remain in English for consistency with the codebase.
Variable names and code identifiers should stay in English.
```
### Handle Technical Terms
Decide how to handle technical terminology:
```yaml
context: |
Language: Japanese
Write in Japanese, but:
- Keep technical terms like "API", "REST", "GraphQL" in English
- Provide Japanese explanations in parentheses for complex terms on first use
- Code examples and file paths remain in English
```
### Combine with Other Context
Language settings work alongside your other project context:
```yaml
schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
Tech stack: TypeScript, React 18, Node.js 20
Database: PostgreSQL with Prisma ORM
Testing: Vitest + React Testing Library
Conventions:
- Use functional components with hooks
- Follow existing patterns in src/components/
```
## Verification
To verify your language config is working:
```bash
# Create a test change
openspec new change test-language
# Check the instructions - should show your language context
openspec instructions proposal --change test-language
# Output will include:
# <context>
# Language: Portuguese (pt-BR)
# All artifacts must be written in Brazilian Portuguese.
# ...
# </context>
```
## Limitations
- Language instructions apply to **all** artifacts. You cannot set different languages for different artifact types.
- The effectiveness depends on the AI model's proficiency in the target language.
- Technical diagrams, code samples, and file paths typically remain in English regardless of language setting.
## Future Considerations
We're considering adding a dedicated `language` field to the config schema in a future release:
```yaml
# Potential future syntax (not yet implemented)
schema: spec-driven
language: pt-BR
```
For now, the `context` approach described above is the recommended method. It provides full flexibility and works with all current versions of OpenSpec.
## Related Documentation
- [Project Config Demo](./project-config-demo.md) - Overview of project configuration
- [Experimental Workflow Guide](./experimental-workflow.md) - Full workflow documentation
+205
View File
@@ -0,0 +1,205 @@
# Project Config Demo Guide
A quick-reference guide for demonstrating the `openspec/config.yaml` feature.
## Summary: What Project Config Does
The feature adds `openspec/config.yaml` as a lightweight customization layer that lets teams:
- **Set a default schema** - New changes automatically use this schema instead of having to specify `--schema` every time
- **Inject project context** - Shared context (tech stack, conventions) shown to AI when creating any artifact
- **Add per-artifact rules** - Custom rules that only apply to specific artifacts (e.g., proposal, specs)
## Demo Walkthrough
### Demo 1: Interactive Setup (Recommended Entry Point)
The easiest way to demo is through the experimental setup command:
```bash
openspec artifact-experimental-setup
```
After creating skills/commands, it will prompt:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 Project Configuration (Optional)
Configure project defaults for OpenSpec workflows.
? Create openspec/config.yaml? (Y/n)
```
Walk through:
1. **Select schema** - Shows available schemas with their artifact flows
2. **Add context** - Opens editor for multi-line project context (tech stack, conventions)
3. **Add rules** - Checkbox to select artifacts, then line-by-line rule entry
This creates `openspec/config.yaml` with the user's choices.
### Demo 2: Manual Config Creation
Show that users can create the config directly:
```bash
cat > openspec/config.yaml << 'EOF'
schema: spec-driven
context: |
Tech stack: TypeScript, React, Node.js, PostgreSQL
API style: RESTful, documented in docs/api.md
Testing: Jest + React Testing Library
We value backwards compatibility for all public APIs
rules:
proposal:
- Include rollback plan
- Identify affected teams and notify in #platform-changes
specs:
- Use Given/When/Then format
- Reference existing patterns before inventing new ones
EOF
```
### Demo 3: Effect on New Changes
Show that creating a new change now uses the default schema:
```bash
# Before config: had to specify schema
openspec new change my-feature --schema spec-driven
# After config: schema is automatic
openspec new change my-feature
# Automatically uses spec-driven from config
```
### Demo 4: Context and Rules Injection
The key demo moment - show how instructions are enriched:
```bash
# Get instructions for an artifact
openspec instructions proposal --change my-feature
```
Output shows the XML structure:
```xml
<context>
Tech stack: TypeScript, React, Node.js, PostgreSQL
API style: RESTful, documented in docs/api.md
...
</context>
<rules>
- Include rollback plan
- Identify affected teams and notify in #platform-changes
</rules>
<template>
[Schema's built-in proposal template]
</template>
```
Key points to highlight:
- **Context** appears in ALL artifacts (proposal, specs, design, tasks)
- **Rules** ONLY appear for the matching artifact (proposal rules only in proposal instructions)
### Demo 5: Precedence Override
Show the schema resolution order:
```bash
# Config sets schema: spec-driven
# 1. CLI flag wins
openspec new change feature-a --schema tdd # Uses tdd
# 2. Change metadata wins over config
# (if .openspec.yaml in change directory specifies schema)
# 3. Config is used as default
openspec new change feature-b # Uses spec-driven from config
# 4. Hardcoded default (no config)
# Would fall back to spec-driven anyway
```
### Demo 6: Validation and Error Handling
Show graceful error handling:
```bash
# Create config with typo
echo "schema: spec-drivne" > openspec/config.yaml
# Try to use it - shows fuzzy matching suggestions
openspec new change test
# Schema 'spec-drivne' not found
# Did you mean: spec-driven (built-in)
```
```bash
# Unknown artifact ID in rules - warns but doesn't halt
cat > openspec/config.yaml << 'EOF'
schema: spec-driven
rules:
testplan: # Schema doesn't have this
- Some rule
EOF
openspec instructions proposal --change test
# ⚠️ Unknown artifact ID in rules: "testplan". Valid IDs for schema "spec-driven": ...
# (continues working)
```
## Quick Demo Script
Here's a quick all-in-one demo:
```bash
# 1. Show there's no config initially
cat openspec/config.yaml 2>/dev/null || echo "No config exists"
# 2. Create a simple config
cat > openspec/config.yaml << 'EOF'
schema: spec-driven
context: |
This is a demo project using React and TypeScript.
We follow semantic versioning.
rules:
proposal:
- Include migration steps if breaking change
EOF
# 3. Show the config
cat openspec/config.yaml
# 4. Create a change (uses default schema from config)
openspec new change demo-feature
# 5. Show instructions with injected context/rules
openspec instructions proposal --change demo-feature | head -30
# 6. Show that specs don't have proposal rules
openspec instructions specs --change demo-feature | head -30
```
## What to Emphasize in Demo
- **Low friction** - Teams can customize without forking schemas
- **Shared context** - Everyone on the team gets the same project knowledge
- **Per-artifact rules** - Targeted guidance where it matters
- **Graceful failures** - Typos warn, don't break workflow
- **Team sharing** - Just commit `openspec/config.yaml` and everyone benefits
## Related Documentation
- [Experimental Workflow Guide](./experimental-workflow.md) - Full user guide with config section
- [Project Config Proposal](../openspec/changes/project-config/proposal.md) - Original design proposal
- [Project Config Design](../openspec/changes/project-config/design.md) - Technical implementation details
+25 -24
View File
@@ -10,18 +10,18 @@ This document analyzes the complete user journey for working with schemas in Ope
| Component | Status |
|-----------|--------|
| Schema resolution (XDG) | 2-level: user override → package built-in |
| Schema resolution | 3-level: project → user → package (PR #522) |
| Built-in schemas | `spec-driven`, `tdd` |
| Artifact workflow commands | `status`, `next`, `instructions`, `templates` with `--schema` flag |
| Change creation | `openspec new change <name>` — no schema binding |
| Project-local schemas | ✅ Supported via `openspec/schemas/` (PR #522) |
| Schema management CLI | ✅ `schema which`, `validate`, `fork`, `init` (PR #525) |
### What's Missing
| Component | Status |
|-----------|--------|
| Schema bound to change | Not stored — must pass `--schema` every time |
| Project-local schemas | Not supported — can't version control with repo |
| Schema management CLI | None — manual path discovery required |
| Project default schema | None — hardcoded to `spec-driven` |
---
@@ -114,13 +114,13 @@ cp -r <package-path>/schemas/spec-driven/* \
## Gap Summary
| Gap | Impact | Workaround |
|-----|--------|------------|
| Schema not bound to change | Wrong results, forgotten context | Remember to pass `--schema` |
| No project-local schemas | Can't share via repo | Manual XDG setup per machine |
| No schema management CLI | Manual path hunting | Know XDG + find npm package |
| No project default schema | Must specify every time | Always pass `--schema` |
| No init-time schema selection | Missed setup opportunity | Manual config |
| Gap | Impact | Status |
|-----|--------|--------|
| Schema not bound to change | Wrong results, forgotten context | ⏳ Pending (Phase 1) |
| No project-local schemas | Can't share via repo | ✅ Fixed (PR #522) |
| No schema management CLI | Manual path hunting | ✅ Fixed (PR #525) |
| No project default schema | Must specify every time | ⏳ Pending (Phase 4) |
| No init-time schema selection | Missed setup opportunity | ⏳ Pending (Phase 4) |
---
@@ -296,18 +296,17 @@ created: 2025-01-15T10:30:00Z
### Phase 2: Project-Local Schemas
**Priority:** High
**Status:** ✅ Complete (PR #522)
**Solves:** Team sharing, version control, no XDG knowledge needed
**Scope:**
- Add `./openspec/schemas/` to resolution order (first priority)
- `openspec schema copy <name> [new-name]` creates in project by default
- `--global` flag for user-level XDG directory
**Implemented:**
- `./openspec/schemas/` added to resolution order (first priority)
- `openspec schema fork <name> [new-name]` creates in project by default
- Teams can commit `openspec/schemas/` to repo
**Resolution order:**
```
1. ./openspec/schemas/<name>/ # Project-local (NEW)
1. ./openspec/schemas/<name>/ # Project-local
2. ~/.local/share/openspec/schemas/<name>/ # User global
3. <npm-package>/schemas/<name>/ # Built-in
```
@@ -316,19 +315,21 @@ created: 2025-01-15T10:30:00Z
### Phase 3: Schema Management CLI
**Priority:** Medium
**Status:** ✅ Complete (PR #525)
**Solves:** Path discovery, scaffolding, debugging
**Commands:**
**Implemented Commands:**
```bash
openspec schema list # Show available schemas with sources
openspec schema which <name> # Show resolution path
openspec schema copy <name> [to] # Copy for customization
openspec schema diff <name> # Compare with built-in
openspec schema reset <name> # Remove override
openspec schema validate <name> # Validate schema.yaml structure
openspec schema which [name] # Show resolution path, --all for all schemas
openspec schema validate [name] # Validate schema structure and templates
openspec schema fork <source> [name] # Copy existing schema for customization
openspec schema init <name> # Create new project-local schema (interactive)
```
**Not implemented (may add later):**
- `schema diff` — Compare override with built-in
- `schema reset` — Remove override, revert to built-in
---
### Phase 4: Project Config + Init Enhancement
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767640445,
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+87
View File
@@ -0,0 +1,87 @@
{
description = "OpenSpec - AI-native system for spec-driven development";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "openspec";
version = "0.20.0";
src = ./.;
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_9;
fetcherVersion = 3;
hash = "sha256-m/7IdY1ou9ljjYAcx3W8AyEJvIZfCBWIWxproQ/INPA=";
};
nativeBuildInputs = with pkgs; [
nodejs_20
npmHooks.npmInstallHook
pnpmConfigHook
pnpm_9
];
buildPhase = ''
runHook preBuild
pnpm run build
runHook postBuild
'';
dontNpmPrune = true;
meta = with pkgs.lib; {
description = "AI-native system for spec-driven development";
homepage = "https://github.com/Fission-AI/OpenSpec";
license = licenses.mit;
maintainers = [ ];
mainProgram = "openspec";
};
});
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/openspec";
};
});
devShells = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
pnpm_9
];
shellHook = ''
echo "OpenSpec development environment"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
echo "Run 'pnpm install' to install dependencies"
'';
};
});
};
}
+9 -7
View File
@@ -9,7 +9,7 @@ Instructions for AI coding assistants using OpenSpec for spec-driven development
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
- Validate: `openspec validate [change-id] --strict` and fix issues
- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
@@ -44,7 +44,7 @@ Skip proposal for:
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
4. Run `openspec validate <id> --strict --no-interactive` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
@@ -61,7 +61,7 @@ After deployment, create separate PR to:
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
- Run `openspec validate --strict` to confirm the archived change passes checks
- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks
## Before Any Task
@@ -108,7 +108,7 @@ openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
openspec validate [change] --strict --no-interactive
```
### Command Flags
@@ -160,6 +160,8 @@ New request?
2. **Write proposal.md:**
```markdown
# Change: [Brief description of change]
## Why
[1-2 sentences on problem/opportunity]
@@ -304,7 +306,7 @@ Example for RENAMED:
```bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
openspec validate [change] --strict --no-interactive
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
@@ -341,7 +343,7 @@ Users MUST provide a second factor during login.
EOF
# 4) Validate
openspec validate $CHANGE --strict
openspec validate $CHANGE --strict --no-interactive
```
## Multi-Capability Example
@@ -447,7 +449,7 @@ Only add complexity with:
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec validate --strict # Is it correct?
openspec validate --strict --no-interactive # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
@@ -0,0 +1,20 @@
## Why
Users and agents need a simple way to submit feedback about OpenSpec directly from the CLI. Currently there's no mechanism to collect user feedback, feature requests, or bug reports in a way that enables follow-up conversation. Using GitHub Issues allows us to track feedback, prevent spam via GitHub auth, and enables outreach to users.
## What Changes
- Add `openspec feedback <message>` CLI command
- Leverage `gh` CLI for GitHub authentication and issue creation
- Add `/feedback` skill for agent-assisted feedback with context enrichment
- Ensure cross-platform compatibility (macOS, Linux, Windows)
## Impact
- Affected specs: New `cli-feedback` capability
- Affected code:
- `src/cli/index.ts` - Register feedback command
- `src/commands/feedback.ts` - Command implementation using `gh` CLI
- `src/core/templates/skill-templates.ts` - Feedback skill template
- `src/core/completions/command-registry.ts` - Shell completions
- External dependency: Requires `gh` CLI installed and authenticated
@@ -0,0 +1,188 @@
## ADDED Requirements
### Requirement: Feedback command
The system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities.
#### Scenario: Simple feedback submission
- **WHEN** user executes `openspec feedback "Great tool!"`
- **THEN** the system executes `gh issue create` with title "Feedback: Great tool!"
- **AND** the issue is created in the openspec repository
- **AND** the issue has the `feedback` label
- **AND** the system displays the created issue URL
#### Scenario: Safe command execution
- **WHEN** submitting feedback via `gh` CLI
- **THEN** the system uses `execFileSync` with separate arguments array
- **AND** user input is NOT passed through a shell
- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text
#### Scenario: Feedback with body
- **WHEN** user executes `openspec feedback "Title here" --body "Detailed description..."`
- **THEN** the system creates a GitHub Issue with the specified title
- **AND** the issue body contains the detailed description
- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp)
### Requirement: GitHub CLI dependency
The system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability.
#### Scenario: Missing gh CLI with fallback
- **WHEN** user runs `openspec feedback "message"`
- **AND** `gh` CLI is not installed (not found in PATH)
- **THEN** the system displays warning: "GitHub CLI not found. Manual submission required."
- **AND** outputs structured feedback content with delimiters:
- "--- FORMATTED FEEDBACK ---"
- Title line
- Labels line
- Body content with metadata
- "--- END FEEDBACK ---"
- **AND** displays pre-filled GitHub issue URL for manual submission
- **AND** exits with zero code (successful fallback)
#### Scenario: Cross-platform gh CLI detection on Unix
- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux')
- **AND** checking if `gh` CLI is installed
- **THEN** the system executes `which gh` command
#### Scenario: Cross-platform gh CLI detection on Windows
- **WHEN** system is running on Windows (platform is 'win32')
- **AND** checking if `gh` CLI is installed
- **THEN** the system executes `where gh` command
#### Scenario: Unauthenticated gh CLI with fallback
- **WHEN** user runs `openspec feedback "message"`
- **AND** `gh` CLI is installed but not authenticated
- **THEN** the system displays warning: "GitHub authentication required. Manual submission required."
- **AND** outputs structured feedback content (same format as missing gh CLI scenario)
- **AND** displays pre-filled GitHub issue URL for manual submission
- **AND** displays authentication instructions: "To auto-submit in the future: gh auth login"
- **AND** exits with zero code (successful fallback)
#### Scenario: Authenticated gh CLI
- **WHEN** user runs `openspec feedback "message"`
- **AND** `gh auth status` returns success (authenticated)
- **THEN** the system proceeds with feedback submission
### Requirement: Issue metadata
The system SHALL include relevant metadata in the GitHub Issue body.
#### Scenario: Standard metadata
- **WHEN** creating a GitHub Issue for feedback
- **THEN** the issue body includes:
- OpenSpec CLI version
- Platform (darwin, linux, win32)
- Submission timestamp
- Separator line: "---\nSubmitted via OpenSpec CLI"
#### Scenario: Windows platform metadata
- **WHEN** creating a GitHub Issue for feedback on Windows
- **THEN** the issue body includes "Platform: win32"
- **AND** all platform detection uses Node.js `os.platform()` API
#### Scenario: No sensitive metadata
- **WHEN** creating a GitHub Issue for feedback
- **THEN** the issue body does NOT include:
- File paths from user's system
- Project names or directory names
- Environment variables
- IP addresses
### Requirement: Feedback always works
The system SHALL allow feedback submission regardless of telemetry settings.
#### Scenario: Feedback with telemetry disabled
- **WHEN** user has disabled telemetry via `OPENSPEC_TELEMETRY=0`
- **AND** user runs `openspec feedback "message"`
- **THEN** the feedback is still submitted via `gh` CLI
- **AND** telemetry events are not sent
#### Scenario: Feedback in CI environment
- **WHEN** `CI=true` is set in the environment
- **AND** user runs `openspec feedback "message"`
- **THEN** the feedback submission proceeds normally (if `gh` is available and authenticated)
### Requirement: Error handling
The system SHALL handle feedback submission errors gracefully.
#### Scenario: gh CLI execution failure
- **WHEN** `gh issue create` command fails
- **THEN** the system displays the error output from `gh` CLI
- **AND** exits with the same exit code as `gh`
#### Scenario: Network failure
- **WHEN** `gh` CLI reports network connectivity issues
- **THEN** the system displays the error message from `gh`
- **AND** suggests checking network connectivity
- **AND** exits with non-zero code
### Requirement: Feedback skill for agents
The system SHALL provide a `/feedback` skill that guides agents through collecting and submitting user feedback.
#### Scenario: Agent-initiated feedback
- **WHEN** user invokes `/feedback` in an agent conversation
- **THEN** the agent gathers context from the conversation
- **AND** drafts a feedback issue with enriched content
- **AND** anonymizes sensitive information
- **AND** presents the draft to the user for approval
- **AND** submits via `openspec feedback` command on user confirmation
#### Scenario: Context enrichment
- **WHEN** agent drafts feedback
- **THEN** the agent includes relevant context such as:
- What task was being performed
- What worked well or poorly
- Specific friction points or praise
#### Scenario: Anonymization
- **WHEN** agent drafts feedback
- **THEN** the agent removes or replaces:
- File paths with `<path>` or generic descriptions
- API keys, tokens, secrets with `<redacted>`
- Company/organization names with `<company>`
- Personal names with `<user>`
- Specific URLs with `<url>` unless public/relevant
#### Scenario: User confirmation required
- **WHEN** agent has drafted feedback
- **THEN** the agent MUST show the complete draft to the user
- **AND** ask for explicit approval before submitting
- **AND** allow the user to request modifications
- **AND** only submit after user confirms
### Requirement: Shell completions
The system SHALL provide shell completions for the feedback command.
#### Scenario: Command completion
- **WHEN** user types `openspec fee<TAB>`
- **THEN** the shell completes to `openspec feedback`
#### Scenario: Flag completion
- **WHEN** user types `openspec feedback "msg" --<TAB>`
- **THEN** the shell suggests available flags (`--body`)
@@ -0,0 +1,30 @@
## 1. Feedback Command
- [x] 1.1 Create `src/commands/feedback.ts` with command implementation
- [x] 1.2 Check `gh` CLI availability using platform-appropriate command (`which` on Unix/macOS, `where` on Windows)
- [x] 1.3 Check GitHub auth status with `gh auth status`
- [x] 1.4 Execute `gh issue create` with formatted title and body using `execFileSync` to prevent shell injection
- [x] 1.5 Display issue URL returned by `gh` CLI
- [x] 1.6 Register `feedback <message>` command in `src/cli/index.ts`
- [x] 1.7 Ensure cross-platform compatibility (macOS, Linux, Windows)
## 2. Shell Completions
- [x] 2.1 Add `feedback` command to command registry
- [x] 2.2 Regenerate completion scripts for all shells
## 3. Feedback Skill
- [x] 3.1 Create feedback skill template in `skill-templates.ts`
- [x] 3.2 Document context gathering workflow
- [x] 3.3 Document anonymization rules
- [x] 3.4 Document user confirmation flow
## 4. Testing
- [x] 4.1 Add unit tests for feedback command (mock `gh` subprocess calls)
- [x] 4.2 Add integration test for full feedback flow with mocked `gh` CLI
- [x] 4.3 Test error handling for missing `gh` CLI
- [x] 4.4 Test error handling for unauthenticated `gh` session
- [x] 4.5 Test cross-platform `gh` CLI detection (verify `which` on Unix, `where` on Windows)
- [x] 4.6 Test platform metadata includes correct value for Windows (win32)
@@ -0,0 +1,96 @@
# Design: Add /opsx:verify Skill
## Architecture Decision: Dynamic Generation via Setup Command
### Context
All existing opsx experimental skills (explore, new, continue, apply, ff, sync, archive) are dynamically generated when users run `openspec artifact-experimental-setup`. They are not manually created files checked into the repository.
### Decision
**Integrate verify into the existing artifact-experimental-setup system rather than creating static skill files.**
### Rationale
1. **Consistency**: All 7 existing opsx skills follow this pattern. Adding verify as the 8th skill should follow the same architecture.
2. **Maintainability**: Template functions in `skill-templates.ts` are the single source of truth. Changes to skill definitions automatically propagate to all users when they re-run setup.
3. **Distribution**: Users get the verify skill automatically when running `openspec artifact-experimental-setup`, just like all other opsx skills. No special installation steps needed.
4. **Versioning**: Skills are generated from the installed npm package version, ensuring consistency between CLI version and skill behavior.
### Implementation Approach
#### 1. Template Functions
Add two template functions to `src/core/templates/skill-templates.ts`:
```typescript
export function getVerifyChangeSkillTemplate(): SkillTemplate
export function getOpsxVerifyCommandTemplate(): CommandTemplate
```
These return the skill definition (for Agent Skills) and slash command definition (for explicit invocation).
#### 2. Setup Integration
Update `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts`:
- Import both template functions
- Add verify to the `skills` array (position 8)
- Add verify to the `commands` array (position 8)
- Update help text to list `/opsx:verify`
#### 3. Generated Artifacts
When users run `openspec artifact-experimental-setup`, the command creates:
- `.claude/skills/openspec-verify-change/SKILL.md` - Agent Skills format
- `.claude/commands/opsx/verify.md` - Slash command format
Both are generated from the template functions, with YAML frontmatter automatically added.
### Alternatives Considered
**Alternative 1: Static skill files in repository**
Create `.claude/skills/openspec-verify-change/SKILL.md` as a static file in the OpenSpec repository.
**Rejected because:**
- Inconsistent with all other opsx skills
- Requires users to manually copy/update files
- Versioning becomes complicated (repo version vs installed package version)
- Breaks the established pattern
**Alternative 2: Separate verify setup command**
Add `openspec setup-verify` as a separate command.
**Rejected because:**
- Fragments the setup experience
- Users would need to run multiple commands
- Doesn't scale if we add more skills in the future
- Goes against the "setup once, get everything" philosophy
### Trade-offs
**Advantages:**
- Consistent with existing architecture
- Zero additional setup burden for users
- Easy to update and maintain
- Automatic version compatibility
**Disadvantages:**
- Slightly more complex initial implementation (template functions + integration)
- Requires understanding the setup system (but that's already documented)
### Verification
The implementation correctly follows this design if:
1. Both template functions exist in `skill-templates.ts`
2. Verify appears in both skills and commands arrays in `artifact-workflow.ts`
3. Help text mentions `/opsx:verify`
4. Running `openspec artifact-experimental-setup` generates both skill and command files
5. Build succeeds with no TypeScript errors
@@ -0,0 +1,48 @@
# Change: Add /opsx:verify Skill
## Why
Users need a way to validate that their implementation actually matches what was requested before archiving a change. Currently, there's no systematic way to check:
- Whether all tasks are truly complete
- Whether the implementation covers all spec requirements and scenarios
- Whether the implementation follows the design decisions
- Whether the code is coherent and makes sense
A user requested: "Can we get a :verify that will ensure that the implementation matches what was requested?"
## What Changes
- Add `getVerifyChangeSkillTemplate()` function to `skill-templates.ts`
- Add `getOpsxVerifyCommandTemplate()` function to `skill-templates.ts`
- Integrate verify skill into `artifactExperimentalSetupCommand` in `artifact-workflow.ts`
- Add verify to the skills and commands arrays in the setup command
- Update help text to include `/opsx:verify` in the list of available commands
- Create `opsx-verify-skill` capability spec
## Verification Dimensions
The skill verifies across three dimensions:
1. **Completeness** - Are all tasks done? Are all specs addressed?
2. **Correctness** - Does the implementation match specs? Are scenarios covered?
3. **Coherence** - Does the implementation make sense? Does it follow design.md?
## Output Format
Produces a prioritized report with:
- Summary scorecard (tasks, specs, design adherence)
- Critical issues first (must fix before archive)
- Warnings second (should fix)
- Suggestions third (nice to have)
- Actionable fix recommendations for each issue
## Impact
- Affected specs: New `opsx-verify-skill` spec
- Affected code:
- `src/core/templates/skill-templates.ts` - Added 2 new template functions
- `src/commands/artifact-workflow.ts` - Integrated verify into experimental setup
- Generated artifacts: When users run `openspec artifact-experimental-setup`:
- Creates `.claude/skills/openspec-verify-change/SKILL.md`
- Creates `.claude/commands/opsx/verify.md`
- Related skills: Works alongside `/opsx:apply` and before `/opsx:archive`
@@ -0,0 +1,190 @@
# opsx-verify-skill Specification
## Purpose
Defines the agent skill for verifying that implementation matches change artifacts (specs, tasks, design).
## ADDED Requirements
### Requirement: Verify Skill Invocation
The system SHALL provide an `/opsx:verify` skill that validates implementation against change artifacts.
#### Scenario: Verify with change name provided
- **WHEN** agent executes `/opsx:verify <change-name>`
- **THEN** the agent verifies implementation for that specific change
- **AND** produces a verification report
#### Scenario: Verify without change name
- **WHEN** agent executes `/opsx:verify` without a change name
- **THEN** the agent prompts user to select from available changes
- **AND** shows only changes that have implementation tasks
#### Scenario: Change has no tasks
- **WHEN** selected change has no tasks.md or tasks are empty
- **THEN** the agent reports "No tasks to verify"
- **AND** suggests running `/opsx:continue` to create tasks
### Requirement: Completeness Verification
The agent SHALL verify that all required work has been completed.
#### Scenario: Task completion check
- **WHEN** verifying completeness
- **THEN** the agent reads tasks.md
- **AND** counts tasks marked `- [x]` (complete) vs `- [ ]` (incomplete)
- **AND** reports completion status with specific incomplete tasks listed
#### Scenario: Spec coverage check
- **WHEN** verifying completeness
- **AND** delta specs exist in `openspec/changes/<name>/specs/`
- **THEN** the agent extracts all requirements from delta specs
- **AND** searches codebase for implementation of each requirement
- **AND** reports which requirements appear to have implementation vs which are missing
#### Scenario: All tasks complete
- **WHEN** all tasks are marked complete
- **THEN** report "Tasks: N/N complete"
- **AND** mark completeness dimension as passed
#### Scenario: Incomplete tasks found
- **WHEN** some tasks are incomplete
- **THEN** report "Tasks: X/N complete"
- **AND** list each incomplete task
- **AND** mark as CRITICAL issue
- **AND** suggest: "Complete remaining tasks or mark as done if already implemented"
### Requirement: Correctness Verification
The agent SHALL verify that implementation matches the specifications.
#### Scenario: Requirement implementation mapping
- **WHEN** verifying correctness
- **THEN** for each requirement in delta specs:
- Search codebase for implementation
- Identify relevant files and line numbers
- Assess whether implementation satisfies the requirement
#### Scenario: Scenario coverage check
- **WHEN** verifying correctness
- **THEN** for each scenario in delta specs:
- Check if the scenario's conditions are handled in code
- Check if tests exist that cover the scenario
- Report coverage status
#### Scenario: Implementation matches spec
- **WHEN** implementation appears to satisfy a requirement
- **THEN** report which files/lines implement it
- **AND** mark requirement as covered
#### Scenario: Implementation diverges from spec
- **WHEN** implementation exists but doesn't match spec intent
- **THEN** report the divergence as WARNING
- **AND** explain what differs
- **AND** suggest: either update implementation or update spec to match reality
#### Scenario: Missing implementation
- **WHEN** no implementation found for a requirement
- **THEN** report as CRITICAL issue
- **AND** suggest: "Implement requirement X" with guidance on what's needed
### Requirement: Coherence Verification
The agent SHALL verify that implementation is sensible and follows design decisions.
#### Scenario: Design.md adherence check
- **WHEN** verifying coherence
- **AND** design.md exists for the change
- **THEN** extract key decisions from design.md
- **AND** verify implementation follows those decisions
- **AND** report any deviations
#### Scenario: No design.md
- **WHEN** verifying coherence
- **AND** no design.md exists
- **THEN** skip design adherence check
- **AND** note "No design.md to verify against"
#### Scenario: Design decision followed
- **WHEN** implementation follows a design decision
- **THEN** report as confirmed
- **AND** cite evidence from code
#### Scenario: Design decision violated
- **WHEN** implementation contradicts a design decision
- **THEN** report as WARNING
- **AND** explain the contradiction
- **AND** suggest: either update implementation or update design.md
#### Scenario: Code pattern consistency
- **WHEN** verifying coherence
- **THEN** check if new code follows existing project patterns
- **AND** flag any significant deviations as suggestions
### Requirement: Verification Report Format
The agent SHALL produce a structured, prioritized report.
#### Scenario: Report summary
- **WHEN** verification completes
- **THEN** display summary scorecard:
```
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|----------|
| Completeness | X/Y |
| Correctness | X/Y |
| Coherence | Followed |
```
#### Scenario: Issue prioritization
- **WHEN** issues are found
- **THEN** group and display in priority order:
1. CRITICAL - Must fix before archive (missing implementation, incomplete tasks)
2. WARNING - Should fix (divergence from spec/design, missing tests)
3. SUGGESTION - Nice to fix (pattern inconsistencies, minor improvements)
#### Scenario: Actionable recommendations
- **WHEN** reporting an issue
- **THEN** include specific, actionable fix recommendation
- **AND** reference relevant files and line numbers where applicable
- **AND** avoid vague suggestions like "consider reviewing"
#### Scenario: All checks pass
- **WHEN** no issues found across all dimensions
- **THEN** display:
```
All checks passed. Ready for archive.
```
#### Scenario: Critical issues found
- **WHEN** CRITICAL issues exist
- **THEN** display:
```
X critical issue(s) found. Fix before archiving.
```
- **AND** do NOT suggest running archive
#### Scenario: Only warnings/suggestions
- **WHEN** no CRITICAL issues but warnings exist
- **THEN** display:
```
No critical issues. Y warning(s) to consider.
Ready for archive (with noted improvements).
```
### Requirement: Flexible Artifact Handling
The agent SHALL gracefully handle changes with varying artifact completeness.
#### Scenario: Minimal change (tasks only)
- **WHEN** change has only tasks.md
- **THEN** verify task completion only
- **AND** skip spec and design checks
- **AND** note which checks were skipped
#### Scenario: Change with specs but no design
- **WHEN** change has tasks.md and delta specs but no design.md
- **THEN** verify completeness and correctness
- **AND** skip design adherence
- **AND** still check code coherence against project patterns
#### Scenario: Full change (all artifacts)
- **WHEN** change has proposal, design, specs, and tasks
- **THEN** perform all verification checks
- **AND** cross-reference artifacts for consistency
@@ -0,0 +1,15 @@
# Tasks: Add /opsx:verify Skill
## 1. Skill Template Functions
- [x] 1.1 Add `getVerifyChangeSkillTemplate()` to skill-templates.ts
- [x] 1.2 Add `getOpsxVerifyCommandTemplate()` to skill-templates.ts
## 2. Integration with artifact-experimental-setup
- [x] 2.1 Import verify template functions in artifact-workflow.ts
- [x] 2.2 Add verify to skills array in artifactExperimentalSetupCommand
- [x] 2.3 Add verify to commands array in artifactExperimentalSetupCommand
- [x] 2.4 Add verify to help text output
## 3. Verification (Build & Test)
- [x] 3.1 Verify TypeScript compilation succeeds
- [x] 3.2 Verify all 8 skills are now included (was 7, now 8)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-07
@@ -0,0 +1,94 @@
## Context
OpenSpec is a TypeScript CLI tool using pnpm for dependency management. The project requires Node.js ≥20.19.0. Nix uses its own build system that needs to understand how to fetch dependencies and build the project reproducibly.
The Nix ecosystem has specific patterns for packaging Node.js/pnpm projects that differ from the traditional npm ecosystem.
## Goals
- Enable OpenSpec to be run directly via `nix run github:Fission-AI/OpenSpec`
- Support all major platforms (Linux x86/ARM, macOS x86/ARM)
- Use existing pnpm-lock.yaml for reproducible builds
- Provide development environment for Nix users
## Non-Goals
- Replace existing npm/pnpm publishing workflow
- Publish to nixpkgs (can be done later as separate effort)
- Support Windows (Nix doesn't run natively on Windows)
## Decisions
### Use stdenv.mkDerivation instead of buildNpmPackage
**Decision**: Package OpenSpec using `stdenv.mkDerivation` with pnpm hooks.
**Rationale**: The zigbee2mqtt package in nixpkgs demonstrates the current best practice for pnpm projects. Using `buildNpmPackage` with pnpm requires complex configuration, while `mkDerivation` with the right hooks is more straightforward and better supported.
**Alternative considered**: Using `buildNpmPackage` with `npmConfigHook = pkgs.pnpmConfigHook` - this is the older pattern and causes issues with dependency fetching.
### Use fetchPnpmDeps with explicit pnpm version
**Decision**: Use `pkgs.fetchPnpmDeps` with `pnpm = pkgs.pnpm_9` and `fetcherVersion = 3`.
**Rationale**:
- pnpm lockfile version 9.0 requires fetcherVersion 3
- Explicit pnpm_9 ensures consistency between fetch and build
- This is the documented way to handle pnpm projects in nixpkgs
### Multi-platform support without flake-utils
**Decision**: Implement multi-platform support using plain Nix with `nixpkgs.lib.genAttrs`.
**Rationale**: Per user request, avoid extra dependencies. The `genAttrs` pattern is simple and well-understood in the Nix community.
### Node.js 20 instead of latest
**Decision**: Pin to nodejs_20 to match package.json engines requirement.
**Rationale**: Ensures consistency with development environment and npm package requirements. Avoids potential compatibility issues with newer Node versions.
## Key Implementation Details
### Dependency Hash Management
The `pnpmDeps.hash` field must be updated whenever dependencies change. The workflow:
1. Set hash to fake value (all zeros)
2. Run `nix build`
3. Nix fails with actual hash
4. Update flake.nix with correct hash
This is standard Nix workflow for fixed-output derivations.
### Build Inputs
Required nativeBuildInputs:
- `nodejs_20` - runtime
- `npmHooks.npmInstallHook` - handles installation phase
- `pnpmConfigHook` - configures pnpm environment
- `pnpm_9` - pnpm executable
The `dontNpmPrune = true` is important to keep all dependencies after build.
## Risks / Trade-offs
**[Risk]** Hash needs updating when dependencies change → **Mitigation**: Document this clearly; error message from Nix provides correct hash
**[Risk]** Nix builds might lag behind npm releases → **Mitigation**: This is fine; Nix users can still use npm if they need bleeding edge
**[Trade-off]** Additional maintenance burden for hash updates → **Benefit**: Better experience for Nix ecosystem users
## Migration Plan
1. Add flake.nix to repository
2. Test builds on multiple platforms (can use GitHub Actions with Nix)
3. Update README with Nix installation instructions
4. Optionally add to CI pipeline to catch hash mismatches early
No breaking changes - this is purely additive.
## Open Questions
- Should we add automatic hash updating to CI? (Could use nix-update-script)
- Should we submit to nixpkgs after validation? (Separate decision)
- Do we want to support older Node versions in flake? (Probably no - stick to package.json requirement)
@@ -0,0 +1,25 @@
## Why
OpenSpec users on NixOS or using the Nix package manager cannot easily install or run OpenSpec without going through npm. Adding a Nix flake makes OpenSpec a first-class citizen in the Nix ecosystem, enabling users to run `nix run github:Fission-AI/OpenSpec -- init` or include OpenSpec in their development environments declaratively.
## What Changes
- Add `flake.nix` to repository root with multi-platform support (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin)
- Package uses pnpm for dependency management (matching existing development workflow)
- Support both direct execution via `nix run` and installation via `nix profile install`
- Provide dev shell for contributors using Nix
## Capabilities
### New Capabilities
- `nix-flake-support`: Nix flake configuration for building and running OpenSpec
### Modified Capabilities
- None
## Impact
- **New files**: `flake.nix` in repository root
- **Documentation**: Should add installation instructions for Nix users
- **CI/CD**: Could add flake checking to CI pipeline (optional)
- **Maintenance**: Requires updating pnpmDeps hash when dependencies change
@@ -0,0 +1,79 @@
## ADDED Requirements
### Requirement: Multi-platform Nix flake
The system SHALL provide a Nix flake that builds OpenSpec for multiple platforms.
#### Scenario: Build on Linux x86_64
- **WHEN** user runs `nix build` on x86_64-linux system
- **THEN** system builds OpenSpec package successfully
- **AND** package includes the `openspec` binary
#### Scenario: Build on macOS ARM
- **WHEN** user runs `nix build` on aarch64-darwin system
- **THEN** system builds OpenSpec package successfully
- **AND** package includes the `openspec` binary
#### Scenario: Build on Linux ARM
- **WHEN** user runs `nix build` on aarch64-linux system
- **THEN** system builds OpenSpec package successfully
#### Scenario: Build on macOS x86_64
- **WHEN** user runs `nix build` on x86_64-darwin system
- **THEN** system builds OpenSpec package successfully
### Requirement: Direct execution via nix run
The system SHALL allow users to run OpenSpec directly from GitHub without installing.
#### Scenario: Run init command from GitHub
- **WHEN** user runs `nix run github:Fission-AI/OpenSpec -- init`
- **THEN** system downloads and builds OpenSpec
- **AND** executes `openspec init` command
#### Scenario: Run any OpenSpec command
- **WHEN** user runs `nix run github:Fission-AI/OpenSpec -- <command> <args>`
- **THEN** system executes `openspec <command> <args>`
### Requirement: pnpm dependency management
The system SHALL use pnpm for building OpenSpec in the Nix flake.
#### Scenario: Fetch dependencies with pnpm
- **WHEN** Nix builds the package
- **THEN** system uses `fetchPnpmDeps` to download dependencies
- **AND** uses pnpm-lock.yaml for reproducible builds
- **AND** uses fetcherVersion 3 for lockfile version 9.0
#### Scenario: Build with pnpm
- **WHEN** Nix runs the build phase
- **THEN** system executes `pnpm run build`
- **AND** produces dist directory with compiled TypeScript
### Requirement: Node.js version compatibility
The system SHALL use Node.js 20 as specified in package.json engines field.
#### Scenario: Build with correct Node version
- **WHEN** Nix builds OpenSpec
- **THEN** system uses nodejs_20 from nixpkgs
- **AND** build succeeds without version compatibility errors
### Requirement: Development shell
The system SHALL provide a Nix development shell for contributors.
#### Scenario: Enter dev shell
- **WHEN** user runs `nix develop` in OpenSpec repository
- **THEN** system provides shell with nodejs_20 and pnpm_9
- **AND** displays welcome message with versions
- **AND** provides instructions to run `pnpm install`
### Requirement: Proper binary installation
The system SHALL install the openspec binary correctly.
#### Scenario: Binary in PATH
- **WHEN** package is built or installed
- **THEN** `openspec` binary is available in `$out/bin/openspec`
- **AND** binary is executable
- **AND** binary can be invoked without full path when installed
#### Scenario: Binary executes correctly
- **WHEN** user runs the installed `openspec` command
- **THEN** system executes the CLI entry point
- **AND** all subcommands work correctly
@@ -0,0 +1,65 @@
## 1. Create Flake Structure
- [x] 1.1 Create flake.nix in repository root
- [x] 1.2 Define inputs (nixpkgs only, no flake-utils)
- [x] 1.3 Set up supportedSystems list (4 platforms)
- [x] 1.4 Create forAllSystems helper function
## 2. Configure Package Build
- [x] 2.1 Set up stdenv.mkDerivation with finalAttrs pattern
- [x] 2.2 Configure pnpmDeps with fetchPnpmDeps
- [x] 2.3 Set pnpm = pnpm_9 and fetcherVersion = 3
- [x] 2.4 Add placeholder hash (all zeros)
- [x] 2.5 Configure nativeBuildInputs (nodejs_20, hooks, pnpm_9)
- [x] 2.6 Set dontNpmPrune = true
## 3. Define Build Phase
- [x] 3.1 Add buildPhase with runHook preBuild
- [x] 3.2 Add pnpm run build command
- [x] 3.3 Add runHook postBuild
## 4. Configure Installation
- [x] 4.1 Let npmInstallHook handle installation automatically
- [x] 4.2 Verify binary ends up in $out/bin/openspec
## 5. Add Metadata
- [x] 5.1 Set meta.description
- [x] 5.2 Set meta.homepage
- [x] 5.3 Set meta.license (MIT)
- [x] 5.4 Set meta.mainProgram = "openspec"
## 6. Configure App Entry Point
- [x] 6.1 Add apps output with forAllSystems
- [x] 6.2 Set default app to openspec binary
- [x] 6.3 Test that nix run works
## 7. Add Development Shell
- [x] 7.1 Add devShells output with forAllSystems
- [x] 7.2 Include nodejs_20 and pnpm_9 in buildInputs
- [x] 7.3 Add shellHook with welcome message and instructions
## 8. Get Correct Dependency Hash
- [x] 8.1 Run nix build to trigger hash mismatch
- [x] 8.2 Copy correct hash from error message
- [x] 8.3 Update pnpmDeps.hash in flake.nix
- [x] 8.4 Verify build succeeds
## 9. Testing
- [x] 9.1 Test `nix build` on x86_64-linux
- [x] 9.2 Test `nix run . -- --version` works
- [x] 9.3 Test `nix develop` provides correct environment
- [ ] 9.4 Test on macOS if available
- [ ] 9.5 Test `nix run github:Fission-AI/OpenSpec -- init` after merge to main
## 10. Documentation
- [x] 10.1 Add Nix installation section to README
- [x] 10.2 Include example commands for common Nix workflows in README
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-09
@@ -0,0 +1,117 @@
## Context
The Nix flake added in the previous change requires manual maintenance when:
1. Package version changes (must update flake.nix version field)
2. Dependencies change (must update pnpmDeps hash)
Currently this requires maintainers to:
- Manually edit flake.nix version
- Set placeholder hash
- Run nix build to get error
- Copy hash from error message
- Update flake.nix again
- Verify build works
This is tedious and error-prone, especially for maintainers unfamiliar with Nix.
## Goals
- Automate version and hash updates for flake.nix
- Make script idempotent and safe to run multiple times
- Provide clear feedback during execution
- Integrate easily into release workflow
## Non-Goals
- Automatically commit changes (maintainer decides when to commit)
- Support non-pnpm package managers
- Handle complex Nix configurations beyond OpenSpec's use case
## Decisions
### Use Bash instead of Node.js script
**Decision**: Implement as bash script rather than Node.js.
**Rationale**:
- Needs to call Nix commands which are bash-native
- Parsing Nix output is simpler in bash with grep/sed
- Maintainers updating flake.nix likely have Nix installed (bash environment)
- Node.js would add unnecessary complexity for shell operations
**Alternative considered**: Node.js script with child_process - adds dependency on extra npm packages for shell operations, less natural for Nix tooling.
### Extract hash from build error output
**Decision**: Trigger intentional build failure with placeholder hash to get correct hash.
**Rationale**: This is the standard Nix workflow for updating fixed-output derivations. No API exists to compute the hash without building.
**Alternative considered**: Pre-compute hash from pnpm-lock.yaml - would require understanding Nix's hash algorithm and pnpm's lockfile structure, fragile and non-standard.
### Use sed for in-place file editing
**Decision**: Use `sed -i` for updating flake.nix in place.
**Rationale**: Simple, available on all Unix-like systems, handles the specific replacement patterns needed.
**Alternative considered**:
- Using Node.js to parse/modify: Overkill for simple string replacement
- Manual `sed` without `-i`: Requires temp files, more complex
### Verify build after hash update
**Decision**: Always run verification build after updating hash.
**Rationale**: Catches errors immediately, gives maintainer confidence the update worked.
**Trade-off**: Takes extra time (~30s) but prevents broken flake.nix commits.
## Key Implementation Details
### Path Resolution
Script calculates paths relative to its own location:
```bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
```
This allows running from any working directory.
### Error Handling
Uses `set -euo pipefail` for strict error handling:
- `-e`: Exit on any command failure
- `-u`: Exit on undefined variable access
- `-o pipefail`: Catch failures in pipes
### Hash Extraction Pattern
Uses grep with Perl regex to extract hash:
```bash
grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+'
```
This reliably extracts the hash regardless of surrounding text.
## Risks / Trade-offs
**[Risk]** Script assumes standard Nix error message format → **Mitigation**: If extraction fails, script exits with error and shows full output
**[Risk]** Build might fail for reasons other than hash mismatch → **Mitigation**: Script checks for hash in output before proceeding
**[Trade-off]** Requires Nix installed to run → **Benefit**: Only maintainers updating flake need to run this, and they have Nix
## Migration Plan
1. Add script to scripts directory
2. Document in scripts/README.md
3. Use in next version bump to verify workflow
4. Update CONTRIBUTING.md if needed to mention script
No breaking changes - purely additive tooling.
## Open Questions
None - straightforward automation script.
@@ -0,0 +1,23 @@
## Why
Maintaining the Nix flake requires manual updates to version and dependency hash when releasing new versions or updating dependencies. This is error-prone and requires maintainers to understand Nix internals. Automating this process ensures consistency and reduces friction for releases.
## What Changes
- Add `scripts/update-flake.sh` to automatically update flake.nix version and dependency hash
- Add `scripts/README.md` documenting all maintenance scripts
- Script extracts version from package.json and determines correct pnpm dependency hash automatically
## Capabilities
### New Capabilities
- `flake-update-script`: Automation script for maintaining flake.nix
### Modified Capabilities
- None
## Impact
- **New files**: `scripts/update-flake.sh`, `scripts/README.md`
- **Maintainer workflow**: Version bumps now include running `./scripts/update-flake.sh`
- **Dependencies**: Script requires Node.js (already a dependency) and Nix (for maintainers using Nix)
@@ -0,0 +1,86 @@
## ADDED Requirements
### Requirement: Automatic Version Update
The script SHALL automatically update the version in flake.nix to match package.json.
#### Scenario: Version extraction from package.json
- **WHEN** script runs
- **THEN** version is read from package.json using Node.js
- **AND** version field in flake.nix is updated to match
#### Scenario: Version already up-to-date
- **WHEN** script runs and flake.nix version already matches package.json
- **THEN** script reports version is up-to-date
- **AND** continues to hash update
### Requirement: Automatic Hash Determination
The script SHALL automatically determine and update the correct pnpm dependency hash.
#### Scenario: Trigger build to get hash
- **WHEN** script needs to determine correct hash
- **THEN** script sets placeholder hash in flake.nix
- **AND** runs nix build which fails with correct hash
- **AND** extracts correct hash from build error output
#### Scenario: Hash extraction from build output
- **WHEN** nix build fails with hash mismatch
- **THEN** script parses "got: sha256-..." from error output
- **AND** updates flake.nix with correct hash
#### Scenario: Hash update failure
- **WHEN** script cannot extract hash from build output
- **THEN** script exits with error
- **AND** displays build output for debugging
### Requirement: Build Verification
The script SHALL verify that flake.nix builds successfully after updates.
#### Scenario: Successful verification
- **WHEN** hash has been updated
- **THEN** script runs nix build to verify
- **AND** reports success if build completes
#### Scenario: Dirty git tree warning
- **WHEN** build succeeds but git tree is dirty
- **THEN** script reports warning about dirty tree
- **AND** still indicates build success
### Requirement: User Feedback
The script SHALL provide clear progress information and next steps.
#### Scenario: Progress reporting
- **WHEN** script runs
- **THEN** each step is reported with descriptive message
- **AND** detected version and hash are displayed
#### Scenario: Success summary
- **WHEN** script completes successfully
- **THEN** summary shows updated version and hash
- **AND** next steps are displayed (test, commit, etc.)
### Requirement: Script Safety
The script SHALL fail fast on errors and use safe defaults.
#### Scenario: Bash error handling
- **WHEN** script encounters an error
- **THEN** script exits immediately (set -e)
- **AND** undefined variables cause exit (set -u)
- **AND** pipe failures are caught (set -o pipefail)
#### Scenario: File path resolution
- **WHEN** script determines file locations
- **THEN** paths are calculated relative to script location
- **AND** script works regardless of working directory
### Requirement: Documentation
The system SHALL provide documentation for the update script.
#### Scenario: Script usage documentation
- **WHEN** maintainer needs to use update script
- **THEN** scripts/README.md explains when and how to use it
- **AND** example workflow is provided
#### Scenario: Script listing
- **WHEN** maintainer views scripts/README.md
- **THEN** all maintenance scripts are documented
- **AND** purpose of each script is clear
@@ -0,0 +1,55 @@
## 1. Create Update Script
- [x] 1.1 Create scripts/update-flake.sh file
- [x] 1.2 Add shebang and error handling (set -euo pipefail)
- [x] 1.3 Add path resolution for project root and files
- [x] 1.4 Make script executable (chmod +x)
## 2. Implement Version Update Logic
- [x] 2.1 Extract version from package.json using Node.js
- [x] 2.2 Use sed to update version in flake.nix
- [x] 2.3 Report if version already up-to-date
- [x] 2.4 Display detected version to user
## 3. Implement Hash Update Logic
- [x] 3.1 Set placeholder hash in flake.nix
- [x] 3.2 Run nix build and capture output (allow failure)
- [x] 3.3 Extract correct hash from build error using grep
- [x] 3.4 Handle case where hash extraction fails
- [x] 3.5 Update flake.nix with correct hash
- [x] 3.6 Display detected hash to user
## 4. Add Build Verification
- [x] 4.1 Run nix build after hash update
- [x] 4.2 Check for dirty git tree warning
- [x] 4.3 Report success or failure clearly
## 5. Add User Feedback
- [x] 5.1 Add progress messages for each step
- [x] 5.2 Add success summary with version and hash
- [x] 5.3 Add next steps instructions (test, commit)
- [x] 5.4 Add error messages with context
## 6. Create Documentation
- [x] 6.1 Create scripts/README.md
- [x] 6.2 Document update-flake.sh purpose and usage
- [x] 6.3 Add example workflow
- [x] 6.4 Document other existing scripts
## 7. Testing
- [x] 7.1 Test script runs successfully
- [x] 7.2 Verify version is extracted correctly
- [x] 7.3 Verify hash is updated correctly
- [x] 7.4 Verify build succeeds after update
- [x] 7.5 Test idempotency (running twice works)
## 8. Integration
- [ ] 8.1 Add note to release process documentation
- [ ] 8.2 Use in next actual version bump to validate workflow
@@ -0,0 +1,206 @@
# Design: Nix CI Validation
## Context
OpenSpec recently added Nix flake support to enable Nix users to install the tool. This includes:
- `flake.nix`: Nix package definition with pnpm dependency fetching
- `scripts/update-flake.sh`: Automation script to update version and hash when releasing
Currently, there is no CI validation ensuring these Nix artifacts remain functional. The existing CI workflow (.github/workflows/ci.yml) validates Node.js builds, tests, and linting across multiple platforms (Linux, macOS, Windows) but does not validate Nix builds.
**Stakeholders**: Nix users, maintainers, contributors who need confidence that Nix support works.
**Constraints**:
- Must work in GitHub Actions Linux runners
- Should minimize CI runtime impact (<5 minutes added)
- Should support local testing with `act` for rapid iteration
- Must integrate with existing required checks
## Goals / Non-Goals
**Goals**:
- Validate `nix build` succeeds on every PR/push
- Validate `scripts/update-flake.sh` executes without errors
- Ensure Nix support doesn't regress silently
- Support local testing with `act`
- Optimize with caching to minimize CI time
**Non-Goals**:
- Testing on macOS (GitHub-hosted macOS runners are slower and more expensive; Nix flake already declares macOS support)
- Building for all declared systems (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) - focus on most common platform
- Validating Nix flake quality/style (nixpkgs-fmt, etc.) - can be added later if needed
- Running OpenSpec's full test suite through Nix build - existing CI already does this
## Decisions
### Decision 1: Use DeterminateSystems nix-installer-action
**What**: Use `determinatesystems/nix-installer-action` for installing Nix in CI.
**Why**:
- Official GitHub Action maintained by Determinate Systems (Nix experts)
- Handles GitHub Actions environment quirks automatically
- Includes automatic caching configuration
- More reliable than curl | sh installation script
- Better error messages and diagnostics
**Alternatives considered**:
- Official Nix installer (`curl -L https://nixos.org/nix/install | sh`): Works but requires manual setup of flakes, caching, and CI-specific configuration
- `cachix/install-nix-action`: Popular alternative but determinatesystems is more actively maintained and has better GHA integration
### Decision 2: Use Magic Nix Cache for performance
**What**: Use `determinatesystems/magic-nix-cache-action` for automatic binary caching.
**Why**:
- Zero-configuration caching for Nix store
- Significantly reduces CI time on subsequent runs (from ~5min to ~1-2min)
- Free for public repositories
- Handles cache keys automatically
**Alternatives considered**:
- Manual Nix store caching with GitHub Actions cache: More complex, requires manual cache key management
- Cachix: Excellent tool but requires account setup and token management
- No caching: Acceptable for initial implementation, but poor developer experience
### Decision 3: Separate job for Nix validation
**What**: Create a dedicated `nix-validate` job in .github/workflows/ci.yml that runs in parallel with other jobs.
**Why**:
- Keeps Nix validation isolated from Node.js validation
- Allows parallel execution for faster CI
- Easier to debug when Nix-specific issues occur
- Can be marked as required check independently
**Alternatives considered**:
- Add Nix steps to existing jobs: Creates coupling between Node.js and Nix validation, harder to maintain
- Separate workflow file: Overkill for a single job, harder to manage required checks
### Decision 4: Validate update script by executing it
**What**: Run `scripts/update-flake.sh` as part of CI validation.
**Why**:
- Ensures the script doesn't break due to changes in package.json format, nix build output, or dependencies
- Tests the full workflow users will follow when releasing
- Catches errors early
**Implementation approach**:
- Execute script in a way that doesn't modify git state (or discard changes after)
- Verify script exits with code 0
- Optionally validate that flake.nix contains expected patterns after execution
**Alternatives considered**:
- Mock/dry-run mode: Would require modifying the script significantly
- Skip validation: Risky - script could break and only be discovered at release time
- Only run on release branches: Misses issues early in development
### Decision 5: Run on pull_request and push to main
**What**: Configure Nix validation job to run on:
- `pull_request` events (any PR to main)
- `push` events (direct pushes to main)
- `workflow_dispatch` (manual trigger for testing)
**Why**:
- Catches issues before merge (pull_request)
- Validates main branch stays healthy (push)
- Allows manual testing without creating PRs (workflow_dispatch)
### Decision 6: Support act for local testing
**What**: Ensure workflow is compatible with `act` tool for local CI testing.
**Why**:
- Faster iteration when developing CI changes
- Allows testing without pushing to GitHub
- Reduces commit noise from CI debugging
**Requirements**:
- Use standard GitHub Actions syntax
- Document any act-specific configuration needed
- Test that Nix can be installed in act's Docker containers
**Limitations**:
- act may not perfectly replicate GitHub's runners, but close enough for validation
## Risks / Trade-offs
### Risk: CI runtime increase
**Impact**: Adding Nix validation will increase total CI time by 2-5 minutes per run.
**Mitigation**:
- Run Nix job in parallel with existing jobs (no blocking delay)
- Use magic-nix-cache for subsequent runs (~1-2 min with cache)
- Configure appropriate timeout (10 minutes max)
**Acceptance**: The benefit of preventing Nix regressions outweighs the cost.
### Risk: Nix installer failures in CI
**Impact**: Transient failures in Nix installation could block PRs.
**Mitigation**:
- Use determinatesystems action which has retry logic
- Monitor for flaky failures and adjust if needed
- Document troubleshooting steps
**Acceptance**: Nix installation is generally stable in GHA; this is low risk.
### Risk: Update script modifies git state
**Impact**: Running update-flake.sh modifies flake.nix, which could cause CI to fail if git state is checked.
**Mitigation**:
- Run script in isolation without committing changes
- Add `git checkout -- flake.nix` after validation
- Or accept dirty git state in CI (doesn't affect build validation)
**Acceptance**: Script validation is important enough to handle this carefully.
### Risk: act compatibility issues
**Impact**: Workflow might not work perfectly with act due to Docker environment differences.
**Mitigation**:
- Document known limitations
- Focus on GitHub Actions as primary validation target
- Use act as best-effort local testing
**Acceptance**: act support is nice-to-have, not required.
## Migration Plan
### Phase 1: Add Nix job (new, non-required)
1. Add `nix-validate` job to .github/workflows/ci.yml
2. Configure to run in parallel with existing jobs
3. Do NOT mark as required check initially
4. Monitor for ~1 week to ensure stability
### Phase 2: Make required
1. After validation is stable, add to required checks
2. Update branch protection rules in GitHub settings
3. Document in CONTRIBUTING.md or README
### Rollback Plan
If Nix validation causes issues:
1. Remove job from required checks in GitHub settings (immediate)
2. Comment out or remove job from workflow (permanent fix)
3. Investigate and fix issues
4. Re-enable following same phased approach
## Open Questions
- **Q**: Should we test update-flake.sh on every CI run, or only when package.json or pnpm-lock.yaml changes?
- **A**: Test on every run for simplicity. The script is fast (<30 seconds) and catching regressions is valuable.
- **Q**: Should we validate on macOS as well?
- **A**: No for initial implementation. Linux validation is sufficient and macOS runners are slower/more expensive. Can add later if users report macOS-specific issues.
- **Q**: Should we run full OpenSpec tests through the Nix build?
- **A**: No. The Nix build already runs `pnpm test` as part of its build phase. Existing CI jobs cover testing thoroughly. Nix validation focuses on build success.
- **Q**: What timeout should we use for the Nix validation job?
- **A**: Start with 10 minutes. With caching, jobs should complete in 1-3 minutes. Without cache (first run), 5-7 minutes is expected.
@@ -0,0 +1,21 @@
# Add Nix CI Validation
## Why
The project recently added Nix flake support (flake.nix) and an automated update script (scripts/update-flake.sh) to enable Nix users to install OpenSpec. However, there is no CI validation ensuring these Nix artifacts continue to work as the project evolves. This creates risk that breaking changes could be merged without detection.
## What Changes
- Add a new GitHub Actions workflow job to validate Nix flake builds successfully
- Add validation that the update-flake.sh script executes without errors
- Test on Linux (where Nix support is most common)
- Ensure CI fails if Nix build or update script breaks
- Enable local testing with `act` for developers
## Impact
- Affected specs: New capability `ci-nix-validation`
- Affected code: `.github/workflows/ci.yml` (add new job)
- Affected infrastructure: GitHub Actions runners with Nix installed
- Benefits: Prevents regressions in Nix support, gives confidence to Nix users
- Trade-offs: Adds ~2-3 minutes to CI runtime
@@ -0,0 +1,104 @@
# CI Nix Validation Specification
## ADDED Requirements
### Requirement: Nix Flake Build Validation
The CI system SHALL validate that the Nix flake builds successfully on every pull request and push to main.
#### Scenario: Successful flake build
- **WHEN** a pull request or push to main is made
- **THEN** the CI SHALL execute `nix build` and verify it completes with exit code 0
- **AND** the build output SHALL contain the openspec binary
#### Scenario: Flake build failure
- **WHEN** the Nix flake configuration is broken
- **THEN** the CI job SHALL fail with a non-zero exit code
- **AND** the CI SHALL prevent merging of the pull request
#### Scenario: Multi-platform support check
- **WHEN** the flake declares support for multiple systems
- **THEN** the CI SHALL validate the flake builds on at least Linux (x86_64-linux)
### Requirement: Update Script Validation
The CI system SHALL validate that the update-flake.sh script executes successfully and produces valid output.
#### Scenario: Update script execution
- **WHEN** the CI runs the update script validation
- **THEN** the script SHALL execute without errors
- **AND** the script SHALL correctly extract the version from package.json
- **AND** the script SHALL update flake.nix with the correct version
#### Scenario: Update script with mock hash
- **WHEN** validating the update script in CI
- **THEN** the script SHALL be able to detect and extract the correct pnpm dependency hash
- **AND** the flake.nix SHALL be updated with a valid sha256 hash
### Requirement: CI Job Integration
The Nix validation jobs SHALL be integrated into the existing GitHub Actions workflow and required for merge.
#### Scenario: PR merge requirements
- **WHEN** a pull request is created
- **THEN** the Nix validation job SHALL be included in required checks
- **AND** the PR SHALL NOT be mergeable until Nix validation passes
#### Scenario: Job execution triggers
- **WHEN** code is pushed to a pull request OR pushed to main OR manually triggered
- **THEN** the Nix validation job SHALL execute automatically
### Requirement: Local Testing Support
The CI workflow SHALL be testable locally using the `act` tool to enable rapid iteration.
#### Scenario: Local CI execution with act
- **WHEN** a developer runs `act` with the Nix validation workflow
- **THEN** the workflow SHALL execute in the local Docker environment
- **AND** the developer SHALL receive feedback on Nix build status without pushing to GitHub
#### Scenario: Act configuration compatibility
- **WHEN** the workflow is designed
- **THEN** it SHALL use standard GitHub Actions syntax compatible with `act`
- **AND** any Nix-specific setup SHALL work in the act Docker environment
### Requirement: Nix Installation in CI
The CI environment SHALL have Nix properly installed and configured before running validation.
#### Scenario: Nix installation step
- **WHEN** the Nix validation job starts
- **THEN** Nix SHALL be installed using the official Nix installer or determinatesystems/nix-installer-action
- **AND** the Nix installation SHALL be cached for subsequent runs to improve performance
#### Scenario: Nix configuration for CI
- **WHEN** Nix is installed in CI
- **THEN** it SHALL be configured to work in the GitHub Actions environment
- **AND** experimental features (flakes, nix-command) SHALL be enabled
### Requirement: CI Performance Optimization
The Nix validation SHALL be optimized to minimize CI runtime impact.
#### Scenario: Acceptable runtime
- **WHEN** the Nix validation job runs
- **THEN** it SHALL complete in under 5 minutes on a clean run
- **AND** with caching, it SHALL complete in under 3 minutes on subsequent runs
#### Scenario: Parallel execution
- **WHEN** multiple CI jobs are running
- **THEN** the Nix validation job SHALL run in parallel with other validation jobs (tests, lint)
- **AND** SHALL NOT block other independent checks
@@ -0,0 +1,49 @@
# Implementation Tasks
## 1. Add Nix Installation to CI
- [x] 1.1 Research Nix installation options for GitHub Actions (nix-installer-action vs manual install)
- [x] 1.2 Add Nix installation step to .github/workflows/ci.yml
- [x] 1.3 Configure Nix with experimental features enabled (flakes, nix-command)
- [x] 1.4 Add Nix store caching to improve CI performance
## 2. Create Nix Build Validation Job
- [x] 2.1 Add new `nix-flake-validate` job to .github/workflows/ci.yml
- [x] 2.2 Implement `nix build` step with proper error handling
- [x] 2.3 Add verification step to confirm binary exists in build output
- [x] 2.4 Add step to test binary execution (`nix run . -- --version`)
## 3. Add Update Script Validation
- [x] 3.1 Add job step to run scripts/update-flake.sh in dry-run or test mode
- [x] 3.2 Verify script executes without errors
- [x] 3.3 Add validation that version is correctly extracted from package.json
- [x] 3.4 Verify flake.nix is updated with correct format (version and hash)
## 4. Configure Job Dependencies and Requirements
- [x] 4.1 Configure Nix validation job to run on pull_request and push events
- [x] 4.2 Add Nix validation to required checks list
- [x] 4.3 Configure job to run in parallel with existing test/lint jobs
- [x] 4.4 Set appropriate timeout (5-10 minutes)
## 5. Test with act Locally
- [x] 5.1 Install act locally if not already available
- [x] 5.2 Test Nix validation job using `act pull_request`
- [x] 5.3 Verify act can run the workflow with Nix installed
- [x] 5.4 Document any act-specific configuration needed in .actrc or README
## 6. Documentation and Finalization
- [x] 6.1 Add documentation about Nix CI validation to README or CONTRIBUTING.md
- [x] 6.2 Document how to test CI locally with act
- [ ] 6.3 Update CI badge or status indicators if needed
- [ ] 6.4 Test end-to-end by creating a test PR
## 7. Archive Change
- [x] 7.1 After merge and verification, create new spec file at openspec/specs/ci-nix-validation/spec.md
- [x] 7.2 Move change directory to openspec/changes/archive/[date]-add-nix-ci-validation/
- [x] 7.3 Run `openspec validate --strict` to confirm archived change passes
@@ -0,0 +1,2 @@
schema: spec-driven
created: "2025-01-13"
+665
View File
@@ -0,0 +1,665 @@
# Design: Project Config
## Context
OpenSpec currently has a fixed schema resolution order:
1. `--schema` CLI flag
2. `.openspec.yaml` in change directory
3. Hardcoded default: `"spec-driven"`
This forces users who want project-level customization to fork entire schemas, even for simple additions like injecting tech stack context or adding artifact-specific rules.
The proposal introduces `openspec/config.yaml` as a lightweight customization layer that sits between preset schemas and full forking. It allows teams to:
- Set a default schema
- Inject project context into all artifacts
- Add per-artifact rules
**Constraints:**
- Must not break existing changes that lack config
- Must maintain clean separation between "configure" (this) and "fork" (project-local-schemas)
- Config is project-level only (no global/user-level config)
**Key stakeholders:**
- OpenSpec users who need light customization without forking
- Teams sharing workflow conventions via committed config
## Goals / Non-Goals
**Goals:**
- Load and parse `openspec/config.yaml` using Zod schema
- Use config's `schema` field as default in schema resolution
- Inject `context` into all artifact instructions
- Inject `rules` into matching artifact instructions only
- Gracefully handle missing or invalid config (fallback to defaults)
**Non-Goals:**
- Structural changes to schemas (`skip`, `add`, inheritance) - those belong in fork path
- File references for context (`context: ./file.md`) - start with strings
- Global user-level config (XDG dirs, etc.)
- Config management commands (`openspec config init`) - manual creation for now
- Migration from old setups (no existing config to migrate from)
## Decisions
### 1. Config File Format: YAML vs JSON
**Decision:** Use YAML (`.yaml` extension, support `.yml` alias)
**Rationale:**
- YAML supports multi-line strings naturally (`context: |`)
- More readable for documentation-heavy content
- Consistent with `.openspec.yaml` used in changes
- Easy to parse with existing `yaml` library
**Alternatives considered:**
- JSON: More strict, but poor multi-line string UX
- TOML: Less familiar to most users
### 2. Config Location: Project Root vs openspec/ Directory
**Decision:** `./openspec/config.yaml` (inside openspec directory)
**Rationale:**
- Co-located with `openspec/schemas/` (project-local-schemas)
- Keeps project root clean
- Natural namespace for OpenSpec configuration
- Mirrors structure used by other tools (e.g., `.github/`)
**Alternatives considered:**
- `./openspec.config.yaml` in root: Pollutes root, less clear ownership
- XDG config directories: Out of scope, no global config yet
### 3. Context Injection: XML Tags vs Markdown Sections
**Decision:** Use XML-style tags `<context>` and `<rules>`
**Rationale:**
- Clear delimiters that don't conflict with Markdown
- Agents can easily parse structure
- Matches existing patterns in the codebase for special sections
**Example:**
```xml
<context>
Tech stack: TypeScript, React
</context>
<rules>
- Include rollback plan
</rules>
<template>
## Summary
...
</template>
```
**Alternatives considered:**
- Markdown headers: Conflicts with template content
- Comments: Less visible to agents
### 4. Schema Resolution: Insert Position
**Decision:** Config's `schema` field goes between change metadata and hardcoded default
**New resolution order:**
1. `--schema` CLI flag (explicit override)
2. `.openspec.yaml` in change directory (change-specific binding)
3. **`openspec/config.yaml` schema field** (NEW - project default)
4. `"spec-driven"` (hardcoded fallback)
**Rationale:**
- Preserves CLI and change-level overrides (most specific wins)
- Makes config act as a "project default"
- Backwards compatible (no existing configs to conflict with)
### 5. Rules Validation: Strict vs Permissive
**Decision:** Warn on unknown artifact IDs, don't error
**Rationale:**
- Future-proof: If schema adds new artifacts, old configs don't break
- Dev experience: Typos show warnings, but don't halt workflow
- User can fix incrementally
**Example:**
```yaml
rules:
proposal: [...]
testplan: [...] # Schema doesn't have this artifact → WARN, not ERROR
```
### 6. Error Handling: Config Parse Failures
**Decision:** Log warning and fall back to defaults (don't halt commands)
**Rationale:**
- Syntax errors in config shouldn't break all of OpenSpec
- User can fix config incrementally
- Commands remain usable during config development
**Warning message:**
```
⚠️ Failed to parse openspec/config.yaml: [error details]
Falling back to default schema (spec-driven)
```
## Implementation Plan
### Phase 1: Core Types and Loading
**File: `src/core/project-config.ts` (NEW)**
```typescript
import { z } from 'zod';
import { readFileSync, existsSync } from 'fs';
import { parse as parseYaml } from 'yaml';
import { findProjectRoot } from '../utils/path-utils';
/**
* Zod schema for project configuration.
*
* Purpose:
* 1. Documentation - clearly defines the config file structure
* 2. Type safety - TypeScript infers ProjectConfig type from schema
* 3. Runtime validation - uses safeParse() for resilient field-by-field validation
*
* Why Zod over manual validation:
* - Helps understand OpenSpec's data interfaces at a glance
* - Single source of truth for type and validation
* - Consistent with other OpenSpec schemas
*/
export const ProjectConfigSchema = z.object({
schema: z.string().min(1).describe('The workflow schema to use (e.g., "spec-driven", "tdd")'),
context: z.string().optional().describe('Project context injected into all artifact instructions'),
rules: z.record(
z.string(),
z.array(z.string())
).optional().describe('Per-artifact rules, keyed by artifact ID'),
});
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit
/**
* Read and parse openspec/config.yaml from project root.
* Uses resilient parsing - validates each field independently using Zod safeParse.
* Returns null if file doesn't exist.
* Returns partial config if some fields are invalid (with warnings).
*/
export function readProjectConfig(): ProjectConfig | null {
const projectRoot = findProjectRoot();
// Try both .yaml and .yml, prefer .yaml
let configPath = path.join(projectRoot, 'openspec', 'config.yaml');
if (!existsSync(configPath)) {
configPath = path.join(projectRoot, 'openspec', 'config.yml');
if (!existsSync(configPath)) {
return null; // No config is OK
}
}
try {
const content = readFileSync(configPath, 'utf-8');
const raw = parseYaml(content);
if (!raw || typeof raw !== 'object') {
console.warn(`⚠️ openspec/config.yaml is not a valid YAML object`);
return null;
}
const config: Partial<ProjectConfig> = {};
// Parse schema field using Zod
const schemaField = z.string().min(1);
const schemaResult = schemaField.safeParse(raw.schema);
if (schemaResult.success) {
config.schema = schemaResult.data;
} else if (raw.schema !== undefined) {
console.warn(`⚠️ Invalid 'schema' field in config (must be non-empty string)`);
}
// Parse context field with size limit
if (raw.context !== undefined) {
const contextField = z.string();
const contextResult = contextField.safeParse(raw.context);
if (contextResult.success) {
const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');
if (contextSize > MAX_CONTEXT_SIZE) {
console.warn(
`⚠️ Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`
);
console.warn(` Ignoring context field`);
} else {
config.context = contextResult.data;
}
} else {
console.warn(`⚠️ Invalid 'context' field in config (must be string)`);
}
}
// Parse rules field using Zod
if (raw.rules !== undefined) {
const rulesField = z.record(z.string(), z.array(z.string()));
// First check if it's an object structure
if (typeof raw.rules === 'object' && !Array.isArray(raw.rules)) {
const parsedRules: Record<string, string[]> = {};
let hasValidRules = false;
for (const [artifactId, rules] of Object.entries(raw.rules)) {
const rulesArrayResult = z.array(z.string()).safeParse(rules);
if (rulesArrayResult.success) {
// Filter out empty strings
const validRules = rulesArrayResult.data.filter(r => r.length > 0);
if (validRules.length > 0) {
parsedRules[artifactId] = validRules;
hasValidRules = true;
}
if (validRules.length < rulesArrayResult.data.length) {
console.warn(
`⚠️ Some rules for '${artifactId}' are empty strings, ignoring them`
);
}
} else {
console.warn(
`⚠️ Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`
);
}
}
if (hasValidRules) {
config.rules = parsedRules;
}
} else {
console.warn(`⚠️ Invalid 'rules' field in config (must be object)`);
}
}
// Return partial config even if some fields failed
return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;
} catch (error) {
console.warn(`⚠️ Failed to parse openspec/config.yaml:`, error);
return null;
}
}
/**
* Validate artifact IDs in rules against a schema's artifacts.
* Called during instruction loading (when schema is known).
* Returns warnings for unknown artifact IDs.
*/
export function validateConfigRules(
rules: Record<string, string[]>,
validArtifactIds: Set<string>,
schemaName: string
): string[] {
const warnings: string[] = [];
for (const artifactId of Object.keys(rules)) {
if (!validArtifactIds.has(artifactId)) {
const validIds = Array.from(validArtifactIds).sort().join(', ');
warnings.push(
`Unknown artifact ID in rules: "${artifactId}". ` +
`Valid IDs for schema "${schemaName}": ${validIds}`
);
}
}
return warnings;
}
/**
* Suggest valid schema names when user provides invalid schema.
* Uses fuzzy matching to find similar names.
*/
export function suggestSchemas(
invalidSchemaName: string,
availableSchemas: { name: string; isBuiltIn: boolean }[]
): string {
// Simple fuzzy match: Levenshtein distance
function levenshtein(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[b.length][a.length];
}
// Find closest matches (distance <= 3)
const suggestions = availableSchemas
.map(s => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))
.filter(s => s.distance <= 3)
.sort((a, b) => a.distance - b.distance)
.slice(0, 3);
const builtIn = availableSchemas.filter(s => s.isBuiltIn).map(s => s.name);
const projectLocal = availableSchemas.filter(s => !s.isBuiltIn).map(s => s.name);
let message = `❌ Schema '${invalidSchemaName}' not found in openspec/config.yaml\n\n`;
if (suggestions.length > 0) {
message += `Did you mean one of these?\n`;
suggestions.forEach(s => {
const type = s.isBuiltIn ? 'built-in' : 'project-local';
message += ` - ${s.name} (${type})\n`;
});
message += '\n';
}
message += `Available schemas:\n`;
if (builtIn.length > 0) {
message += ` Built-in: ${builtIn.join(', ')}\n`;
}
if (projectLocal.length > 0) {
message += ` Project-local: ${projectLocal.join(', ')}\n`;
} else {
message += ` Project-local: (none found)\n`;
}
message += `\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;
return message;
}
```
### Phase 2: Schema Resolution
**File: `src/utils/change-metadata.ts`**
Update `resolveSchemaForChange()` to check config:
```typescript
export function resolveSchemaForChange(
changeName: string,
cliSchema?: string
): string {
// 1. CLI flag wins
if (cliSchema) {
return cliSchema;
}
// 2. Change metadata (.openspec.yaml)
const metadata = readChangeMetadata(changeName);
if (metadata?.schema) {
return metadata.schema;
}
// 3. Project config (NEW)
const projectConfig = readProjectConfig();
if (projectConfig?.schema) {
return projectConfig.schema;
}
// 4. Hardcoded default
return 'spec-driven';
}
```
**File: `src/utils/change-utils.ts`**
Update `createNewChange()` to use config schema:
```typescript
export function createNewChange(
changeName: string,
schema?: string
): void {
// Use schema from config if not specified
const resolvedSchema = schema ?? readProjectConfig()?.schema ?? 'spec-driven';
// ... rest of change creation logic
}
```
### Phase 3: Instruction Injection and Validation
**File: `src/core/artifact-graph/instruction-loader.ts`**
Update `loadInstructions()` to inject context, rules, and validate artifact IDs:
```typescript
// Session-level cache for validation warnings (avoid repeating same warnings)
const shownWarnings = new Set<string>();
export function loadInstructions(
changeName: string,
artifactId: string
): InstructionOutput {
const projectConfig = readProjectConfig();
// Load base instructions from schema
const baseInstructions = loadSchemaInstructions(changeName, artifactId);
const schema = getSchemaForChange(changeName); // Assumes we have schema loaded
// Validate rules artifact IDs (only once per session)
if (projectConfig?.rules) {
const validArtifactIds = new Set(schema.artifacts.map(a => a.id));
const warnings = validateConfigRules(
projectConfig.rules,
validArtifactIds,
schema.name
);
// Show each unique warning only once per session
for (const warning of warnings) {
if (!shownWarnings.has(warning)) {
console.warn(`⚠️ ${warning}`);
shownWarnings.add(warning);
}
}
}
// Build enriched instruction with XML sections
let enrichedInstruction = '';
// Add context (all artifacts)
if (projectConfig?.context) {
enrichedInstruction += `<context>\n${projectConfig.context}\n</context>\n\n`;
}
// Add rules (only for matching artifact)
const rulesForArtifact = projectConfig?.rules?.[artifactId];
if (rulesForArtifact && rulesForArtifact.length > 0) {
enrichedInstruction += `<rules>\n`;
for (const rule of rulesForArtifact) {
enrichedInstruction += `- ${rule}\n`;
}
enrichedInstruction += `</rules>\n\n`;
}
// Add original template
enrichedInstruction += `<template>\n${baseInstructions.template}\n</template>`;
return {
...baseInstructions,
instruction: enrichedInstruction,
};
}
```
**Note on validation timing:** Rules are validated lazily during instruction loading (not at config load time) because:
1. Schema isn't known at config load time (circular dependency)
2. Warnings shown when user actually uses the feature (better UX)
3. Validation warnings cached per session to avoid spam
### Phase 4: Performance and Caching
**Why config is read multiple times:**
```typescript
// Example: "openspec instructions proposal --change my-feature"
// 1. Schema resolution (to know which schema to use)
resolveSchemaForChange('my-feature')
→ readProjectConfig() // Read #1
// 2. Instruction loading (to inject context and rules)
loadInstructions('my-feature', 'proposal')
→ readProjectConfig() // Read #2
// Result: Config read twice per command
// More complex commands may read 3-5 times
```
**Performance Strategy:**
V1 approach: No caching, read config fresh each time
- Simpler implementation
- No cache invalidation complexity
- Acceptable if config reads are fast enough
**Benchmark targets:**
- Typical config (1KB context, 5 artifact rules): **< 10ms** per read (imperceptible even 5x)
- Large config (50KB context limit): **< 50ms** per read (acceptable for rare case)
**If benchmarks fail:** Add simple caching:
```typescript
// Simple in-memory cache with no invalidation
let cachedConfig: { mtime: number; config: ProjectConfig | null } | null = null;
export function readProjectConfig(): ProjectConfig | null {
const projectRoot = findProjectRoot();
const configPath = path.join(projectRoot, 'openspec', 'config.yaml');
if (!existsSync(configPath)) {
return null;
}
const stats = statSync(configPath);
const mtime = stats.mtimeMs;
// Return cached config if file hasn't changed
if (cachedConfig && cachedConfig.mtime === mtime) {
return cachedConfig.config;
}
// Read and parse config
const config = parseConfigFile(configPath); // Extracted logic
// Cache result
cachedConfig = { mtime, config };
return config;
}
```
**Performance testing task:** Add to Phase 6 (Testing)
- Measure typical config read time (1KB context)
- Measure large config read time (50KB context limit)
- Measure repeated reads within single command
- Document results, add caching only if needed
## Data Flow
```
┌──────────────────────────────────────────────────────────────┐
│ │
│ User runs: openspec instructions proposal --change foo │
│ │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ resolveSchemaForChange("foo") │
│ │
│ 1. Check CLI flag ✗ │
│ 2. Check .openspec.yaml ✗ │
│ 3. Check openspec/config.yaml ✓ → "spec-driven" │
│ │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ loadInstructions("foo", "proposal") │
│ │
│ 1. Load spec-driven/artifacts/proposal.yaml │
│ 2. Read openspec/config.yaml │
│ 3. Build enriched instruction: │
│ - <context>...</context> │
│ - <rules>...</rules> (if rules.proposal exists) │
│ - <template>...</template> │
│ │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Return InstructionOutput with enriched content │
│ │
│ Agent sees project context + rules + schema template │
│ │
└──────────────────────────────────────────────────────────────┘
```
## Risks / Trade-offs
**[Risk]** Config typos silently ignored (e.g., wrong artifact ID in rules)
→ **Mitigation:** Validate and warn on unknown artifact IDs during config load. Don't error to allow forward compatibility.
**[Risk]** Context grows too large, pollutes all artifact instructions
→ **Mitigation:** Document recommended size (< 500 chars). If this becomes an issue, add per-artifact context override later.
**[Risk]** YAML parsing errors break OpenSpec commands
→ **Mitigation:** Catch parse errors, log warning, fall back to defaults. Commands remain functional.
**[Risk]** Config cached incorrectly across commands
→ **Mitigation:** Read config fresh on each `readProjectConfig()` call. No caching layer for v1 (simplicity over perf).
**[Trade-off]** Context is injected into ALL artifacts
→ **Benefit:** Consistent project knowledge across workflow
→ **Cost:** Can't scope context to specific artifacts (yet)
→ **Future:** Add `context: { global: "...", proposal: "..." }` if needed
**[Trade-off]** Rules use artifact IDs, not human names
→ **Benefit:** Stable identifiers (IDs don't change)
→ **Cost:** User needs to know artifact IDs from schema
→ **Mitigation:** Document common artifact IDs, show in `openspec status` output
## Migration Plan
**No migration needed** - this is a new feature with no existing state.
**Rollout steps:**
1. Deploy with config loading behind feature flag (optional, for safety)
2. Test with internal project (this repo)
3. Document in README with examples
4. Remove feature flag if used
**Rollback strategy:**
- Config is additive only (doesn't break existing changes)
- If bugs found, config parsing can be disabled with env var
- Users can delete config file to restore old behavior
## Open Questions
**Q: Should context support file references (`context: ./CONTEXT.md`)?**
**A (deferred):** Start with string-only. Add file reference later if users request it. Keeps v1 simple.
**Q: Should we support `.yml` alias in addition to `.yaml`?**
**A:** Yes, check both extensions. Prefer `.yaml` in docs, but accept `.yml` for users who prefer it.
**Q: What if config's schema field references a non-existent schema?**
**A:** Schema resolution will fail downstream. Show error when trying to load schema, suggest valid schema names.
**Q: Should rules be validated against the resolved schema's artifact IDs?**
**A:** Yes, validate and warn, but don't halt. This allows forward compatibility if schema evolves.
+774
View File
@@ -0,0 +1,774 @@
# Project Config
## Summary
Add `openspec/config.yaml` support for project-level configuration. This enables teams to customize OpenSpec behavior without forking schemas, by providing context and rules that are injected into artifact generation.
## Motivation
Currently, customizing OpenSpec requires forking entire schemas:
- Must copy all files even to add one rule
- Lose updates when openspec upgrades
- High friction for simple customizations
Most users don't need different workflow structure. They need to:
- Provide project context (tech stack, conventions, constraints)
- Add rules for specific artifacts (requirements, formatting preferences)
## Design Decisions
### Two-Path Model
OpenSpec customization follows two distinct paths:
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ CONFIGURE (this change) FORK (project-local-schemas) │
│ ───────────────────── ──────────────────────────── │
│ │
│ Use a preset schema Define your own schema │
│ + add context from scratch │
│ + add rules │
│ │
│ openspec/config.yaml openspec/schemas/my-flow/ │
│ │
│ ✓ Simple ✓ Full control │
│ ✓ Get updates ✗ You maintain everything │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Config Schema
```yaml
# openspec/config.yaml
# Required: which workflow schema to use
schema: spec-driven
# Optional: project context injected into all artifact prompts
context: |
Tech stack: TypeScript, React, Node.js, PostgreSQL
API style: RESTful, documented in docs/api-conventions.md
Testing: Jest + React Testing Library
We value backwards compatibility for all public APIs
# Optional: per-artifact rules (additive)
rules:
proposal:
- Include rollback plan
- Identify affected teams and notify in #platform-changes
specs:
- Use Given/When/Then format
- Reference existing patterns before inventing new ones
tasks:
- Each task should be completable in < 2 hours
- Include acceptance criteria
```
### What's NOT in Config
The following were explicitly excluded to keep the model simple:
| Feature | Decision | Rationale |
|---------|----------|-----------|
| `skip: [artifact]` | Not supported | Structural changes belong in fork path |
| `add: [{...}]` | Not supported | Structural changes belong in fork path |
| `extends: base` | Not supported | No inheritance, fork is full copy |
| `context: ./file.md` | Not supported (yet) | Start with string, add file reference later if needed |
### Field Definitions
#### `schema` (required)
Which workflow schema to use. Can be:
- Built-in name: `spec-driven`, `tdd`
- Project-local schema name: `my-workflow` (requires project-local-schemas change)
This becomes the default schema for:
- New changes created without `--schema` flag
- Commands run on changes without `.openspec.yaml` metadata
#### `context` (optional)
A string containing project context. Injected into ALL artifact prompts.
Use cases:
- Tech stack description
- Link to conventions/style guides
- Team constraints or preferences
- Domain-specific context
#### `rules` (optional)
Per-artifact rules, keyed by artifact ID. Additive to schema's built-in guidance.
```yaml
rules:
<artifact-id>:
- Rule 1
- Rule 2
```
Rules are injected into the specific artifact's prompt, not all prompts.
### Injection Format
When generating instructions for an artifact:
```xml
<context>
Tech stack: TypeScript, React, Node.js, PostgreSQL
API style: RESTful, documented in docs/api-conventions.md
...
</context>
<rules>
- Include rollback plan
- Identify affected teams and notify in #platform-changes
</rules>
<template>
[Schema's built-in template content]
</template>
```
Context appears for all artifacts. Rules only appear for the matching artifact.
### Config Creation Strategy
**Why integrate with `artifact-experimental-setup`?**
This feature targets **experimental workflow users**. The decision to create config during experimental setup (rather than providing standalone commands) is intentional:
**Rationale:**
1. **Single entry point** - Users setting up experimental features are already in "configuration mode"
2. **Contextual timing** - Natural to configure project defaults when setting up workflow
3. **Avoids premature API surface** - No standalone `openspec config init` until feature graduates
4. **Experimental scope** - Keeps config as experimental feature, not stable API
5. **Progressive disclosure** - Users can skip and create manually later if needed
**Evolution path:**
```
Today (Experimental):
openspec artifact-experimental-setup
→ prompts for config creation
→ creates .claude/skills/
→ creates openspec/config.yaml
Future (When graduating):
openspec init
→ prompts for config creation
→ creates openspec/ directory
→ creates openspec/config.yaml
+ standalone commands:
openspec config init
openspec config validate
openspec config set <key> <value>
```
**Why optional?**
Config is **additive**, not required:
- OpenSpec works without config (uses defaults)
- Users can skip during setup and add manually later
- Teams can start simple and add config when they feel friction
- No config file in git = no problem, everyone gets defaults
**Design principle:** The system never *requires* config, but makes it easy to create when users want customization.
## Scope
### In Scope
**Core Config System:**
- Define `ProjectConfig` type with Zod schema
- Add `readProjectConfig()` function with graceful error handling
- Update instruction generation to inject context (all artifacts)
- Update instruction generation to inject rules (per-artifact)
- Update schema resolution to use config's `schema` field as default
- Update `openspec new change` to use config's schema as default
**Config Creation (Experimental Setup):**
- Extend `artifact-experimental-setup` command to optionally create config
- Interactive prompts for schema selection (with description of each schema)
- Interactive prompts for project context (optional multi-line input)
- Interactive prompts for per-artifact rules (optional)
- Validate config immediately after creation
- Show clear "skip" option for users who want to create config manually later
- Display created config location and usage examples
### Out of Scope
- `skip` / `add` for structural changes (use fork path for structural changes)
- File reference for context (`context: ./CONTEXT.md`) - start with string, add later if needed
- Global user-level config (XDG directories, etc.)
- Integration with standard `openspec init` (will add when experimental graduates)
- Standalone `openspec config init` command (may add in future change)
- `openspec config validate` command (may add in future change)
- Config editing/updating commands (users edit YAML directly)
## User Experience
### Setting Up Config (Experimental Workflow)
When users set up the experimental workflow, they're prompted to optionally create config:
```bash
$ openspec artifact-experimental-setup
Setting up experimental artifact workflow...
✓ Created .claude/skills/openspec-explore/SKILL.md
✓ Created .claude/skills/openspec-new-change/SKILL.md
✓ Created .claude/skills/openspec-continue-change/SKILL.md
✓ Created .claude/skills/openspec-apply-change/SKILL.md
✓ Created .claude/skills/openspec-ff-change/SKILL.md
✓ Created .claude/skills/openspec-sync-specs/SKILL.md
✓ Created .claude/skills/openspec-archive-change/SKILL.md
✓ Created .claude/commands/opsx/explore.md
✓ Created .claude/commands/opsx/new.md
✓ Created .claude/commands/opsx/continue.md
✓ Created .claude/commands/opsx/apply.md
✓ Created .claude/commands/opsx/ff.md
✓ Created .claude/commands/opsx/sync.md
✓ Created .claude/commands/opsx/archive.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 Project Configuration (Optional)
Configure project defaults for OpenSpec workflows.
? Create openspec/config.yaml? (Y/n) Y
? Default schema for new changes?
❯ spec-driven (proposal → specs → design → tasks)
tdd (spec → tests → implementation → docs)
? Add project context? (optional)
Context is shown to AI when creating artifacts.
Examples: tech stack, conventions, style guides, domain knowledge
Press Enter to skip, or type/paste context:
│ Tech stack: TypeScript, React, Node.js, PostgreSQL
│ API style: RESTful, documented in docs/api-conventions.md
│ Testing: Jest + React Testing Library
│ We value backwards compatibility for all public APIs
│
[Press Enter when done]
? Add per-artifact rules? (optional) (Y/n) Y
Which artifacts should have custom rules?
[Space to select, Enter when done]
◯ proposal
◉ specs
◯ design
◯ tasks
? Rules for specs artifact:
Enter rules one per line, press Enter on empty line to finish:
│ Use Given/When/Then format for scenarios
│ Reference existing patterns before inventing new ones
│
[Empty line to finish]
✓ Created openspec/config.yaml
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎉 Setup Complete!
📖 Config created at: openspec/config.yaml
• Default schema: spec-driven
• Project context: Added (4 lines)
• Rules: 1 artifact configured
Usage:
• New changes automatically use 'spec-driven' schema
• Context injected into all artifact instructions
• Rules applied to matching artifacts
To share with team:
git add openspec/config.yaml .claude/
git commit -m "Setup OpenSpec experimental workflow with project config"
[Rest of experimental setup output...]
```
**Key UX decisions:**
1. **Prompted during setup** - Natural place since users are already configuring experimental features
2. **Optional at every step** - Clear skip options, no forced configuration
3. **Guided prompts** - Schema descriptions, example context, artifact selection
4. **Immediate validation** - Config is validated after creation, errors shown immediately
5. **Clear output** - Shows exactly what was created and how it affects workflow
### Setting Up Config (Manual Creation)
Users can also create config manually (or skip during setup and add later):
```bash
# Create config file manually
cat > openspec/config.yaml << 'EOF'
schema: spec-driven
context: |
Tech stack: TypeScript, React, Node.js
We follow REST conventions documented in docs/api.md
All changes require backwards compatibility consideration
rules:
proposal:
- Must include rollback plan
- Must identify affected teams
specs:
- Use Given/When/Then format
EOF
```
### Effect on Workflow
Once config is created, it affects the experimental workflow in three ways:
**1. Default Schema Selection**
```bash
# Before config: must specify schema
/opsx:new my-feature --schema spec-driven
# After config (with schema: spec-driven): schema is automatic
/opsx:new my-feature
# Automatically uses spec-driven from config
# Override still works
/opsx:new my-feature --schema tdd
# Uses tdd, ignoring config
```
**2. Context Injection (All Artifacts)**
```bash
# Get instructions for any artifact
openspec instructions proposal --change my-feature
# Output now includes project context:
<context>
Tech stack: TypeScript, React, Node.js, PostgreSQL
API style: RESTful, documented in docs/api-conventions.md
Testing: Jest + React Testing Library
We value backwards compatibility for all public APIs
</context>
<template>
[Schema's proposal template]
</template>
```
Context appears in instructions for **all artifacts** (proposal, specs, design, tasks).
**3. Rules Injection (Per-Artifact)**
```bash
# Get instructions for artifact with rules configured
openspec instructions specs --change my-feature
# Output includes artifact-specific rules:
<context>
[Project context]
</context>
<rules>
- Use Given/When/Then format for scenarios
- Reference existing patterns before inventing new ones
</rules>
<template>
[Schema's specs template]
</template>
```
Rules only appear for the **specific artifact** they're configured for.
**Artifacts without rules** (e.g., design, tasks) don't get a `<rules>` section:
```bash
openspec instructions design --change my-feature
# Output: <context> then <template> only (no rules)
```
### Team Sharing
```bash
# Commit config
git add openspec/config.yaml
git commit -m "Add project config with context and rules"
# Everyone gets the same context and rules automatically
```
## Implementation Notes
### Files to Modify/Create
| File | Changes |
|------|---------|
| `src/core/project-config.ts` | **NEW FILE:** Types, parsing, reading, validation helpers |
| `src/core/artifact-graph/instruction-loader.ts` | Inject context (all artifacts) and rules (per-artifact) |
| `src/utils/change-utils.ts` | Use config schema as default in `createChange()` |
| `src/utils/change-metadata.ts` | Update `resolveSchemaForChange()` to check config |
| `src/commands/artifact-workflow.ts` | Extend `artifactExperimentalSetupCommand()` to prompt for config creation |
| `src/core/config-prompts.ts` | **NEW FILE:** Interactive prompts for config creation (reusable) |
### Config Location
Always at `./openspec/config.yaml` relative to project root. No XDG/global config for simplicity.
### Resolution Order Update
Schema selection order becomes:
```
1. --schema CLI flag # Explicit override
2. .openspec.yaml in change directory # Change-specific binding
3. openspec/config.yaml schema field # Project default (NEW)
4. "spec-driven" # Hardcoded fallback
```
### Validation
- `schema` must be a valid schema name (exists in resolution)
- `context` must be string
- `rules` must be object with string keys (artifact IDs) and array values
- Unknown artifact IDs in `rules` should warn, not error (allows forward compat)
### Experimental Setup Integration
**Changes to `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts`:**
After creating skills and commands, the setup command will:
1. **Display section header:**
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 Project Configuration (Optional)
Configure project defaults for OpenSpec workflows.
```
2. **Prompt: Create config?**
- Yes/No prompt with default "Yes"
- If No → skip entire config section, show usage instructions
- If Yes → continue to detailed prompts
3. **Prompt: Schema selection**
- Use `listSchemasWithInfo()` to get available schemas
- Display each with description and artifact flow
- Default to first schema (likely "spec-driven")
4. **Prompt: Project context**
- Multi-line input (or editor if available)
- Show examples: "tech stack, conventions, style guides"
- Allow empty (skip)
5. **Prompt: Per-artifact rules**
- Yes/No prompt, default "No" (rules are less common)
- If Yes:
- Show checklist of artifacts from selected schema
- For each selected artifact, prompt for rules (line-by-line input)
- Allow empty line to finish each artifact's rules
6. **Create and validate config:**
- Build `ProjectConfig` object from inputs
- Validate with Zod schema
- Write to `openspec/config.yaml` using YAML serializer
- If validation fails, show error and ask to retry or skip
7. **Display success summary:**
- Path to created config
- Summary: schema used, context added (line count), rules count
- Usage examples showing how config affects workflow
- Suggestion to commit config to git
**Error handling:**
- Invalid schema selection → show available schemas with fuzzy match suggestions, retry
- Context too large (>50KB) → reject with error, ask to reduce size
- Rules reference invalid artifact → warn but continue (forward compat)
- File write fails → show error, suggest manual creation
- Config already exists → show message, skip config section, continue with setup
- User cancellation (Ctrl+C) → log "Config creation cancelled", continue with rest of setup (skills/commands already created)
**If config already exists:**
When `openspec/config.yaml` already exists:
```bash
$ openspec artifact-experimental-setup
[Skills and commands created...]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 Project Configuration
ℹ️ openspec/config.yaml already exists. Skipping config creation.
To update config, edit openspec/config.yaml manually or:
1. Delete openspec/config.yaml
2. Run openspec artifact-experimental-setup again
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Rest of setup output...]
```
This prevents accidentally overwriting user's config.
**Implementation approach:**
Create separate `src/core/config-prompts.ts` module:
```typescript
export interface ConfigPromptResult {
createConfig: boolean;
schema?: string;
context?: string;
rules?: Record<string, string[]>;
}
export async function promptForConfig(): Promise<ConfigPromptResult> {
// Prompt logic using inquirer or similar
// Returns structured result for config creation
// Throws ExitPromptError on Ctrl+C (handled by caller)
}
```
**Ctrl+C handling in setup command:**
```typescript
try {
const configResult = await promptForConfig();
if (configResult.createConfig) {
writeConfigFile(configResult);
console.log('✓ Created openspec/config.yaml');
}
} catch (error) {
if (error.name === 'ExitPromptError') {
console.log('\nℹ️ Config creation cancelled');
console.log(' Skills and commands already created');
console.log(' Run setup again to create config later');
// Continue with rest of setup (not a fatal error)
} else {
throw error; // Re-throw unexpected errors
}
}
```
This keeps prompts reusable and testable separately from the setup command.
### Dependencies
**Interactive Prompting Library:**
The experimental setup command will need an interactive prompting library for the config creation flow. Options:
1. **@inquirer/prompts** (recommended)
- Modern, tree-shakeable, TypeScript-first
- Individual imports: `@inquirer/input`, `@inquirer/confirm`, `@inquirer/checkbox`, `@inquirer/editor`
- Already used in OpenSpec (if not, lightweight addition)
2. **inquirer** (classic)
- More established, larger ecosystem
- Heavier bundle size
- Single package with all prompt types
**Prompts needed:**
- `confirm` - "Create config?" "Add rules?"
- `select` - Schema selection with descriptions
- `editor` or multi-line `input` - Project context
- `checkbox` - Artifact selection for rules
- `input` (repeated) - Rule entry (line-by-line)
**Alternative (no dependency):**
Use Node's built-in `readline` for basic prompts:
- More code to write
- Less polished UX (no arrow key navigation, checkbox selection)
- Zero dependency cost
**Recommendation:** Use `@inquirer/prompts` for best UX. Config setup is a one-time operation where UX matters.
### YAML Serialization
Config creation needs YAML serialization:
- **yaml** package (already a dependency)
- Use `yaml.stringify()` to write config
- Preserve multi-line strings with `|` literal style
- Format: 2-space indent, no quotes unless needed
Example:
```typescript
import { stringify } from 'yaml';
const config = {
schema: 'spec-driven',
context: 'Multi-line\ncontext\nhere',
rules: { proposal: ['Rule 1', 'Rule 2'] }
};
const yamlContent = stringify(config, {
indent: 2,
defaultStringType: 'QUOTE_DOUBLE',
defaultKeyType: 'PLAIN',
});
// context will use | literal style automatically for multi-line
```
## Testing Considerations
**Core Config Functionality:**
- Create config with all fields (schema, context, rules), verify parsing
- Create minimal config (schema only), verify parsing
- Verify context appears in instruction output for all artifacts
- Verify rules appear only for matching artifact (not all artifacts)
- Verify schema from config is used for new changes
- Verify CLI `--schema` flag overrides config
- Verify change's `.openspec.yaml` overrides config
- Verify graceful handling of missing config (fallback to defaults)
- Verify graceful handling of invalid YAML syntax (warning, fallback)
- Verify graceful handling of invalid schema (warning, show valid schemas)
- Verify unknown artifact IDs in rules emit warnings but don't halt
**Schema Resolution Precedence:**
- Test all four levels of schema resolution:
1. CLI flag `--schema` (highest priority)
2. Change metadata `.openspec.yaml`
3. Project config `openspec/config.yaml`
4. Hardcoded default "spec-driven" (lowest priority)
- Verify each level correctly overrides lower levels
**Context and Rules Injection:**
- Verify context injection uses `<context>` XML-style tags
- Verify rules injection uses `<rules>` XML-style tags with bullets
- Verify injection order: `<context>` → `<rules>` → `<template>`
- Verify multi-line context is preserved
- Verify special characters in context/rules are not escaped
- Verify empty context/rules don't create tags
**Experimental Setup Integration:**
- Test `artifact-experimental-setup` with user skipping config creation
- Test `artifact-experimental-setup` with minimal config (schema only)
- Test `artifact-experimental-setup` with full config (schema + context + rules)
- Test schema selection from available schemas
- Test multi-line context input
- Test per-artifact rules prompts
- Test artifact selection (checkboxes)
- Test validation errors during config creation
- Test file write errors (permissions, etc.)
- Verify created config can be parsed by `readProjectConfig()`
- Verify success summary shows correct information
**Edge Cases:**
- Config file exists but is empty → treat as invalid, warn
- Config has `.yml` extension instead of `.yaml` → accept both
- Both `.yaml` and `.yml` exist → prefer `.yaml`
- Context contains YAML-significant characters → properly escape in output
- Rules array contains empty strings → filter out or warn
- Schema references non-existent schema → error with suggestions
- Config in subdirectory (not project root) → not found, use defaults
**Backward Compatibility:**
- Existing projects without config continue to work
- Existing changes with `.openspec.yaml` metadata aren't affected by config
- Adding config to existing project doesn't break in-progress changes
**Integration Tests:**
- Create config → create change → verify schema used
- Create config → get instructions → verify context injected
- Create config → get instructions → verify rules injected
- Update config → verify changes reflected immediately (no caching)
- Run `artifact-experimental-setup` → create config → create change → verify flow
## Related Changes
- **project-local-schemas**: Enables `schema: my-workflow` to reference project-local schemas
## Appendix: Full Config Schema
```typescript
import { z } from 'zod';
// Zod schema serves as both runtime validation and documentation
// Type is inferred from schema for type safety
export const ProjectConfigSchema = z.object({
// Required: which schema to use (e.g., "spec-driven", "tdd", or project-local schema name)
schema: z.string().min(1).describe('The workflow schema to use (e.g., "spec-driven", "tdd")'),
// Optional: project context (injected into all artifact instructions)
// Max size: 50KB (enforced during parsing)
context: z.string().optional().describe('Project context injected into all artifact instructions'),
// Optional: per-artifact rules (additive to schema's built-in guidance)
rules: z.record(
z.string(), // artifact ID
z.array(z.string()) // list of rules
).optional().describe('Per-artifact rules, keyed by artifact ID'),
});
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
// Note: Parsing uses safeParse() on individual fields for resilient error handling
// Invalid fields are warned about but don't prevent other fields from being loaded
```
## Appendix: Visual Summary
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User provides: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ openspec/config.yaml │ │
│ │ │ │
│ │ schema: spec-driven │ │
│ │ context: "We use React, TypeScript..." │ │
│ │ rules: │ │
│ │ proposal: [...] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ OpenSpec merges: │ │
│ │ │ │
│ │ Schema (spec-driven) │ │
│ │ + User's context │ │
│ │ + User's rules │ │
│ │ ───────────────────────── │ │
│ │ = Enriched instructions │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Agent sees (for proposal artifact): │ │
│ │ │ │
│ │ <context> │ │
│ │ We use React, TypeScript... │ │
│ │ </context> │ │
│ │ │ │
│ │ <rules> │ │
│ │ - Include rollback plan │ │
│ │ - Identify affected teams │ │
│ │ </rules> │ │
│ │ │ │
│ │ <template> │ │
│ │ [Built-in proposal template] │ │
│ │ </template> │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
@@ -0,0 +1,119 @@
# Spec: Config Loading
## ADDED Requirements
### Requirement: Load project config from openspec/config.yaml
The system SHALL read and parse the project configuration file located at `openspec/config.yaml` relative to the project root.
#### Scenario: Valid config file exists
- **WHEN** `openspec/config.yaml` exists with valid YAML content
- **THEN** system parses the file and returns a ProjectConfig object
#### Scenario: Config file does not exist
- **WHEN** `openspec/config.yaml` does not exist
- **THEN** system returns null without error
#### Scenario: Config file has invalid YAML syntax
- **WHEN** `openspec/config.yaml` contains malformed YAML
- **THEN** system logs a warning message and returns null
#### Scenario: Config file has valid YAML but invalid schema
- **WHEN** `openspec/config.yaml` contains valid YAML that fails Zod schema validation
- **THEN** system logs a warning message with validation details and returns null
### Requirement: Support .yml file extension alias
The system SHALL accept both `.yaml` and `.yml` file extensions for the config file.
#### Scenario: Config file uses .yml extension
- **WHEN** `openspec/config.yml` exists and `openspec/config.yaml` does not exist
- **THEN** system reads from `openspec/config.yml`
#### Scenario: Both .yaml and .yml exist
- **WHEN** both `openspec/config.yaml` and `openspec/config.yml` exist
- **THEN** system prefers `openspec/config.yaml`
### Requirement: Use resilient field-by-field parsing
The system SHALL parse each config field independently, collecting valid fields and warning about invalid ones without rejecting the entire config.
#### Scenario: Schema field is valid
- **WHEN** config contains `schema: "spec-driven"`
- **THEN** schema field is included in returned config
#### Scenario: Schema field is missing
- **WHEN** config lacks the `schema` field
- **THEN** no warning is logged (field is optional at parse level)
#### Scenario: Schema field is empty string
- **WHEN** config contains `schema: ""`
- **THEN** warning is logged and schema field is not included in returned config
#### Scenario: Schema field is invalid type
- **WHEN** config contains `schema: 123` (number instead of string)
- **THEN** warning is logged and schema field is not included in returned config
#### Scenario: Context field is valid
- **WHEN** config contains `context: "Tech stack: TypeScript"`
- **THEN** context field is included in returned config
#### Scenario: Context field is invalid type
- **WHEN** config contains `context: 123` (number instead of string)
- **THEN** warning is logged and context field is not included in returned config
#### Scenario: Rules field has valid structure
- **WHEN** config contains `rules: { proposal: ["Rule 1"], specs: ["Rule 2"] }`
- **THEN** rules field is included in returned config with valid rules
#### Scenario: Rules field has non-array value for artifact
- **WHEN** config contains `rules: { proposal: "not an array", specs: ["Valid"] }`
- **THEN** warning is logged for proposal, but specs rules are still included in returned config
#### Scenario: Rules array contains non-string elements
- **WHEN** config contains `rules: { proposal: ["Valid rule", 123, ""] }`
- **THEN** only "Valid rule" is included, warning logged about invalid elements
#### Scenario: Mix of valid and invalid fields
- **WHEN** config contains valid schema, invalid context type, valid rules
- **THEN** config is returned with schema and rules fields, warning logged about context
### Requirement: Enforce context size limit
The system SHALL reject context fields exceeding 50KB and log a warning.
#### Scenario: Context within size limit
- **WHEN** config contains context of 1KB
- **THEN** context is included in returned config
#### Scenario: Context at size limit
- **WHEN** config contains context of exactly 50KB
- **THEN** context is included in returned config
#### Scenario: Context exceeds size limit
- **WHEN** config contains context of 51KB
- **THEN** warning is logged with size and limit, context field is not included in returned config
### Requirement: Defer artifact ID validation to instruction loading
The system SHALL NOT validate artifact IDs in rules during config load time. Validation happens during instruction loading when schema is known.
#### Scenario: Config with rules is loaded
- **WHEN** config contains `rules: { unknownartifact: [...] }`
- **THEN** config is loaded successfully without validation errors
#### Scenario: Validation happens at instruction load time
- **WHEN** instructions are loaded for any artifact and config has unknown artifact IDs in rules
- **THEN** warnings are emitted about unknown artifact IDs (see rules-injection spec for details)
### Requirement: Gracefully handle config errors without halting
The system SHALL continue operation with default values when config loading or parsing fails.
#### Scenario: Config parse failure during command execution
- **WHEN** config file has syntax errors and user runs `openspec new change`
- **THEN** command executes using default schema "spec-driven"
#### Scenario: Warning is visible to user
- **WHEN** config loading fails
- **THEN** system outputs warning message to stderr with details about the failure
@@ -0,0 +1,51 @@
# Spec: Context Injection
## ADDED Requirements
### Requirement: Inject context into all artifact instructions
The system SHALL inject the context field from project config into instructions for all artifacts, wrapped in XML-style `<context>` tags.
#### Scenario: Config has context field
- **WHEN** config contains `context: "Tech stack: TypeScript, React"`
- **THEN** instruction output includes `<context>\nTech stack: TypeScript, React\n</context>`
#### Scenario: Config has no context field
- **WHEN** config omits the context field or context is undefined
- **THEN** instruction output does not include `<context>` tags
#### Scenario: Context is multi-line string
- **WHEN** config contains context with multiple lines
- **THEN** instruction output preserves line breaks within `<context>` tags
#### Scenario: Context applied to all artifacts
- **WHEN** instructions are loaded for any artifact (proposal, specs, design, tasks)
- **THEN** context section appears in all instruction outputs
### Requirement: Format context with XML-style tags
The system SHALL wrap context content in `<context>` opening and `</context>` closing tags with content on separate lines.
#### Scenario: Context tag structure
- **WHEN** context is injected into instructions
- **THEN** format is exactly `<context>\n{content}\n</context>\n\n`
#### Scenario: Context appears before template
- **WHEN** instructions are generated with context
- **THEN** `<context>` section appears before the `<template>` section
### Requirement: Preserve context content exactly as provided
The system SHALL inject context content without modification, escaping, or interpretation.
#### Scenario: Context contains special characters
- **WHEN** context includes characters like `<`, `>`, `&`, quotes
- **THEN** characters are preserved exactly as written in the config
#### Scenario: Context contains URLs
- **WHEN** context includes URLs like "docs at https://example.com"
- **THEN** URLs are preserved exactly in the injected content
#### Scenario: Context contains markdown
- **WHEN** context includes markdown formatting like `**bold**` or `[links](url)`
- **THEN** markdown is preserved without rendering or escaping
@@ -0,0 +1,99 @@
# Spec: Rules Injection
## ADDED Requirements
### Requirement: Inject rules only for matching artifact
The system SHALL inject rules from config into instructions only when the artifact ID matches a key in the rules object.
#### Scenario: Rules exist for the artifact
- **WHEN** loading instructions for "proposal" and config has `rules: { proposal: ["Rule 1", "Rule 2"] }`
- **THEN** instruction output includes rules section with both rules
#### Scenario: No rules for the artifact
- **WHEN** loading instructions for "design" and config has `rules: { proposal: [...] }`
- **THEN** instruction output does not include `<rules>` tags
#### Scenario: Rules object is undefined
- **WHEN** config omits the rules field or rules is undefined
- **THEN** instruction output does not include `<rules>` tags for any artifact
#### Scenario: Rules array is empty for artifact
- **WHEN** config has `rules: { proposal: [] }`
- **THEN** instruction output does not include `<rules>` tags
### Requirement: Format rules with XML-style tags and bullet list
The system SHALL wrap rules in `<rules>` tags with each rule as a bulleted list item.
#### Scenario: Single rule for artifact
- **WHEN** config has `rules: { proposal: ["Include rollback plan"] }`
- **THEN** instruction output includes `<rules>\n- Include rollback plan\n</rules>\n\n`
#### Scenario: Multiple rules for artifact
- **WHEN** config has `rules: { proposal: ["Rule 1", "Rule 2", "Rule 3"] }`
- **THEN** instruction output includes each rule as separate bullet point
#### Scenario: Rules appear after context and before template
- **WHEN** instructions are generated with both context and rules
- **THEN** order is `<context>` then `<rules>` then `<template>`
### Requirement: Preserve rule text exactly as provided
The system SHALL inject rule text without modification, escaping, or interpretation.
#### Scenario: Rule contains markdown
- **WHEN** rule includes markdown like "Use **Given/When/Then** format"
- **THEN** markdown is preserved in the injected content
#### Scenario: Rule contains special characters
- **WHEN** rule includes characters like `<`, `>`, quotes
- **THEN** characters are preserved exactly as written
#### Scenario: Rule is multi-line string
- **WHEN** rule text contains line breaks
- **THEN** line breaks are preserved within the bullet point
### Requirement: Support multiple artifacts with different rules
The system SHALL allow different rule sets for different artifacts in the same config.
#### Scenario: Multiple artifacts have rules
- **WHEN** config has `rules: { proposal: ["P1"], specs: ["S1", "S2"], tasks: ["T1"] }`
- **THEN** proposal instructions show only ["P1"], specs show only ["S1", "S2"], tasks show only ["T1"]
#### Scenario: Some artifacts have rules, others do not
- **WHEN** config has rules for proposal and specs only
- **THEN** design and tasks instructions have no `<rules>` section
### Requirement: Rules are additive to schema guidance
The system SHALL add config rules to the schema's built-in artifact instruction, not replace it.
#### Scenario: Artifact has schema instruction and config rules
- **WHEN** artifact has built-in instruction from schema and config provides rules
- **THEN** final instruction contains both schema guidance and config rules
#### Scenario: Rules provide additional constraints
- **WHEN** schema says "create proposal" and config rules say "include rollback plan"
- **THEN** agent sees both the schema template and the additional rule
### Requirement: Validate artifact IDs during instruction loading
The system SHALL validate artifact IDs in rules against the schema when instructions are loaded and emit warnings for unknown IDs.
#### Scenario: All artifact IDs are valid
- **WHEN** instructions loaded and config has `rules: { proposal: [...], specs: [...] }` for schema with those artifacts
- **THEN** no validation warnings are emitted
#### Scenario: Unknown artifact ID in rules
- **WHEN** instructions loaded and config has `rules: { unknownartifact: [...] }`
- **THEN** warning emitted: "Unknown artifact ID in rules: 'unknownartifact'. Valid IDs for schema 'spec-driven': design, proposal, specs, tasks"
#### Scenario: Multiple unknown artifact IDs
- **WHEN** instructions loaded and config has multiple unknown artifact IDs
- **THEN** separate warning emitted for each unknown artifact ID
#### Scenario: Validation warnings shown once per session
- **WHEN** instructions loaded multiple times in same CLI session
- **THEN** each unique validation warning is shown only once (cached)
@@ -0,0 +1,83 @@
# Spec: Schema Resolution with Config
## ADDED Requirements
### Requirement: Use config schema as default for new changes
The system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without explicit `--schema` flag.
#### Scenario: Create change without --schema flag and config exists
- **WHEN** user runs `openspec new change foo` and config contains `schema: "tdd"`
- **THEN** system creates change with schema "tdd"
#### Scenario: Create change without --schema flag and no config
- **WHEN** user runs `openspec new change foo` and no config file exists
- **THEN** system creates change with default schema "spec-driven"
#### Scenario: Create change with explicit --schema flag
- **WHEN** user runs `openspec new change foo --schema custom` and config contains `schema: "tdd"`
- **THEN** system creates change with schema "custom" (CLI flag overrides config)
### Requirement: Resolve schema with updated precedence order
The system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, project config, hardcoded default.
#### Scenario: CLI flag is provided
- **WHEN** user runs command with `--schema custom`
- **THEN** system uses "custom" regardless of change metadata or config
#### Scenario: Change metadata specifies schema
- **WHEN** change has `.openspec.yaml` with `schema: bound` and config has `schema: tdd`
- **THEN** system uses "bound" from change metadata
#### Scenario: Only project config specifies schema
- **WHEN** no CLI flag or change metadata, but config has `schema: tdd`
- **THEN** system uses "tdd" from project config
#### Scenario: No schema specified anywhere
- **WHEN** no CLI flag, change metadata, or project config
- **THEN** system uses hardcoded default "spec-driven"
### Requirement: Support project-local schema names in config
The system SHALL allow the config schema field to reference project-local schemas defined in `openspec/schemas/`.
#### Scenario: Config references project-local schema
- **WHEN** config contains `schema: "my-workflow"` and `openspec/schemas/my-workflow/` exists
- **THEN** system resolves to the project-local schema
#### Scenario: Config references non-existent schema
- **WHEN** config contains `schema: "nonexistent"` and that schema does not exist
- **THEN** system shows error when attempting to load the schema with fuzzy match suggestions and list of all valid schemas
### Requirement: Provide helpful error message for invalid schema
The system SHALL display schema error with fuzzy match suggestions, list of available schemas, and fix instructions.
#### Scenario: Schema name with typo (close match)
- **WHEN** config contains `schema: "spce-driven"` (typo)
- **THEN** error message includes "Did you mean: spec-driven (built-in)" as suggestion
#### Scenario: Schema name with no close matches
- **WHEN** config contains `schema: "completely-wrong"`
- **THEN** error message shows list of all available built-in and project-local schemas
#### Scenario: Error message includes fix instructions
- **WHEN** config references invalid schema
- **THEN** error message includes "Fix: Edit openspec/config.yaml and change 'schema: X' to a valid schema name"
#### Scenario: Error distinguishes built-in vs project-local schemas
- **WHEN** error lists available schemas
- **THEN** output clearly labels each as "built-in" or "project-local"
### Requirement: Maintain backwards compatibility for existing changes
The system SHALL continue to work with existing changes that do not have project config.
#### Scenario: Existing change without config
- **WHEN** change was created before config feature and no config file exists
- **THEN** system resolves schema using existing logic (change metadata or hardcoded default)
#### Scenario: Existing change with config added later
- **WHEN** config file is added to project with existing changes
- **THEN** existing changes continue to use their bound schema from `.openspec.yaml`
+72
View File
@@ -0,0 +1,72 @@
## 1. Core Config System
- [x] 1.1 Create `src/core/project-config.ts` with ProjectConfigSchema using Zod (for docs and type inference)
- [x] 1.2 Implement `readProjectConfig()` with resilient field-by-field parsing using Zod's `safeParse()`
- [x] 1.3 Add support for both .yaml and .yml extensions (prefer .yaml)
- [x] 1.4 Add 50KB hard limit for context field with size check and warning
- [x] 1.5 Implement `validateConfigRules()` to validate artifact IDs against schema (called during instruction loading)
- [x] 1.6 Implement `suggestSchemas()` with Levenshtein distance fuzzy matching for helpful error messages
- [x] 1.7 Add unit tests for resilient parsing (partial configs, field-level errors with Zod safeParse)
- [x] 1.8 Add unit tests for context size limit enforcement
- [x] 1.9 Add unit tests for .yml/.yaml precedence
- [x] 1.10 Add unit tests for fuzzy schema matching with typos
## 2. Schema Resolution Integration
- [x] 2.1 Update `resolveSchemaForChange()` in `src/utils/change-metadata.ts` to check project config (3rd in precedence)
- [x] 2.2 Update `createNewChange()` in `src/utils/change-utils.ts` to use config schema as default
- [x] 2.3 Add integration tests for schema resolution precedence (CLI → change metadata → config → default)
- [x] 2.4 Add test for project-local schema names in config
- [x] 2.5 Add test for non-existent schema error handling with suggestions
## 3. Context and Rules Injection
- [x] 3.1 Update `loadInstructions()` in `src/core/artifact-graph/instruction-loader.ts` to inject context for all artifacts
- [x] 3.2 Add rules injection logic for matching artifacts only with XML tags and bullet formatting
- [x] 3.3 Add validation call during instruction loading to check artifact IDs in rules
- [x] 3.4 Implement session-level warning cache to avoid repeating same validation warnings
- [x] 3.5 Implement proper ordering: `<context>` → `<rules>` → `<template>`
- [x] 3.6 Preserve multi-line strings and special characters without escaping
- [x] 3.7 Add unit tests for context injection (present, absent, multi-line, special chars)
- [x] 3.8 Add unit tests for rules injection (matching artifact, non-matching, empty array, multiple artifacts)
- [x] 3.9 Add unit tests for validation timing (warnings during instruction load, not config load)
- [x] 3.10 Add unit tests for warning deduplication (same warning shown once per session)
- [x] 3.11 Add integration test verifying full instruction output with context + rules + template
## 4. Interactive Config Creation
- [x] 4.1 Add @inquirer/prompts dependency to package.json
- [x] 4.2 Create `src/core/config-prompts.ts` with ConfigPromptResult interface
- [x] 4.3 Implement `promptForConfig()` function with schema selection prompt
- [x] 4.4 Add multi-line context input prompt with examples and skip option
- [x] 4.5 Add per-artifact rules prompts with checkbox selection and line-by-line input
- [x] 4.6 Implement YAML serialization with proper multi-line string formatting
- [x] 4.7 Add validation and retry logic for prompt errors
## 5. Experimental Setup Integration
- [x] 5.1 Update `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts` to check for existing config
- [x] 5.2 Add config creation section after skills/commands creation with header and description
- [x] 5.3 Integrate `promptForConfig()` calls with proper flow control
- [x] 5.4 Add Ctrl+C (ExitPromptError) handling - log cancellation message, continue with setup (non-fatal)
- [x] 5.5 Write created config to `openspec/config.yaml` using YAML stringify
- [x] 5.6 Display success summary showing path, schema, context lines, rules count
- [x] 5.7 Show usage examples and git commit suggestion
- [x] 5.8 Handle existing config case with skip message and manual update instructions
- [x] 5.9 Add error handling for file write failures with fallback suggestions
- [x] 5.10 Add test for cancellation behavior (skills/commands preserved, config not created)
## 6. Testing and Documentation
- [x] 6.1 Add end-to-end test: run experimental setup → create config → create change → verify schema used
- [x] 6.2 Add end-to-end test: create config → get instructions → verify context and rules injected
- [x] 6.3 Test backwards compatibility: existing changes work without config
- [x] 6.4 Test config changes are reflected immediately (no stale cache)
- [x] 6.5 Add performance benchmark: measure config read time with typical config (1KB context)
- [x] 6.6 Add performance benchmark: measure config read time with large config (50KB context)
- [x] 6.7 Add performance benchmark: measure repeated reads within single command
- [x] 6.8 Document benchmark results and decide if caching is needed (target: <10ms typical, <50ms acceptable)
- [x] 6.9 If benchmarks fail: implement mtime-based caching with cache invalidation
- [x] 6.10 Update README or docs with config feature examples and schema
- [x] 6.11 Document common artifact IDs for different schemas
- [x] 6.12 Add troubleshooting section for config validation errors
@@ -0,0 +1,2 @@
schema: spec-driven
created: "2025-01-13"
@@ -0,0 +1,117 @@
## Context
OpenSpec currently resolves schemas from two locations:
1. User override: `~/.local/share/openspec/schemas/<name>/`
2. Package built-in: `<npm-package>/schemas/<name>/`
This change adds a third, highest-priority level: project-local schemas at `./openspec/schemas/<name>/`.
The resolver functions in `src/core/artifact-graph/resolver.ts` currently don't take a `projectRoot` parameter because user and package paths are absolute. To support project-local schemas, we need to pass project root context into the resolver.
## Goals / Non-Goals
**Goals:**
- Enable version-controlled custom workflow schemas
- Allow teams to share schemas via git without per-machine setup
- Maintain backward compatibility with existing resolver API
- Integrate with `config.yaml`'s `schema` field (from project-config change)
**Non-Goals:**
- Schema inheritance or `extends` keyword
- Template-level overrides (partial forks)
- Schema management CLI commands (`openspec schema copy/which/diff/reset`)
- Validation that project-local schema names don't conflict with built-ins (shadowing is intentional)
## Decisions
### Decision 1: Add optional `projectRoot` parameter to resolver functions
**Choice:** Add optional `projectRoot?: string` parameter to resolver functions rather than using `process.cwd()` internally.
**Alternatives considered:**
- Use `process.cwd()` internally: Simpler API but implicit, harder to test, doesn't match existing codebase patterns
- Create separate project-aware functions: No breaking changes but awkward API, callers must compose
**Rationale:** The codebase already follows a pattern where CLI commands get project root via `process.cwd()` and pass it down to functions that need it. Adding an optional parameter maintains backward compatibility while enabling explicit, testable behavior.
**Affected functions:**
```typescript
getSchemaDir(name: string, projectRoot?: string): string | null
listSchemas(projectRoot?: string): string[]
listSchemasWithInfo(projectRoot?: string): SchemaInfo[]
resolveSchema(name: string, projectRoot?: string): SchemaYaml
```
### Decision 2: Resolution order is project → user → package
**Choice:** Project-local schemas have highest priority, then user overrides, then package built-ins.
**Rationale:**
- Project-local should win because it represents team intent (version controlled, shared)
- User overrides still useful for personal experimentation without affecting team
- Package built-ins are the fallback defaults
```
1. ./openspec/schemas/<name>/ # Project-local (highest)
2. ~/.local/share/openspec/schemas/<name>/ # User override
3. <npm-package>/schemas/<name>/ # Package built-in (lowest)
```
### Decision 3: Add `getProjectSchemasDir()` helper function
**Choice:** Create a dedicated function to get the project schemas directory path.
```typescript
function getProjectSchemasDir(projectRoot: string): string {
return path.join(projectRoot, 'openspec', 'schemas');
}
```
**Rationale:** Matches existing pattern with `getPackageSchemasDir()` and `getUserSchemasDir()`. Keeps path logic centralized.
### Decision 4: Extend `SchemaInfo.source` to include `'project'`
**Choice:** Update the source type from `'package' | 'user'` to `'project' | 'user' | 'package'`.
**Rationale:** Consumers need to distinguish project-local schemas for display purposes (e.g., `schemasCommand` output).
### Decision 5: No special handling for schema name conflicts
**Choice:** If a project-local schema has the same name as a built-in (e.g., `spec-driven`), the project-local version wins. No warning, no error.
**Rationale:** This is intentional shadowing. Teams may want to customize a built-in schema while keeping the same name for familiarity.
## Risks / Trade-offs
### Risk: Confusion when project schema shadows built-in
A team could create `openspec/schemas/spec-driven/` that shadows the built-in, causing confusion when someone expects default behavior.
**Mitigation:** The `openspec schemas` command shows the source of each schema. Users can see `spec-driven (project)` vs `spec-driven (package)`.
### Risk: Missing projectRoot parameter
If callers forget to pass `projectRoot`, project-local schemas won't be found.
**Mitigation:**
- Make the change incrementally, updating call sites that need project-local support
- Existing behavior (user + package only) is preserved when `projectRoot` is undefined
### Trade-off: Optional parameter vs required
Making `projectRoot` optional maintains backward compatibility but means some code paths may silently skip project-local resolution.
**Accepted:** Backward compatibility is more important. The main entry points (CLI commands) will always pass `projectRoot`.
## Implementation Approach
1. **Update `resolver.ts`:**
- Add `getProjectSchemasDir(projectRoot: string)` function
- Update `getSchemaDir()` to check project-local first when `projectRoot` provided
- Update `listSchemas()` to include project schemas when `projectRoot` provided
- Update `listSchemasWithInfo()` to return `source: 'project'` for project schemas
- Update `SchemaInfo` type to include `'project'` in source union
2. **Update `artifact-workflow.ts`:**
- Update `schemasCommand` to pass `projectRoot` and display source labels
3. **Update call sites:**
- Any existing code that needs project-local resolution should pass `projectRoot`
- `config.yaml` schema resolution already has access to `projectRoot`
@@ -0,0 +1,167 @@
# Project-Local Schemas
## Summary
Add project-local schema resolution (`./openspec/schemas/`) as the highest priority in the schema lookup chain. This enables teams to version control custom workflow schemas with their repository.
## Motivation
Currently, schema resolution is 2-level:
1. User override: `~/.local/share/openspec/schemas/<name>/`
2. Package built-in: `<npm-package>/schemas/<name>/`
This creates friction for teams:
- Custom schemas must be set up per-machine via XDG paths
- Cannot share schemas via version control
- No single source of truth for team workflows
## Design Decisions
### 3-Level Resolution Order
```
1. ./openspec/schemas/<name>/ # Project-local (NEW)
2. ~/.local/share/openspec/schemas/<name>/ # User global (XDG)
3. <npm-package>/schemas/<name>/ # Package built-in
```
Project-local takes highest priority, enabling:
- Version-controlled custom workflows
- Automatic team sharing via git
- No per-machine setup required
### Fork Model (Not Inheritance)
Custom schemas are complete definitions, not extensions. There is no `extends` keyword.
**Rationale:** Simplicity. Inheritance adds complexity (conflict resolution, partial overrides, debugging "where did this come from?"). Users who need custom workflows can define them fully. This keeps the mental model simple:
- Use a preset → Configure path (see project-config change)
- Need different structure → Fork path (define your own)
### Directory Structure
```
openspec/
├── schemas/ # Project-local schemas
│ └── my-workflow/
│ ├── schema.yaml # Full schema definition
│ └── templates/
│ ├── artifact1.md
│ ├── artifact2.md
│ └── ...
└── changes/
```
### Schema Naming
Project-local schemas are referenced by their directory name:
- `openspec/schemas/my-workflow/` → referenced as `my-workflow`
- Works with `--schema my-workflow` flag
- Works with `schema: my-workflow` in config.yaml (see project-config change)
## Scope
### In Scope
- Add `getProjectSchemasDir()` function to resolver
- Update `getSchemaDir()` to check project-local first
- Update `listSchemas()` to include project schemas
- Update `listSchemasWithInfo()` to include `source: 'project'`
- Update `schemasCommand` output to show project schemas
### Out of Scope
- Schema management CLI (`openspec schema copy/which/diff/reset`) - future enhancement
- Schema inheritance/extends - explicitly not supported
- Template-level overrides (partial fork) - explicitly not supported
## User Experience
### Creating a Custom Schema
```bash
# Create schema directory
mkdir -p openspec/schemas/my-workflow/templates
# Define schema
cat > openspec/schemas/my-workflow/schema.yaml << 'EOF'
name: my-workflow
version: 1
description: Our team's planning workflow
artifacts:
- id: research
generates: research.md
template: research.md
description: Background research
requires: []
- id: proposal
generates: proposal.md
template: proposal.md
description: Change proposal
requires: [research]
- id: tasks
generates: tasks.md
template: tasks.md
description: Implementation tasks
requires: [proposal]
EOF
# Create templates
echo "# Research\n\n..." > openspec/schemas/my-workflow/templates/research.md
# ... etc
```
### Using the Custom Schema
```bash
# Via CLI flag
openspec new change add-feature --schema my-workflow
openspec status --change add-feature --schema my-workflow
# Via config.yaml (requires project-config change)
# schema: my-workflow
```
### Team Sharing
```bash
# Commit to repo
git add openspec/schemas/
git commit -m "Add custom workflow schema"
git push
# Team members get it automatically
git pull
openspec status --change add-feature --schema my-workflow # Just works
```
## Implementation Notes
### Files to Modify
| File | Changes |
|------|---------|
| `src/core/artifact-graph/resolver.ts` | Add `getProjectSchemasDir()`, update resolution order |
| `src/commands/artifact-workflow.ts` | Update `schemasCommand` to show source |
### Project Root Detection
Use existing `findProjectRoot()` pattern or current working directory. The project-local schemas directory is always `./openspec/schemas/` relative to project root.
### Source Indication
`listSchemasWithInfo()` returns `source: 'project' | 'user' | 'package'`. Update type definition and implementation.
## Testing Considerations
- Create temp project with local schema, verify resolution priority
- Verify local schema overrides user override with same name
- Verify `listSchemas()` includes project schemas
- Verify `schemasCommand` shows correct source labels
## Related Changes
- **project-config**: Adds `config.yaml` with `schema` field that can reference project-local schemas
@@ -0,0 +1,88 @@
## ADDED Requirements
### Requirement: Project-local schema resolution
The system SHALL resolve schemas from the project-local directory (`./openspec/schemas/<name>/`) with highest priority when a `projectRoot` is provided.
#### Scenario: Project-local schema takes precedence over user override
- **WHEN** a schema named "my-workflow" exists at `./openspec/schemas/my-workflow/schema.yaml`
- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`
- **AND** `getSchemaDir("my-workflow", projectRoot)` is called
- **THEN** the system SHALL return the project-local path
#### Scenario: Project-local schema takes precedence over package built-in
- **WHEN** a schema named "spec-driven" exists at `./openspec/schemas/spec-driven/schema.yaml`
- **AND** "spec-driven" is a package built-in schema
- **AND** `getSchemaDir("spec-driven", projectRoot)` is called
- **THEN** the system SHALL return the project-local path
#### Scenario: Falls back to user override when no project-local schema
- **WHEN** no schema named "my-workflow" exists at `./openspec/schemas/my-workflow/`
- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`
- **AND** `getSchemaDir("my-workflow", projectRoot)` is called
- **THEN** the system SHALL return the user override path
#### Scenario: Falls back to package built-in when no project-local or user schema
- **WHEN** no schema named "spec-driven" exists at `./openspec/schemas/spec-driven/`
- **AND** no schema named "spec-driven" exists at `~/.local/share/openspec/schemas/spec-driven/`
- **AND** "spec-driven" is a package built-in schema
- **AND** `getSchemaDir("spec-driven", projectRoot)` is called
- **THEN** the system SHALL return the package built-in path
#### Scenario: Backward compatibility when projectRoot not provided
- **WHEN** `getSchemaDir("my-workflow")` is called without a `projectRoot` parameter
- **THEN** the system SHALL only check user override and package built-in locations
- **AND** the system SHALL NOT check project-local location
### Requirement: Project schemas directory helper
The system SHALL provide a `getProjectSchemasDir(projectRoot)` function that returns the project-local schemas directory path.
#### Scenario: Returns correct path
- **WHEN** `getProjectSchemasDir("/path/to/project")` is called
- **THEN** the system SHALL return `/path/to/project/openspec/schemas`
### Requirement: List schemas includes project-local
The system SHALL include project-local schemas when listing available schemas if `projectRoot` is provided.
#### Scenario: Project-local schemas appear in list
- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml`
- **AND** `listSchemas(projectRoot)` is called
- **THEN** the returned list SHALL include "team-flow"
#### Scenario: Project-local schema shadows same-named user schema in list
- **WHEN** a schema named "custom" exists at both project-local and user override locations
- **AND** `listSchemas(projectRoot)` is called
- **THEN** the returned list SHALL include "custom" exactly once
#### Scenario: Backward compatibility for listSchemas
- **WHEN** `listSchemas()` is called without a `projectRoot` parameter
- **THEN** the system SHALL only include user override and package built-in schemas
### Requirement: Schema info includes project source
The system SHALL indicate `source: 'project'` for project-local schemas in `listSchemasWithInfo()` results.
#### Scenario: Project-local schema shows project source
- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml`
- **AND** `listSchemasWithInfo(projectRoot)` is called
- **THEN** the schema info for "team-flow" SHALL have `source: 'project'`
#### Scenario: User override schema shows user source
- **WHEN** a schema named "my-custom" exists only at `~/.local/share/openspec/schemas/my-custom/`
- **AND** `listSchemasWithInfo(projectRoot)` is called
- **THEN** the schema info for "my-custom" SHALL have `source: 'user'`
#### Scenario: Package built-in schema shows package source
- **WHEN** "spec-driven" exists only as a package built-in
- **AND** `listSchemasWithInfo(projectRoot)` is called
- **THEN** the schema info for "spec-driven" SHALL have `source: 'package'`
### Requirement: Schemas command shows source
The `openspec schemas` command SHALL display the source of each schema.
#### Scenario: Display format includes source
- **WHEN** user runs `openspec schemas`
- **THEN** the output SHALL show each schema with its source label (project, user, or package)
@@ -0,0 +1,28 @@
## 1. Update Resolver Types and Helpers
- [x] 1.1 Update `SchemaInfo.source` type to include `'project'` in `src/core/artifact-graph/resolver.ts`
- [x] 1.2 Add `getProjectSchemasDir(projectRoot: string): string` function
## 2. Update Schema Resolution Functions
- [x] 2.1 Update `getSchemaDir(name, projectRoot?)` to check project-local first when projectRoot provided
- [x] 2.2 Update `resolveSchema(name, projectRoot?)` to pass projectRoot to getSchemaDir
- [x] 2.3 Update `listSchemas(projectRoot?)` to include project-local schemas
- [x] 2.4 Update `listSchemasWithInfo(projectRoot?)` to include project schemas with `source: 'project'`
## 3. Update CLI Commands
- [x] 3.1 Update `schemasCommand` to pass projectRoot and display source labels in output
## 4. Update Call Sites
- [x] 4.1 Review and update call sites that need project-local schema support to pass projectRoot
## 5. Testing
- [x] 5.1 Add unit tests for `getProjectSchemasDir()`
- [x] 5.2 Add unit tests for project-local schema resolution priority
- [x] 5.3 Add unit tests for backward compatibility (no projectRoot = user + package only)
- [x] 5.4 Add unit tests for `listSchemas()` including project schemas
- [x] 5.5 Add unit tests for `listSchemasWithInfo()` with `source: 'project'`
- [x] 5.6 Add integration test with temp project containing local schema
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-20
@@ -0,0 +1,113 @@
## Context
OpenSpec uses workflow schemas to define artifact sequences for change proposals. Currently, schemas are resolved from three locations (project → user → package), but managing custom schemas requires manual file creation with no tooling support. The resolver infrastructure exists (`src/core/artifact-graph/resolver.ts`) but there's no CLI exposure for schema management operations.
Users who want to customize workflows must:
1. Manually create directory structures under `openspec/schemas/<name>/`
2. Copy and modify `schema.yaml` files without validation
3. Debug resolution issues by inspecting the filesystem directly
This creates friction for schema customization and leads to runtime errors when schemas are malformed.
## Goals / Non-Goals
**Goals:**
- Provide CLI commands for common schema management operations
- Enable interactive schema creation with guided prompts
- Allow forking existing schemas as customization starting points
- Surface schema validation errors before runtime
- Help debug schema resolution order when shadowing occurs
**Non-Goals:**
- Schema editing (users edit YAML directly or via `$EDITOR`)
- Schema publishing or sharing mechanisms
- Schema versioning or migration tooling
- Validation of template file contents (only checks existence)
- Schema inheritance or composition beyond simple forking
## Decisions
### 1. Command Structure: `openspec schema <subcommand>`
Add a new command group following the existing pattern used by `openspec config` and `openspec completion`.
**Rationale:** Grouping related commands under a noun (schema) matches the established CLI patterns and provides a natural namespace for future schema operations.
**Alternatives considered:**
- Flat commands (`openspec schema-init`, `openspec schema-fork`): Rejected because it pollutes the top-level namespace and doesn't scale well.
- Extending existing commands (`openspec init --schema`): Rejected because schema management is distinct from project initialization.
### 2. Implementation Location
New file `src/commands/schema.ts` with a `registerSchemaCommand(program: Command)` function that registers the `schema` command group and all subcommands.
**Rationale:** Follows the pattern established by `config.ts` and matches how other command groups are organized.
### 3. Schema Validation Approach
Validation checks:
1. `schema.yaml` exists and is valid YAML
2. Parses successfully against the Zod schema in `types.ts`
3. All referenced template files exist in the schema directory
4. Artifact dependency graph has no cycles (use existing topological sort)
**Rationale:** Reuse existing validation infrastructure (`parseSchema` from `schema.ts`) and extend with template existence checks. This catches the most common errors without duplicating validation logic.
**Alternatives considered:**
- Deep template validation (check frontmatter, syntax): Rejected as over-engineering. Template contents are free-form markdown.
### 4. Interactive Prompts for `schema init`
Use `@inquirer/prompts` (already a dependency) for:
- Schema name input with kebab-case validation
- Schema description input
- Multi-select for artifact selection with descriptions
- Optional: set as project default
**Rationale:** Matches the UX established by `openspec init` and `openspec config reset`. Provides a guided experience while keeping the wizard lightweight.
### 5. Fork Source Resolution
`schema fork <source>` resolves the source schema using the existing `getSchemaDir()` function, respecting the full resolution order (project → user → package). This allows forking from any accessible schema.
The destination is always project-local: `openspec/schemas/<name>/`
**Rationale:** Forking to project scope makes sense because:
- Custom schemas are project-specific decisions
- User-global schemas can be added manually if needed
- Keeps the command simple with a clear default
### 6. Output Format Consistency
All commands support `--json` flag for machine-readable output:
- `schema init`: Outputs `{ "created": true, "path": "...", "schema": "..." }`
- `schema fork`: Outputs `{ "forked": true, "source": "...", "destination": "..." }`
- `schema validate`: Outputs validation report matching existing validate command format
- `schema which`: Outputs `{ "name": "...", "source": "project|user|package", "path": "..." }`
Text output uses ora spinners for progress and clear success/error messaging.
**Rationale:** Consistent with existing OpenSpec commands and enables scripting/automation.
### 7. Schema `which` Command Design
Shows resolution details for a schema name:
- Which location it resolves from (project/user/package)
- Full path to the schema directory
- Whether it shadows other schemas at lower priority levels
**Rationale:** Essential for debugging "why isn't my schema being used?" scenarios when multiple schemas with the same name exist.
## Risks / Trade-offs
**[Template scaffolding may become stale]** → The `schema init` command will scaffold a default set of artifacts (proposal, specs, design, tasks). If the built-in schema patterns evolve, these templates may not reflect best practices.
- *Mitigation*: Document that `init` creates a minimal starting point. Users can `fork` built-in schemas for the latest patterns.
**[Interactive prompts in CI environments]** → `schema init` with prompts may hang in non-interactive environments.
- *Mitigation*: Support `--name`, `--description`, and `--artifacts` flags for non-interactive use. Detect TTY and show helpful error if prompts would hang.
**[Validation doesn't catch all errors]** → Schema validation checks structure but can't verify semantic correctness (e.g., a template that doesn't match its artifact purpose).
- *Mitigation*: This is acceptable. Full semantic validation would require understanding template intent, which is out of scope.
**[Fork overwrites without warning]** → If target schema already exists, `fork` could overwrite it.
- *Mitigation*: Check for existing schema and require `--force` flag or interactive confirmation before overwriting.
@@ -0,0 +1,55 @@
## Why
Creating and managing project-local schemas currently requires manual directory creation, copying files, and hoping the structure is correct. Users only discover structural errors at runtime when commands fail. This friction discourages schema customization and makes it harder to tailor OpenSpec workflows to specific project needs.
Key pain points:
- **Manual scaffolding**: Users must manually create `openspec/schemas/<name>/` with correct structure
- **No validation feedback**: Schema errors aren't caught until a command tries to use the schema
- **Starting from scratch is hard**: No easy way to base a custom schema on an existing one
- **Debugging resolution**: When a schema doesn't resolve as expected, there's no way to see the resolution path
## What Changes
Add a new `openspec schema` command group with subcommands for creating, forking, validating, and inspecting schemas.
### Commands
1. **`openspec schema init <name>`** - Interactive wizard to scaffold a new project schema
- Prompts for schema description
- Prompts for artifacts to include (with explanations)
- Creates valid directory structure with `schema.yaml` and template files
- Optionally sets as project default in `openspec/config.yaml`
2. **`openspec schema fork <source> [name]`** - Copy an existing schema as a starting point
- Copies from user override or package built-in
- Allows renaming (defaults to `<source>-custom`)
- Preserves all templates and configuration
3. **`openspec schema validate [name]`** - Validate schema structure and templates
- Checks `schema.yaml` is valid
- Verifies all referenced templates exist
- Reports missing or malformed files
- Run without name to validate all project schemas
4. **`openspec schema which <name>`** - Show schema resolution path
- Displays which location the schema resolves from (project/user/package)
- Shows full path to schema directory
- Useful for debugging shadowing issues
## Capabilities
### New Capabilities
- `schema-init-command`: Interactive wizard for creating new project schemas with guided prompts
- `schema-fork-command`: Copy existing schemas to project for customization
- `schema-validate-command`: Validate schema structure and report errors before runtime
- `schema-which-command`: Debug schema resolution by showing which location is used
### Modified Capabilities
<!-- None - these are additive commands -->
## Impact
- **Code**: New command implementations in `src/commands/` using existing resolver infrastructure
- **CLI**: New `schema` command group with 4 subcommands
- **Dependencies**: May use `enquirer` or similar for interactive prompts in `schema init`
- **Documentation**: Need to update CLI reference and schema customization guide
@@ -0,0 +1,66 @@
## ADDED Requirements
### Requirement: Schema fork copies existing schema
The CLI SHALL provide an `openspec schema fork <source> [name]` command that copies an existing schema to the project's `openspec/schemas/` directory.
#### Scenario: Fork with explicit name
- **WHEN** user runs `openspec schema fork spec-driven my-custom`
- **THEN** system locates `spec-driven` schema using resolution order (project → user → package)
- **AND** copies all files to `openspec/schemas/my-custom/`
- **AND** updates `name` field in `schema.yaml` to `my-custom`
- **AND** displays success message with source and destination paths
#### Scenario: Fork with default name
- **WHEN** user runs `openspec schema fork spec-driven` without specifying a name
- **THEN** system copies to `openspec/schemas/spec-driven-custom/`
- **AND** updates `name` field in `schema.yaml` to `spec-driven-custom`
#### Scenario: Source schema not found
- **WHEN** user runs `openspec schema fork nonexistent`
- **THEN** system displays error that schema was not found
- **AND** lists available schemas
- **AND** exits with non-zero code
### Requirement: Schema fork prevents accidental overwrites
The CLI SHALL require confirmation or `--force` flag when the destination schema already exists.
#### Scenario: Destination exists without force
- **WHEN** user runs `openspec schema fork spec-driven my-custom` and `openspec/schemas/my-custom/` exists
- **THEN** system displays error that destination already exists
- **AND** suggests using `--force` to overwrite
- **AND** exits with non-zero code
#### Scenario: Destination exists with force flag
- **WHEN** user runs `openspec schema fork spec-driven my-custom --force` and destination exists
- **THEN** system removes existing destination directory
- **AND** copies source schema to destination
- **AND** displays success message
#### Scenario: Interactive confirmation for overwrite
- **WHEN** user runs `openspec schema fork spec-driven my-custom` in interactive mode and destination exists
- **THEN** system prompts for confirmation to overwrite
- **AND** proceeds based on user response
### Requirement: Schema fork preserves all schema files
The CLI SHALL copy the complete schema directory including templates, configuration, and any additional files.
#### Scenario: Copy includes template files
- **WHEN** user forks a schema with template files (e.g., `proposal.md`, `design.md`)
- **THEN** all template files are copied to the destination
- **AND** template file contents are unchanged
#### Scenario: Copy includes nested directories
- **WHEN** user forks a schema with nested directories (e.g., `templates/specs/`)
- **THEN** nested directory structure is preserved
- **AND** all nested files are copied
### Requirement: Schema fork outputs JSON format
The CLI SHALL support `--json` flag for machine-readable output.
#### Scenario: JSON output on success
- **WHEN** user runs `openspec schema fork spec-driven my-custom --json`
- **THEN** system outputs JSON with `forked: true`, `source`, `destination`, and `sourcePath` fields
#### Scenario: JSON output shows source location
- **WHEN** user runs `openspec schema fork spec-driven --json`
- **THEN** JSON output includes `sourceLocation` field indicating "project", "user", or "package"
@@ -0,0 +1,71 @@
## ADDED Requirements
### Requirement: Schema init command creates project-local schema
The CLI SHALL provide an `openspec schema init <name>` command that creates a new schema directory under `openspec/schemas/<name>/` with a valid `schema.yaml` file and default template files.
#### Scenario: Create schema with valid name
- **WHEN** user runs `openspec schema init my-workflow`
- **THEN** system creates directory `openspec/schemas/my-workflow/`
- **AND** creates `schema.yaml` with name, version, description, and artifacts array
- **AND** creates template files referenced by artifacts
- **AND** displays success message with created path
#### Scenario: Reject invalid schema name
- **WHEN** user runs `openspec schema init "My Workflow"` (contains space)
- **THEN** system displays error about invalid schema name
- **AND** suggests using kebab-case format
- **AND** exits with non-zero code
#### Scenario: Schema name already exists
- **WHEN** user runs `openspec schema init existing-schema` and `openspec/schemas/existing-schema/` already exists
- **THEN** system displays error that schema already exists
- **AND** suggests using `--force` to overwrite or `schema fork` to copy
- **AND** exits with non-zero code
### Requirement: Schema init supports interactive mode
The CLI SHALL prompt for schema configuration when run in an interactive terminal without explicit flags.
#### Scenario: Interactive prompts for description
- **WHEN** user runs `openspec schema init my-workflow` in an interactive terminal
- **THEN** system prompts for schema description
- **AND** uses provided description in generated `schema.yaml`
#### Scenario: Interactive prompts for artifact selection
- **WHEN** user runs `openspec schema init my-workflow` in an interactive terminal
- **THEN** system displays multi-select prompt with common artifacts (proposal, specs, design, tasks)
- **AND** each option includes a brief description
- **AND** uses selected artifacts in generated `schema.yaml`
#### Scenario: Non-interactive mode with flags
- **WHEN** user runs `openspec schema init my-workflow --description "My workflow" --artifacts proposal,tasks`
- **THEN** system creates schema without prompting
- **AND** uses flag values for configuration
### Requirement: Schema init supports setting project default
The CLI SHALL offer to set the newly created schema as the project default.
#### Scenario: Set as default interactively
- **WHEN** user runs `openspec schema init my-workflow` in interactive mode
- **AND** user confirms setting as default
- **THEN** system updates `openspec/config.yaml` with `defaultSchema: my-workflow`
#### Scenario: Set as default via flag
- **WHEN** user runs `openspec schema init my-workflow --default`
- **THEN** system creates schema and updates `openspec/config.yaml` with `defaultSchema: my-workflow`
#### Scenario: Skip setting default
- **WHEN** user runs `openspec schema init my-workflow --no-default`
- **THEN** system creates schema without modifying `openspec/config.yaml`
### Requirement: Schema init outputs JSON format
The CLI SHALL support `--json` flag for machine-readable output.
#### Scenario: JSON output on success
- **WHEN** user runs `openspec schema init my-workflow --json --description "Test" --artifacts proposal`
- **THEN** system outputs JSON with `created: true`, `path`, and `schema` fields
- **AND** does not display interactive prompts or spinners
#### Scenario: JSON output on error
- **WHEN** user runs `openspec schema init "invalid name" --json`
- **THEN** system outputs JSON with `error` field describing the issue
- **AND** exits with non-zero code
@@ -0,0 +1,86 @@
## ADDED Requirements
### Requirement: Schema validate checks schema structure
The CLI SHALL provide an `openspec schema validate [name]` command that validates schema configuration and reports errors.
#### Scenario: Validate specific schema
- **WHEN** user runs `openspec schema validate my-workflow`
- **THEN** system locates schema using resolution order
- **AND** validates `schema.yaml` against the schema Zod type
- **AND** displays validation result (valid or list of errors)
#### Scenario: Validate all project schemas
- **WHEN** user runs `openspec schema validate` without a name
- **THEN** system validates all schemas in `openspec/schemas/`
- **AND** displays results for each schema
- **AND** exits with non-zero code if any schema is invalid
#### Scenario: Schema not found
- **WHEN** user runs `openspec schema validate nonexistent`
- **THEN** system displays error that schema was not found
- **AND** exits with non-zero code
### Requirement: Schema validate checks YAML syntax
The CLI SHALL report YAML parsing errors with line numbers when possible.
#### Scenario: Invalid YAML syntax
- **WHEN** user runs `openspec schema validate my-workflow` and `schema.yaml` has syntax errors
- **THEN** system displays YAML parse error with line number
- **AND** exits with non-zero code
#### Scenario: Valid YAML but missing required fields
- **WHEN** `schema.yaml` is valid YAML but missing `name` field
- **THEN** system displays Zod validation error for missing required field
- **AND** identifies the specific missing field
### Requirement: Schema validate checks template existence
The CLI SHALL verify that all template files referenced by artifacts exist.
#### Scenario: Missing template file
- **WHEN** artifact references `template: proposal.md` but file doesn't exist in schema directory
- **THEN** system reports error: "Template file 'proposal.md' not found for artifact 'proposal'"
- **AND** exits with non-zero code
#### Scenario: All templates exist
- **WHEN** all artifact templates exist
- **THEN** system reports that templates are valid
- **AND** template existence is included in validation summary
### Requirement: Schema validate checks dependency graph
The CLI SHALL verify that artifact dependencies form a valid directed acyclic graph.
#### Scenario: Valid dependency graph
- **WHEN** artifact dependencies form a valid DAG (e.g., tasks → specs → proposal)
- **THEN** system reports dependency graph is valid
#### Scenario: Circular dependency detected
- **WHEN** artifact A requires B and artifact B requires A
- **THEN** system reports circular dependency error
- **AND** identifies the artifacts involved in the cycle
- **AND** exits with non-zero code
#### Scenario: Unknown dependency reference
- **WHEN** artifact requires `nonexistent-artifact`
- **THEN** system reports error: "Artifact 'x' requires unknown artifact 'nonexistent-artifact'"
- **AND** exits with non-zero code
### Requirement: Schema validate outputs JSON format
The CLI SHALL support `--json` flag for machine-readable validation results.
#### Scenario: JSON output for valid schema
- **WHEN** user runs `openspec schema validate my-workflow --json` and schema is valid
- **THEN** system outputs JSON with `valid: true`, `name`, and `path` fields
#### Scenario: JSON output for invalid schema
- **WHEN** user runs `openspec schema validate my-workflow --json` and schema has errors
- **THEN** system outputs JSON with `valid: false` and `issues` array
- **AND** each issue includes `level`, `path`, and `message` fields
- **AND** format matches existing `openspec validate` output structure
### Requirement: Schema validate supports verbose mode
The CLI SHALL support `--verbose` flag for detailed validation information.
#### Scenario: Verbose output shows all checks
- **WHEN** user runs `openspec schema validate my-workflow --verbose`
- **THEN** system displays each validation check as it runs
- **AND** shows pass/fail status for: YAML parsing, Zod validation, template existence, dependency graph
@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Schema which shows resolution result
The CLI SHALL provide an `openspec schema which <name>` command that displays where a schema resolves from.
#### Scenario: Schema resolves from project
- **WHEN** user runs `openspec schema which my-workflow` and schema exists in `openspec/schemas/my-workflow/`
- **THEN** system displays source as "project"
- **AND** displays full path to schema directory
#### Scenario: Schema resolves from user directory
- **WHEN** user runs `openspec schema which my-workflow` and schema exists only in user data directory
- **THEN** system displays source as "user"
- **AND** displays full path including XDG data directory
#### Scenario: Schema resolves from package
- **WHEN** user runs `openspec schema which spec-driven` and no override exists
- **THEN** system displays source as "package"
- **AND** displays full path to package's schemas directory
#### Scenario: Schema not found
- **WHEN** user runs `openspec schema which nonexistent`
- **THEN** system displays error that schema was not found
- **AND** lists available schemas
- **AND** exits with non-zero code
### Requirement: Schema which shows shadowing information
The CLI SHALL indicate when a schema shadows another schema at a lower priority level.
#### Scenario: Project schema shadows package
- **WHEN** user runs `openspec schema which spec-driven` and both project and package have `spec-driven`
- **THEN** system displays that project schema is active
- **AND** indicates it shadows the package version
- **AND** shows path to shadowed package schema
#### Scenario: No shadowing
- **WHEN** schema exists only in one location
- **THEN** system does not display shadowing information
#### Scenario: Multiple shadows
- **WHEN** project schema shadows both user and package schemas
- **THEN** system lists all shadowed locations in priority order
### Requirement: Schema which outputs JSON format
The CLI SHALL support `--json` flag for machine-readable output.
#### Scenario: JSON output basic
- **WHEN** user runs `openspec schema which spec-driven --json`
- **THEN** system outputs JSON with `name`, `source`, and `path` fields
#### Scenario: JSON output with shadows
- **WHEN** user runs `openspec schema which spec-driven --json` and schema has shadows
- **THEN** JSON includes `shadows` array with `source` and `path` for each shadowed schema
### Requirement: Schema which supports list mode
The CLI SHALL support listing all schemas with their resolution sources.
#### Scenario: List all schemas
- **WHEN** user runs `openspec schema which --all`
- **THEN** system displays all available schemas grouped by source
- **AND** indicates which schemas shadow others
#### Scenario: List in JSON format
- **WHEN** user runs `openspec schema which --all --json`
- **THEN** system outputs JSON array with resolution info for each schema
@@ -0,0 +1,67 @@
## 1. Setup and Command Structure
- [x] 1.1 Create `src/commands/schema.ts` with `registerSchemaCommand(program: Command)` function
- [x] 1.2 Register schema command in `src/cli/index.ts` (import and call `registerSchemaCommand`)
- [x] 1.3 Add schema command group with description: "Manage workflow schemas"
## 2. Schema Which Command
- [x] 2.1 Add `schema which <name>` subcommand with `--json` and `--all` options
- [x] 2.2 Implement resolution lookup using `getSchemaDir()` with project root
- [x] 2.3 Implement shadow detection by checking all three locations (project, user, package)
- [x] 2.4 Add text output: show source, path, and shadowing info
- [x] 2.5 Add JSON output: `{ name, source, path, shadows: [] }`
- [x] 2.6 Add `--all` mode to list all schemas with their resolution sources
## 3. Schema Validate Command
- [x] 3.1 Add `schema validate [name]` subcommand with `--json` and `--verbose` options
- [x] 3.2 Implement single-schema validation using existing `parseSchema()` from `schema.ts`
- [x] 3.3 Add template existence check for each artifact's template file
- [x] 3.4 Add dependency graph cycle detection (reuse topological sort logic)
- [x] 3.5 Add validate-all mode when no name provided (scan `openspec/schemas/`)
- [x] 3.6 Add text output with pass/fail indicators and error messages
- [x] 3.7 Add JSON output matching existing `openspec validate` format: `{ valid, issues: [] }`
- [x] 3.8 Add verbose mode showing each validation step
## 4. Schema Fork Command
- [x] 4.1 Add `schema fork <source> [name]` subcommand with `--json` and `--force` options
- [x] 4.2 Implement source resolution using `getSchemaDir()` with project root
- [x] 4.3 Implement default destination naming: `<source>-custom`
- [x] 4.4 Implement directory copy with recursive file copy
- [x] 4.5 Update `name` field in copied `schema.yaml`
- [x] 4.6 Add overwrite protection: check destination exists, require `--force` or confirmation
- [x] 4.7 Add text output with source/destination paths
- [x] 4.8 Add JSON output: `{ forked, source, destination, sourceLocation }`
## 5. Schema Init Command
- [x] 5.1 Add `schema init <name>` subcommand with `--json`, `--description`, `--artifacts`, `--default`, `--no-default`, `--force` options
- [x] 5.2 Implement schema name validation (kebab-case, no spaces)
- [x] 5.3 Implement interactive prompts for description using `@inquirer/prompts`
- [x] 5.4 Implement interactive artifact selection with descriptions (multi-select)
- [x] 5.5 Create schema directory and `schema.yaml` with selected configuration
- [x] 5.6 Create default template files for selected artifacts
- [x] 5.7 Add `--default` flag to update `openspec/config.yaml` with new schema as default
- [x] 5.8 Add overwrite protection: check if schema exists, require `--force`
- [x] 5.9 Add text output with created path and next steps
- [x] 5.10 Add JSON output: `{ created, path, schema }`
- [x] 5.11 Add non-interactive mode with `--description` and `--artifacts` flags
## 6. Testing
- [x] 6.1 Add unit tests for `schema which` command in `test/commands/schema.test.ts`
- [x] 6.2 Add unit tests for `schema validate` command
- [x] 6.3 Add unit tests for `schema fork` command
- [x] 6.4 Add unit tests for `schema init` command
- [x] 6.5 Test interactive mode mocking with `@inquirer/prompts`
- [x] 6.6 Test JSON output format for all commands
- [x] 6.7 Test error cases: invalid name, not found, already exists, cycle detection
## 7. Documentation and Polish
- [x] 7.1 Add CLI help text for all schema subcommands
- [x] 7.2 Update shell completion to include schema commands
- [x] 7.3 Run linting and fix any issues (`npm run lint`)
- [x] 7.4 Run full test suite (`npm test`)
+24
View File
@@ -0,0 +1,24 @@
schema: spec-driven
context: |
Tech stack: TypeScript, Node.js (≥20.19.0), ESM modules
Package manager: pnpm
CLI framework: Commander.js
Cross-platform requirements:
- This tool runs on macOS, Linux, AND Windows
- Always use path.join() or path.resolve() for file paths - never hardcode slashes
- Never assume forward-slash path separators
- Tests must use path.join() for expected path values, not hardcoded strings
- Consider case sensitivity differences in file systems
rules:
specs:
- Include scenarios for Windows path handling when dealing with file paths
- Requirements involving paths must specify cross-platform behavior
tasks:
- Add Windows CI verification as a task when changes involve file paths
- Include cross-platform testing considerations
design:
- Document any platform-specific behavior or limitations
- Prefer Node.js path module over string manipulation for paths
+107
View File
@@ -0,0 +1,107 @@
# ci-nix-validation Specification
## Purpose
Validates Nix flake builds and maintenance scripts in CI to ensure Nix users can reliably install and use OpenSpec. Prevents regressions in Nix support by testing builds and the update-flake.sh script on every pull request and push to main.
## Requirements
### Requirement: Nix Flake Build Validation
The CI system SHALL validate that the Nix flake builds successfully on every pull request and push to main.
#### Scenario: Successful flake build
- **WHEN** a pull request or push to main is made
- **THEN** the CI SHALL execute `nix build` and verify it completes with exit code 0
- **AND** the build output SHALL contain the openspec binary
#### Scenario: Flake build failure
- **WHEN** the Nix flake configuration is broken
- **THEN** the CI job SHALL fail with a non-zero exit code
- **AND** the CI SHALL prevent merging of the pull request
#### Scenario: Multi-platform support check
- **WHEN** the flake declares support for multiple systems
- **THEN** the CI SHALL validate the flake builds on at least Linux (x86_64-linux)
### Requirement: Update Script Validation
The CI system SHALL validate that the update-flake.sh script executes successfully and produces valid output.
#### Scenario: Update script execution
- **WHEN** the CI runs the update script validation
- **THEN** the script SHALL execute without errors
- **AND** the script SHALL correctly extract the version from package.json
- **AND** the script SHALL update flake.nix with the correct version
#### Scenario: Update script with mock hash
- **WHEN** validating the update script in CI
- **THEN** the script SHALL be able to detect and extract the correct pnpm dependency hash
- **AND** the flake.nix SHALL be updated with a valid sha256 hash
### Requirement: CI Job Integration
The Nix validation jobs SHALL be integrated into the existing GitHub Actions workflow and required for merge.
#### Scenario: PR merge requirements
- **WHEN** a pull request is created
- **THEN** the Nix validation job SHALL be included in required checks
- **AND** the PR SHALL NOT be mergeable until Nix validation passes
#### Scenario: Job execution triggers
- **WHEN** code is pushed to a pull request OR pushed to main OR manually triggered
- **THEN** the Nix validation job SHALL execute automatically
### Requirement: Local Testing Support
The CI workflow SHALL be testable locally using the `act` tool to enable rapid iteration.
#### Scenario: Local CI execution with act
- **WHEN** a developer runs `act` with the Nix validation workflow
- **THEN** the workflow SHALL execute in the local Docker environment
- **AND** the developer SHALL receive feedback on Nix build status without pushing to GitHub
#### Scenario: Act configuration compatibility
- **WHEN** the workflow is designed
- **THEN** it SHALL use standard GitHub Actions syntax compatible with `act`
- **AND** any Nix-specific setup SHALL work in the act Docker environment
### Requirement: Nix Installation in CI
The CI environment SHALL have Nix properly installed and configured before running validation.
#### Scenario: Nix installation step
- **WHEN** the Nix validation job starts
- **THEN** Nix SHALL be installed using the official Nix installer or determinatesystems/nix-installer-action
- **AND** the Nix installation SHALL be cached for subsequent runs to improve performance
#### Scenario: Nix configuration for CI
- **WHEN** Nix is installed in CI
- **THEN** it SHALL be configured to work in the GitHub Actions environment
- **AND** experimental features (flakes, nix-command) SHALL be enabled
### Requirement: CI Performance Optimization
The Nix validation SHALL be optimized to minimize CI runtime impact.
#### Scenario: Acceptable runtime
- **WHEN** the Nix validation job runs
- **THEN** it SHALL complete in under 5 minutes on a clean run
- **AND** with caching, it SHALL complete in under 3 minutes on subsequent runs
#### Scenario: Parallel execution
- **WHEN** multiple CI jobs are running
- **THEN** the Nix validation job SHALL run in parallel with other validation jobs (tests, lint)
- **AND** SHALL NOT block other independent checks
+6
View File
@@ -211,6 +211,12 @@ The init command SHALL generate slash command files for supported editors using
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Continue
- **WHEN** the user selects Continue during initialization
- **THEN** create `.continue/prompts/openspec-proposal.prompt`, `.continue/prompts/openspec-apply.prompt`, and `.continue/prompts/openspec-archive.prompt`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
+5
View File
@@ -75,6 +75,11 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Continue
- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.18.0",
"version": "0.22.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -54,13 +54,13 @@
"check:pack-version": "node scripts/pack-version-check.mjs",
"release": "pnpm run release:ci",
"release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
"release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
"changeset": "changeset"
},
"engines": {
"node": ">=20.19.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.2",
"@changesets/cli": "^2.27.7",
"@types/node": "^24.2.0",
"@vitest/ui": "^3.2.4",
+66
View File
@@ -36,6 +36,9 @@ importers:
specifier: ^4.0.17
version: 4.0.17
devDependencies:
'@changesets/changelog-github':
specifier: ^0.5.2
version: 0.5.2
'@changesets/cli':
specifier: ^2.27.7
version: 2.29.6(@types/node@24.2.0)
@@ -73,6 +76,9 @@ packages:
'@changesets/changelog-git@0.2.1':
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
'@changesets/changelog-github@0.5.2':
resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==}
'@changesets/cli@2.29.6':
resolution: {integrity: sha512-6qCcVsIG1KQLhpQ5zE8N0PckIx4+9QlHK3z6/lwKnw7Tir71Bjw8BeOZaxA/4Jt00pcgCnCSWZnyuZf5Il05QQ==}
hasBin: true
@@ -86,6 +92,9 @@ packages:
'@changesets/get-dependents-graph@2.1.3':
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
'@changesets/get-github-info@0.7.0':
resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==}
'@changesets/get-release-plan@4.0.13':
resolution: {integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==}
@@ -829,6 +838,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
dataloader@1.4.0:
resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -853,6 +865,10 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dotenv@8.6.0:
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
engines: {node: '>=10'}
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -1198,6 +1214,15 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
@@ -1465,6 +1490,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -1574,6 +1602,12 @@ packages:
jsdom:
optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1641,6 +1675,14 @@ snapshots:
dependencies:
'@changesets/types': 6.1.0
'@changesets/changelog-github@0.5.2':
dependencies:
'@changesets/get-github-info': 0.7.0
'@changesets/types': 6.1.0
dotenv: 8.6.0
transitivePeerDependencies:
- encoding
'@changesets/cli@2.29.6(@types/node@24.2.0)':
dependencies:
'@changesets/apply-release-plan': 7.0.12
@@ -1695,6 +1737,13 @@ snapshots:
picocolors: 1.1.1
semver: 7.7.2
'@changesets/get-github-info@0.7.0':
dependencies:
dataloader: 1.4.0
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
'@changesets/get-release-plan@4.0.13':
dependencies:
'@changesets/assemble-release-plan': 6.0.9
@@ -2379,6 +2428,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
dataloader@1.4.0: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -2393,6 +2444,8 @@ snapshots:
dependencies:
path-type: 4.0.0
dotenv@8.6.0: {}
emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {}
@@ -2737,6 +2790,10 @@ snapshots:
natural-compare@1.4.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
@@ -2985,6 +3042,8 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
ts-api-utils@2.1.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -3092,6 +3151,13 @@ snapshots:
- tsx
- yaml
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0
+38
View File
@@ -0,0 +1,38 @@
# OpenSpec Scripts
Utility scripts for OpenSpec maintenance and development.
## update-flake.sh
Updates `flake.nix` version and dependency hash automatically.
**When to use**: After updating dependencies or releasing a new version.
**Usage**:
```bash
./scripts/update-flake.sh
```
**What it does**:
1. Extracts version from `package.json`
2. Updates version in `flake.nix`
3. Automatically determines the correct pnpm dependency hash
4. Updates the hash in `flake.nix`
5. Verifies the build succeeds
**Example workflow**:
```bash
# After version bump and dependency updates
pnpm install
./scripts/update-flake.sh
git add flake.nix
git commit -m "chore: update flake.nix for v0.18.0"
```
## postinstall.js
Post-installation script that runs after package installation.
## pack-version-check.mjs
Validates package version consistency before publishing.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
# Script to update flake.nix version and dependency hash
# Run this after updating package.json version
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FLAKE_FILE="$PROJECT_ROOT/flake.nix"
PACKAGE_JSON="$PROJECT_ROOT/package.json"
# Detect OS and set sed in-place flag
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS (BSD sed) requires empty string argument for -i
SED_INPLACE=(-i '')
else
# Linux (GNU sed)
SED_INPLACE=(-i)
fi
echo "==> Updating flake.nix..."
# Extract version from package.json
VERSION=$(node -p "require('$PACKAGE_JSON').version")
echo " Detected version: $VERSION"
# Update version in flake.nix
if ! grep -q "version = \"$VERSION\"" "$FLAKE_FILE"; then
echo " Updating version in flake.nix..."
sed "${SED_INPLACE[@]}" "s|version = \"[^\"]*\"|version = \"$VERSION\"|" "$FLAKE_FILE"
else
echo " Version already up-to-date in flake.nix"
fi
# Set placeholder hash to trigger error
echo " Setting placeholder hash..."
PLACEHOLDER="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$FLAKE_FILE"
# Try to build and capture the correct hash
echo " Building to get correct hash (this will fail)..."
BUILD_OUTPUT=$(nix build 2>&1 || true)
# Extract the correct hash from error output
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+' | head -1)
if [ -z "$CORRECT_HASH" ]; then
echo "❌ Error: Could not extract hash from build output"
echo "Build output:"
echo "$BUILD_OUTPUT"
exit 1
fi
echo " Detected hash: $CORRECT_HASH"
# Update flake.nix with correct hash
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
# Verify the build works
echo " Verifying build..."
if nix build 2>&1 | grep -q "warning: Git tree.*is dirty"; then
echo "⚠️ Warning: Git tree is dirty, but build succeeded"
else
echo "✅ Build successful"
fi
echo ""
echo "✅ flake.nix updated successfully!"
echo " Version: $VERSION"
echo " Hash: $CORRECT_HASH"
echo ""
echo "Next steps:"
echo " 1. Test: nix run . -- --version"
echo " 2. Commit: git add flake.nix"
echo " 3. Include in version bump commit"
+25 -3
View File
@@ -13,8 +13,10 @@ import { ChangeCommand } from '../commands/change.js';
import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { FeedbackCommand } from '../commands/feedback.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
import { registerSchemaCommand } from '../commands/schema.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
const program = new Command();
@@ -50,7 +52,10 @@ program
program.option('--no-color', 'Disable color output');
// Apply global flags and telemetry before any command runs
program.hook('preAction', async (thisCommand) => {
// Note: preAction receives (thisCommand, actionCommand) where:
// - thisCommand: the command where hook was added (root program)
// - actionCommand: the command actually being executed (subcommand)
program.hook('preAction', async (thisCommand, actionCommand) => {
const opts = thisCommand.opts();
if (opts.color === false) {
process.env.NO_COLOR = '1';
@@ -59,8 +64,8 @@ program.hook('preAction', async (thisCommand) => {
// Show first-run telemetry notice (if not seen)
await maybeShowTelemetryNotice();
// Track command execution
const commandPath = getCommandPath(thisCommand);
// Track command execution (use actionCommand to get the actual subcommand)
const commandPath = getCommandPath(actionCommand);
await trackCommand(commandPath, version);
});
@@ -239,6 +244,7 @@ program
registerSpecCommand(program);
registerConfigCommand(program);
registerSchemaCommand(program);
// Top-level validate command
program
@@ -290,6 +296,22 @@ program
}
});
// Feedback command
program
.command('feedback <message>')
.description('Submit feedback about OpenSpec')
.option('--body <text>', 'Detailed description for the feedback')
.action(async (message: string, options?: { body?: string }) => {
try {
const feedbackCommand = new FeedbackCommand();
await feedbackCommand.execute(message, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Completion command with subcommands
const completionCmd = program
.command('completion')
+171 -24
View File
@@ -28,8 +28,10 @@ import {
type SchemaInfo,
} from '../core/artifact-graph/index.js';
import { createChange, validateChangeName } from '../utils/change-utils.js';
import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate } from '../core/templates/skill-templates.js';
import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxVerifyCommandTemplate } from '../core/templates/skill-templates.js';
import { FileSystemUtils } from '../utils/file-system.js';
import { promptForConfig, serializeConfig, isExitPromptError } from '../core/config-prompts.js';
import { readProjectConfig } from '../core/project-config.js';
// -----------------------------------------------------------------------------
// Types for Apply Instructions
@@ -157,11 +159,14 @@ async function validateChangeExists(
/**
* Validates that a schema exists and returns available schemas if not.
*
* @param schemaName - The schema name to validate
* @param projectRoot - Optional project root for project-local schema resolution
*/
function validateSchemaExists(schemaName: string): string {
const schemaDir = getSchemaDir(schemaName);
function validateSchemaExists(schemaName: string, projectRoot?: string): string {
const schemaDir = getSchemaDir(schemaName, projectRoot);
if (!schemaDir) {
const availableSchemas = listSchemas();
const availableSchemas = listSchemas(projectRoot);
throw new Error(
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
);
@@ -188,7 +193,7 @@ async function statusCommand(options: StatusOptions): Promise<void> {
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
validateSchemaExists(options.schema, projectRoot);
}
// loadChangeContext will auto-detect schema from metadata if not provided
@@ -258,7 +263,7 @@ async function instructionsCommand(
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
validateSchemaExists(options.schema, projectRoot);
}
// loadChangeContext will auto-detect schema from metadata if not provided
@@ -282,7 +287,7 @@ async function instructionsCommand(
);
}
const instructions = generateInstructions(context, artifactId);
const instructions = generateInstructions(context, artifactId, projectRoot);
const isBlocked = instructions.dependencies.some((d) => !d.done);
spinner.stop();
@@ -596,7 +601,7 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
validateSchemaExists(options.schema, projectRoot);
}
// generateApplyInstructions uses loadChangeContext which auto-detects schema
@@ -680,27 +685,40 @@ interface TemplatesOptions {
interface TemplateInfo {
artifactId: string;
templatePath: string;
source: 'user' | 'package';
source: 'project' | 'user' | 'package';
}
async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();
try {
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const schema = resolveSchema(schemaName);
const projectRoot = process.cwd();
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
const schema = resolveSchema(schemaName, projectRoot);
const graph = ArtifactGraph.fromSchema(schema);
const schemaDir = getSchemaDir(schemaName)!;
const schemaDir = getSchemaDir(schemaName, projectRoot)!;
// Determine if this is a user override or package built-in
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
// Determine the source (project, user, or package)
const {
getUserSchemasDir,
getProjectSchemasDir,
} = await import('../core/artifact-graph/resolver.js');
const projectSchemasDir = getProjectSchemasDir(projectRoot);
const userSchemasDir = getUserSchemasDir();
const isUserOverride = schemaDir.startsWith(userSchemasDir);
let source: 'project' | 'user' | 'package';
if (schemaDir.startsWith(projectSchemasDir)) {
source = 'project';
} else if (schemaDir.startsWith(userSchemasDir)) {
source = 'user';
} else {
source = 'package';
}
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: path.join(schemaDir, 'templates', artifact.template),
source: isUserOverride ? 'user' : 'package',
source,
}));
spinner.stop();
@@ -715,7 +733,7 @@ async function templatesCommand(options: TemplatesOptions): Promise<void> {
}
console.log(`Schema: ${schemaName}`);
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
console.log(`Source: ${source}`);
console.log();
for (const t of templates) {
@@ -747,17 +765,18 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
throw new Error(validation.error);
}
const projectRoot = process.cwd();
// Validate schema if provided
if (options.schema) {
validateSchemaExists(options.schema);
validateSchemaExists(options.schema, projectRoot);
}
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
try {
const projectRoot = process.cwd();
await createChange(projectRoot, name, { schema: options.schema });
const result = await createChange(projectRoot, name, { schema: options.schema });
// If description provided, create README.md with description
if (options.description) {
@@ -767,8 +786,7 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
}
const schemaUsed = options.schema ?? DEFAULT_SCHEMA;
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${schemaUsed})`);
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
@@ -800,6 +818,7 @@ async function artifactExperimentalSetupCommand(): Promise<void> {
const ffChangeSkill = getFfChangeSkillTemplate();
const syncSpecsSkill = getSyncSpecsSkillTemplate();
const archiveChangeSkill = getArchiveChangeSkillTemplate();
const verifyChangeSkill = getVerifyChangeSkillTemplate();
// Get command templates
const exploreCommand = getOpsxExploreCommandTemplate();
@@ -809,6 +828,7 @@ async function artifactExperimentalSetupCommand(): Promise<void> {
const ffCommand = getOpsxFfCommandTemplate();
const syncCommand = getOpsxSyncCommandTemplate();
const archiveCommand = getOpsxArchiveCommandTemplate();
const verifyCommand = getOpsxVerifyCommandTemplate();
// Create skill directories and SKILL.md files
const skills = [
@@ -819,6 +839,7 @@ async function artifactExperimentalSetupCommand(): Promise<void> {
{ template: ffChangeSkill, dirName: 'openspec-ff-change' },
{ template: syncSpecsSkill, dirName: 'openspec-sync-specs' },
{ template: archiveChangeSkill, dirName: 'openspec-archive-change' },
{ template: verifyChangeSkill, dirName: 'openspec-verify-change' },
];
const createdSkillFiles: string[] = [];
@@ -850,6 +871,7 @@ ${template.instructions}
{ template: ffCommand, fileName: 'ff.md' },
{ template: syncCommand, fileName: 'sync.md' },
{ template: archiveCommand, fileName: 'archive.md' },
{ template: verifyCommand, fileName: 'verify.md' },
];
const createdCommandFiles: string[] = [];
@@ -889,6 +911,124 @@ ${template.content}
console.log(chalk.green(' ✓ ' + file));
}
console.log();
// Config creation section
console.log('━'.repeat(70));
console.log();
console.log(chalk.bold('📋 Project Configuration (Optional)'));
console.log();
console.log('Configure project defaults for OpenSpec workflows.');
console.log();
// Check if config already exists
const configPath = path.join(projectRoot, 'openspec', 'config.yaml');
const configYmlPath = path.join(projectRoot, 'openspec', 'config.yml');
const configExists = fs.existsSync(configPath) || fs.existsSync(configYmlPath);
if (configExists) {
// Config already exists, skip creation
console.log(chalk.blue('ℹ️ openspec/config.yaml already exists. Skipping config creation.'));
console.log();
console.log(' To update config, edit openspec/config.yaml manually or:');
console.log(' 1. Delete openspec/config.yaml');
console.log(' 2. Run openspec artifact-experimental-setup again');
console.log();
} else if (!process.stdin.isTTY) {
// Non-interactive mode (CI, automation, piped input)
console.log(chalk.blue('ℹ️ Skipping config prompts (non-interactive mode)'));
console.log();
console.log(' To create config manually, add openspec/config.yaml with:');
console.log(chalk.dim(' schema: spec-driven'));
console.log();
} else {
// Prompt for config creation
try {
const configResult = await promptForConfig(projectRoot);
if (configResult.createConfig && configResult.schema) {
// Build config object
const config = {
schema: configResult.schema,
context: configResult.context,
rules: configResult.rules,
};
// Serialize to YAML
const yamlContent = serializeConfig(config);
// Write config file
try {
await FileSystemUtils.writeFile(configPath, yamlContent);
console.log();
console.log(chalk.green('✓ Created openspec/config.yaml'));
console.log();
console.log('━'.repeat(70));
console.log();
console.log(chalk.bold('📖 Config created at: openspec/config.yaml'));
// Display summary
const contextLines = config.context ? config.context.split('\n').length : 0;
const rulesCount = config.rules ? Object.keys(config.rules).length : 0;
console.log(` • Default schema: ${chalk.cyan(config.schema)}`);
if (contextLines > 0) {
console.log(` • Project context: ${chalk.cyan(`Added (${contextLines} lines)`)}`);
}
if (rulesCount > 0) {
console.log(` • Rules: ${chalk.cyan(`${rulesCount} artifact${rulesCount > 1 ? 's' : ''} configured`)}`);
}
console.log();
// Usage examples
console.log(chalk.bold('Usage:'));
console.log(' • New changes automatically use this schema');
console.log(' • Context injected into all artifact instructions');
console.log(' • Rules applied to matching artifacts');
console.log();
// Git commit suggestion
console.log(chalk.bold('To share with team:'));
console.log(chalk.dim(' git add openspec/config.yaml .claude/'));
console.log(chalk.dim(' git commit -m "Setup OpenSpec experimental workflow with project config"'));
console.log();
} catch (writeError) {
// Handle file write errors
console.error();
console.error(chalk.red('✗ Failed to write openspec/config.yaml'));
console.error(chalk.dim(` ${(writeError as Error).message}`));
console.error();
console.error('Fallback: Create config manually:');
console.error(chalk.dim(' 1. Create openspec/config.yaml'));
console.error(chalk.dim(' 2. Copy the following content:'));
console.error();
console.error(chalk.dim(yamlContent));
console.error();
}
} else {
// User chose not to create config
console.log();
console.log(chalk.blue('ℹ️ Skipped config creation.'));
console.log(' You can create openspec/config.yaml manually later.');
console.log();
}
} catch (promptError) {
if (isExitPromptError(promptError)) {
// User cancelled (Ctrl+C)
console.log();
console.log(chalk.blue('ℹ️ Config creation cancelled'));
console.log(' Skills and commands already created');
console.log(' Run setup again to create config later');
console.log();
} else {
// Unexpected error
throw promptError;
}
}
}
console.log('━'.repeat(70));
console.log();
console.log(chalk.bold('📖 Usage:'));
console.log();
console.log(' ' + chalk.cyan('Skills') + ' work automatically in compatible editors:');
@@ -908,6 +1048,7 @@ ${template.content}
console.log(' • /opsx:apply - Implement tasks');
console.log(' • /opsx:ff - Fast-forward: create all artifacts at once');
console.log(' • /opsx:sync - Sync delta specs to main specs');
console.log(' • /opsx:verify - Verify implementation matches artifacts');
console.log(' • /opsx:archive - Archive a completed change');
console.log();
console.log(chalk.yellow('💡 This is an experimental feature.'));
@@ -928,7 +1069,8 @@ interface SchemasOptions {
}
async function schemasCommand(options: SchemasOptions): Promise<void> {
const schemas = listSchemasWithInfo();
const projectRoot = process.cwd();
const schemas = listSchemasWithInfo(projectRoot);
if (options.json) {
console.log(JSON.stringify(schemas, null, 2));
@@ -939,7 +1081,12 @@ async function schemasCommand(options: SchemasOptions): Promise<void> {
console.log();
for (const schema of schemas) {
const sourceLabel = schema.source === 'user' ? chalk.dim(' (user override)') : '';
let sourceLabel = '';
if (schema.source === 'project') {
sourceLabel = chalk.cyan(' (project)');
} else if (schema.source === 'user') {
sourceLabel = chalk.dim(' (user override)');
}
console.log(` ${chalk.bold(schema.name)}${sourceLabel}`);
console.log(` ${schema.description}`);
console.log(` Artifacts: ${schema.artifacts.join(' → ')}`);
+208
View File
@@ -0,0 +1,208 @@
import { execSync, execFileSync } from 'child_process';
import { createRequire } from 'module';
import os from 'os';
const require = createRequire(import.meta.url);
/**
* Check if gh CLI is installed and available in PATH
* Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
*/
function isGhInstalled(): boolean {
try {
const command = process.platform === 'win32' ? 'where gh' : 'which gh';
execSync(command, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Check if gh CLI is authenticated
*/
function isGhAuthenticated(): boolean {
try {
execSync('gh auth status', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Get OpenSpec version from package.json
*/
function getVersion(): string {
try {
const { version } = require('../../package.json');
return version;
} catch {
return 'unknown';
}
}
/**
* Get platform name
*/
function getPlatform(): string {
return os.platform();
}
/**
* Get current timestamp in ISO format
*/
function getTimestamp(): string {
return new Date().toISOString();
}
/**
* Generate metadata footer for feedback
*/
function generateMetadata(): string {
const version = getVersion();
const platform = getPlatform();
const timestamp = getTimestamp();
return `---
Submitted via OpenSpec CLI
- Version: ${version}
- Platform: ${platform}
- Timestamp: ${timestamp}`;
}
/**
* Format the feedback title
*/
function formatTitle(message: string): string {
return `Feedback: ${message}`;
}
/**
* Format the full feedback body
*/
function formatBody(bodyText?: string): string {
const parts: string[] = [];
if (bodyText) {
parts.push(bodyText);
parts.push(''); // Empty line before metadata
}
parts.push(generateMetadata());
return parts.join('\n');
}
/**
* Generate a pre-filled GitHub issue URL for manual submission
*/
function generateManualSubmissionUrl(title: string, body: string): string {
const repo = 'Fission-AI/OpenSpec';
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
const encodedLabels = encodeURIComponent('feedback');
return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;
}
/**
* Display formatted feedback content for manual submission
*/
function displayFormattedFeedback(title: string, body: string): void {
console.log('\n--- FORMATTED FEEDBACK ---');
console.log(`Title: ${title}`);
console.log(`Labels: feedback`);
console.log('\nBody:');
console.log(body);
console.log('--- END FEEDBACK ---\n');
}
/**
* Submit feedback via gh CLI
* Uses execFileSync to prevent shell injection vulnerabilities
*/
function submitViaGhCli(title: string, body: string): void {
try {
const result = execFileSync(
'gh',
[
'issue',
'create',
'--repo',
'Fission-AI/OpenSpec',
'--title',
title,
'--body',
body,
'--label',
'feedback',
],
{ encoding: 'utf-8', stdio: 'pipe' }
);
const issueUrl = result.trim();
console.log(`\n✓ Feedback submitted successfully!`);
console.log(`Issue URL: ${issueUrl}\n`);
} catch (error: any) {
// Display the error output from gh CLI
if (error.stderr) {
console.error(error.stderr.toString());
} else if (error.message) {
console.error(error.message);
}
// Exit with the same code as gh CLI
process.exit(error.status ?? 1);
}
}
/**
* Handle fallback when gh CLI is not available or not authenticated
*/
function handleFallback(title: string, body: string, reason: 'missing' | 'unauthenticated'): void {
if (reason === 'missing') {
console.log('⚠️ GitHub CLI not found. Manual submission required.');
} else {
console.log('⚠️ GitHub authentication required. Manual submission required.');
}
displayFormattedFeedback(title, body);
const manualUrl = generateManualSubmissionUrl(title, body);
console.log('Please submit your feedback manually:');
console.log(manualUrl);
if (reason === 'unauthenticated') {
console.log('\nTo auto-submit in the future: gh auth login');
}
// Exit with success code (fallback is successful)
process.exit(0);
}
/**
* Feedback command implementation
*/
export class FeedbackCommand {
async execute(message: string, options?: { body?: string }): Promise<void> {
// Format title and body once for all code paths
const title = formatTitle(message);
const body = formatBody(options?.body);
// Check if gh CLI is installed
if (!isGhInstalled()) {
handleFallback(title, body, 'missing');
return;
}
// Check if gh CLI is authenticated
if (!isGhAuthenticated()) {
handleFallback(title, body, 'unauthenticated');
return;
}
// Submit via gh CLI
submitViaGhCli(title, body);
}
}
File diff suppressed because it is too large Load Diff
+78 -7
View File
@@ -4,8 +4,12 @@ import { getSchemaDir, resolveSchema } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
import { readProjectConfig, validateConfigRules } from '../project-config.js';
import type { Artifact, CompletedSet } from './types.js';
// Session-level cache for validation warnings (avoid repeating same warnings)
const shownWarnings = new Set<string>();
/**
* Error thrown when loading a template fails.
*/
@@ -33,6 +37,8 @@ export interface ChangeContext {
changeName: string;
/** Path to the change directory */
changeDir: string;
/** Project root directory */
projectRoot: string;
}
/**
@@ -110,11 +116,16 @@ export interface ChangeStatus {
*
* @param schemaName - Schema name (e.g., "spec-driven")
* @param templatePath - Relative path within the templates directory (e.g., "proposal.md")
* @param projectRoot - Optional project root for project-local schema resolution
* @returns The template content
* @throws TemplateLoadError if the template cannot be loaded
*/
export function loadTemplate(schemaName: string, templatePath: string): string {
const schemaDir = getSchemaDir(schemaName);
export function loadTemplate(
schemaName: string,
templatePath: string,
projectRoot?: string
): string {
const schemaDir = getSchemaDir(schemaName, projectRoot);
if (!schemaDir) {
throw new TemplateLoadError(
`Schema '${schemaName}' not found`,
@@ -165,7 +176,7 @@ export function loadChangeContext(
// Resolve schema: explicit > metadata > default
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
const schema = resolveSchema(resolvedSchemaName);
const schema = resolveSchema(resolvedSchemaName, projectRoot);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, changeDir);
@@ -175,30 +186,90 @@ export function loadChangeContext(
schemaName: resolvedSchemaName,
changeName,
changeDir,
projectRoot,
};
}
/**
* Generates enriched instructions for creating an artifact.
*
* Instruction injection order:
* 1. <context> - Project context from config (if present)
* 2. <rules> - Artifact-specific rules from config (if present)
* 3. <template> - Schema's template content
*
* @param context - Change context
* @param artifactId - Artifact ID to generate instructions for
* @param projectRoot - Project root directory (for reading config)
* @returns Enriched artifact instructions
* @throws Error if artifact not found
*/
export function generateInstructions(
context: ChangeContext,
artifactId: string
artifactId: string,
projectRoot?: string
): ArtifactInstructions {
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
}
const template = loadTemplate(context.schemaName, artifact.template);
const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot);
const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
// Build enriched template with project config injections
let enrichedTemplate = '';
let projectConfig = null;
// Use projectRoot from context if not explicitly provided
const effectiveProjectRoot = projectRoot ?? context.projectRoot;
// Try to read project config
if (effectiveProjectRoot) {
try {
projectConfig = readProjectConfig(effectiveProjectRoot);
} catch {
// If config read fails, continue without config
}
}
// Validate rules artifact IDs if config has rules (only once per session)
if (projectConfig?.rules) {
const validArtifactIds = new Set(context.graph.getAllArtifacts().map((a) => a.id));
const warnings = validateConfigRules(
projectConfig.rules,
validArtifactIds,
context.schemaName
);
// Show each unique warning only once per session
for (const warning of warnings) {
if (!shownWarnings.has(warning)) {
console.warn(warning);
shownWarnings.add(warning);
}
}
}
// 1. Add context (all artifacts)
if (projectConfig?.context) {
enrichedTemplate += `<context>\n${projectConfig.context}\n</context>\n\n`;
}
// 2. Add rules (only for matching artifact)
const rulesForArtifact = projectConfig?.rules?.[artifactId];
if (rulesForArtifact && rulesForArtifact.length > 0) {
enrichedTemplate += `<rules>\n`;
for (const rule of rulesForArtifact) {
enrichedTemplate += `- ${rule}\n`;
}
enrichedTemplate += `</rules>\n\n`;
}
// 3. Add original template (without wrapper - CLI handles XML structure)
enrichedTemplate += templateContent;
return {
changeName: context.changeName,
artifactId: artifact.id,
@@ -207,7 +278,7 @@ export function generateInstructions(
outputPath: artifact.generates,
description: artifact.description,
instruction: artifact.instruction,
template,
template: enrichedTemplate,
dependencies,
unlocks,
};
@@ -255,7 +326,7 @@ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[
*/
export function formatChangeStatus(context: ChangeContext): ChangeStatus {
// Load schema to get apply phase configuration
const schema = resolveSchema(context.schemaName);
const schema = resolveSchema(context.schemaName, context.projectRoot);
const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id);
const artifacts = context.graph.getAllArtifacts();
+95 -19
View File
@@ -36,25 +36,51 @@ export function getUserSchemasDir(): string {
return path.join(getGlobalDataDir(), 'schemas');
}
/**
* Gets the project-local schemas directory path.
* @param projectRoot - The project root directory
* @returns The path to the project's schemas directory
*/
export function getProjectSchemasDir(projectRoot: string): string {
return path.join(projectRoot, 'openspec', 'schemas');
}
/**
* Resolves a schema name to its directory path.
*
* Resolution order:
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
* Resolution order (when projectRoot is provided):
* 1. Project-local: <projectRoot>/openspec/schemas/<name>/schema.yaml
* 2. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 3. Package built-in: <package>/schemas/<name>/schema.yaml
*
* When projectRoot is not provided, only user override and package built-in are checked
* (backward compatible behavior).
*
* @param name - Schema name (e.g., "spec-driven")
* @param projectRoot - Optional project root directory for project-local schema resolution
* @returns The path to the schema directory, or null if not found
*/
export function getSchemaDir(name: string): string | null {
// 1. Check user override directory
export function getSchemaDir(
name: string,
projectRoot?: string
): string | null {
// 1. Check project-local directory (if projectRoot provided)
if (projectRoot) {
const projectDir = path.join(getProjectSchemasDir(projectRoot), name);
const projectSchemaPath = path.join(projectDir, 'schema.yaml');
if (fs.existsSync(projectSchemaPath)) {
return projectDir;
}
}
// 2. Check user override directory
const userDir = path.join(getUserSchemasDir(), name);
const userSchemaPath = path.join(userDir, 'schema.yaml');
if (fs.existsSync(userSchemaPath)) {
return userDir;
}
// 2. Check package built-in directory
// 3. Check package built-in directory
const packageDir = path.join(getPackageSchemasDir(), name);
const packageSchemaPath = path.join(packageDir, 'schema.yaml');
if (fs.existsSync(packageSchemaPath)) {
@@ -67,21 +93,26 @@ export function getSchemaDir(name: string): string | null {
/**
* Resolves a schema name to a SchemaYaml object.
*
* Resolution order:
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
* Resolution order (when projectRoot is provided):
* 1. Project-local: <projectRoot>/openspec/schemas/<name>/schema.yaml
* 2. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 3. Package built-in: <package>/schemas/<name>/schema.yaml
*
* When projectRoot is not provided, only user override and package built-in are checked
* (backward compatible behavior).
*
* @param name - Schema name (e.g., "spec-driven")
* @param projectRoot - Optional project root directory for project-local schema resolution
* @returns The resolved schema object
* @throws Error if schema is not found in any location
*/
export function resolveSchema(name: string): SchemaYaml {
export function resolveSchema(name: string, projectRoot?: string): SchemaYaml {
// Normalize name (remove .yaml extension if provided)
const normalizedName = name.replace(/\.ya?ml$/, '');
const schemaDir = getSchemaDir(normalizedName);
const schemaDir = getSchemaDir(normalizedName, projectRoot);
if (!schemaDir) {
const availableSchemas = listSchemas();
const availableSchemas = listSchemas(projectRoot);
throw new Error(
`Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}`
);
@@ -123,9 +154,11 @@ export function resolveSchema(name: string): SchemaYaml {
/**
* Lists all available schema names.
* Combines user override and package built-in schemas.
* Combines project-local, user override, and package built-in schemas.
*
* @param projectRoot - Optional project root directory for project-local schema resolution
*/
export function listSchemas(): string[] {
export function listSchemas(projectRoot?: string): string[] {
const schemas = new Set<string>();
// Add package built-in schemas
@@ -154,6 +187,21 @@ export function listSchemas(): string[] {
}
}
// Add project-local schemas (if projectRoot provided)
if (projectRoot) {
const projectDir = getProjectSchemasDir(projectRoot);
if (fs.existsSync(projectDir)) {
for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
schemas.add(entry.name);
}
}
}
}
}
return Array.from(schemas).sort();
}
@@ -164,22 +212,50 @@ export interface SchemaInfo {
name: string;
description: string;
artifacts: string[];
source: 'package' | 'user';
source: 'project' | 'user' | 'package';
}
/**
* Lists all available schemas with their descriptions and artifact lists.
* Useful for agent skills to present schema selection to users.
*
* @param projectRoot - Optional project root directory for project-local schema resolution
*/
export function listSchemasWithInfo(): SchemaInfo[] {
export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] {
const schemas: SchemaInfo[] = [];
const seenNames = new Set<string>();
// Add user override schemas first (they take precedence)
// Add project-local schemas first (highest priority, if projectRoot provided)
if (projectRoot) {
const projectDir = getProjectSchemasDir(projectRoot);
if (fs.existsSync(projectDir)) {
for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
try {
const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8'));
schemas.push({
name: entry.name,
description: schema.description || '',
artifacts: schema.artifacts.map((a) => a.id),
source: 'project',
});
seenNames.add(entry.name);
} catch {
// Skip invalid schemas
}
}
}
}
}
}
// Add user override schemas (if not overridden by project)
const userDir = getUserSchemasDir();
if (fs.existsSync(userDir)) {
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.isDirectory() && !seenNames.has(entry.name)) {
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
try {
@@ -199,7 +275,7 @@ export function listSchemasWithInfo(): SchemaInfo[] {
}
}
// Add package built-in schemas (if not overridden)
// Add package built-in schemas (if not overridden by project or user)
const packageDir = getPackageSchemasDir();
if (fs.existsSync(packageDir)) {
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
+88
View File
@@ -155,6 +155,18 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
},
],
},
{
name: 'feedback',
description: 'Submit feedback about OpenSpec',
acceptsPositional: true,
flags: [
{
name: 'body',
description: 'Detailed description for the feedback',
takesValue: true,
},
],
},
{
name: 'change',
description: 'Manage OpenSpec change proposals (deprecated)',
@@ -367,4 +379,80 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
},
],
},
{
name: 'schema',
description: 'Manage workflow schemas',
flags: [],
subcommands: [
{
name: 'which',
description: 'Show where a schema resolves from',
acceptsPositional: true,
positionalType: 'schema-name',
flags: [
COMMON_FLAGS.json,
{
name: 'all',
description: 'List all schemas with their resolution sources',
},
],
},
{
name: 'validate',
description: 'Validate a schema structure and templates',
acceptsPositional: true,
positionalType: 'schema-name',
flags: [
COMMON_FLAGS.json,
{
name: 'verbose',
description: 'Show detailed validation steps',
},
],
},
{
name: 'fork',
description: 'Copy an existing schema to project for customization',
acceptsPositional: true,
positionalType: 'schema-name',
flags: [
COMMON_FLAGS.json,
{
name: 'force',
description: 'Overwrite existing destination',
},
],
},
{
name: 'init',
description: 'Create a new project-local schema',
acceptsPositional: true,
flags: [
COMMON_FLAGS.json,
{
name: 'description',
description: 'Schema description',
takesValue: true,
},
{
name: 'artifacts',
description: 'Comma-separated artifact IDs',
takesValue: true,
},
{
name: 'default',
description: 'Set as project default schema',
},
{
name: 'no-default',
description: 'Do not prompt to set as default',
},
{
name: 'force',
description: 'Overwrite existing schema',
},
],
},
],
},
];
@@ -8,6 +8,11 @@ import { POWERSHELL_DYNAMIC_HELPERS } from '../templates/powershell-templates.js
export class PowerShellGenerator implements CompletionGenerator {
readonly shell = 'powershell' as const;
private stripTrailingCommaFromLastLine(lines: string[]): void {
if (lines.length === 0) return;
lines[lines.length - 1] = lines[lines.length - 1].replace(/,\s*$/, '');
}
/**
* Generate a PowerShell completion script
*
@@ -20,6 +25,7 @@ export class PowerShellGenerator implements CompletionGenerator {
for (const cmd of commands) {
commandLines.push(` @{Name="${cmd.name}"; Description="${this.escapeDescription(cmd.description)}"},`);
}
this.stripTrailingCommaFromLastLine(commandLines);
const topLevelCommands = commandLines.join('\n');
// Build command cases using push() for loop clarity
@@ -88,6 +94,7 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
}
}
this.stripTrailingCommaFromLastLine(lines);
lines.push(`${indent} )`);
lines.push(`${indent} $flags | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterName", $_.Description)`);
@@ -103,6 +110,7 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
for (const subcmd of cmd.subcommands) {
lines.push(`${indent} @{Name="${subcmd.name}"; Description="${this.escapeDescription(subcmd.description)}"},`);
}
this.stripTrailingCommaFromLastLine(lines);
lines.push(`${indent} )`);
lines.push(`${indent} $subcommands | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterValue", $_.Description)`);
@@ -148,6 +156,7 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
}
}
this.stripTrailingCommaFromLastLine(lines);
lines.push(`${indent} )`);
lines.push(`${indent} $flags | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterName", $_.Description)`);
+2 -1
View File
@@ -66,9 +66,10 @@ export interface CommandDefinition {
* - 'change-or-spec-id': Complete with both changes and specs
* - 'path': Complete with file paths
* - 'shell': Complete with supported shell names
* - 'schema-name': Complete with available schema names
* - undefined: No specific completion
*/
positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell';
positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell' | 'schema-name';
}
/**
+199
View File
@@ -0,0 +1,199 @@
import { stringify as stringifyYaml } from 'yaml';
import { listSchemasWithInfo, resolveSchema } from './artifact-graph/resolver.js';
import type { ProjectConfig } from './project-config.js';
/**
* Check if an error is an ExitPromptError (user cancelled with Ctrl+C).
* Used instead of instanceof check since @inquirer modules use dynamic imports.
*/
export function isExitPromptError(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'name' in error &&
(error as { name: string }).name === 'ExitPromptError'
);
}
/**
* Result of interactive config creation prompts.
*/
export interface ConfigPromptResult {
/** Whether to create config file */
createConfig: boolean;
/** Selected schema name */
schema?: string;
/** Project context (optional) */
context?: string;
/** Per-artifact rules (optional) */
rules?: Record<string, string[]>;
}
/**
* Prompt user to create project config interactively.
* Used by experimental setup command.
*
* @param projectRoot - Optional project root for project-local schema resolution
* @returns Config prompt result
* @throws ExitPromptError if user cancels (Ctrl+C)
*/
export async function promptForConfig(
projectRoot?: string
): Promise<ConfigPromptResult> {
// Dynamic imports to prevent pre-commit hook hangs (see #367)
const { confirm, select, editor, checkbox } = await import('@inquirer/prompts');
// Ask if user wants to create config
const shouldCreate = await confirm({
message: 'Create openspec/config.yaml?',
default: true,
});
if (!shouldCreate) {
return { createConfig: false };
}
// Get available schemas
const schemas = listSchemasWithInfo(projectRoot);
if (schemas.length === 0) {
throw new Error('No schemas found. Cannot create config.');
}
// Prompt for schema selection
const selectedSchema = await select({
message: 'Default schema for new changes?',
choices: schemas.map((s) => ({
name: `${s.name} (${s.artifacts.join(' → ')})`,
value: s.name,
description: s.description || undefined,
})),
});
// Prompt for project context
console.log('\nAdd project context? (optional)');
console.log('Context is shown to AI when creating artifacts.');
console.log('Examples: tech stack, conventions, style guides, domain knowledge\n');
const contextInput = await editor({
message: 'Press Enter to skip, or edit context:',
default: '',
waitForUseInput: false,
});
const context = contextInput.trim() || undefined;
// Prompt for per-artifact rules
const addRules = await confirm({
message: 'Add per-artifact rules? (optional)',
default: false,
});
let rules: Record<string, string[]> | undefined;
if (addRules) {
// Load the selected schema to get artifact list
const schema = resolveSchema(selectedSchema, projectRoot);
const artifactIds = schema.artifacts.map((a) => a.id);
// Let user select which artifacts to add rules for
const selectedArtifacts = await checkbox({
message: 'Which artifacts should have custom rules?',
choices: artifactIds.map((id) => ({
name: id,
value: id,
})),
});
if (selectedArtifacts.length > 0) {
rules = {};
// For each selected artifact, collect rules line by line
for (const artifactId of selectedArtifacts) {
const artifactRules = await promptForArtifactRules(artifactId);
if (artifactRules.length > 0) {
rules[artifactId] = artifactRules;
}
}
// If no rules were actually added, set to undefined
if (Object.keys(rules).length === 0) {
rules = undefined;
}
}
}
return {
createConfig: true,
schema: selectedSchema,
context,
rules,
};
}
/**
* Prompt for rules for a specific artifact.
* Collects rules one per line until user enters empty line.
*
* @param artifactId - The artifact ID to collect rules for
* @returns Array of rules
*/
async function promptForArtifactRules(artifactId: string): Promise<string[]> {
// Dynamic import to prevent pre-commit hook hangs (see #367)
const { input } = await import('@inquirer/prompts');
const rules: string[] = [];
console.log(`\nRules for ${artifactId} artifact:`);
console.log('Enter rules one per line, press Enter on empty line to finish:\n');
while (true) {
const rule = await input({
message: '│',
validate: () => {
// Empty string is valid (signals end of input)
return true;
},
});
const trimmed = rule.trim();
// Empty line signals end of input
if (!trimmed) {
break;
}
rules.push(trimmed);
}
return rules;
}
/**
* Serialize config to YAML string with proper multi-line formatting.
*
* @param config - Partial config object (schema required, context/rules optional)
* @returns YAML string ready to write to file
*/
export function serializeConfig(config: Partial<ProjectConfig>): string {
// Build clean config object (only include defined fields)
const cleanConfig: Record<string, unknown> = {
schema: config.schema,
};
if (config.context) {
cleanConfig.context = config.context;
}
if (config.rules && Object.keys(config.rules).length > 0) {
cleanConfig.rules = config.rules;
}
// Serialize to YAML with proper formatting
return stringifyYaml(cleanConfig, {
indent: 2,
lineWidth: 0, // Don't wrap long lines
defaultStringType: 'PLAIN',
defaultKeyType: 'PLAIN',
});
}
+1
View File
@@ -24,6 +24,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ 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: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
+51
View File
@@ -0,0 +1,51 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.continue/prompts/openspec-proposal.prompt',
apply: '.continue/prompts/openspec-apply.prompt',
archive: '.continue/prompts/openspec-archive.prompt'
};
/*
* Continue .prompt format requires YAML frontmatter:
* ---
* name: commandName
* description: description
* invokable: true
* ---
* Body...
*
* The 'invokable: true' field is required to make the prompt available as a slash command.
* We use 'openspec-proposal' as the name so the command becomes /openspec-proposal.
*/
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: openspec-proposal
description: Scaffold a new OpenSpec change and validate strictly.
invokable: true
---`,
apply: `---
name: openspec-apply
description: Implement an approved OpenSpec change and keep tasks in sync.
invokable: true
---`,
archive: `---
name: openspec-archive
description: Archive a deployed OpenSpec change and update specs.
invokable: true
---`
};
export class ContinueSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'continue';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
+3
View File
@@ -19,6 +19,7 @@ import { QwenSlashCommandConfigurator } from './qwen.js';
import { RooCodeSlashCommandConfigurator } from './roocode.js';
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
import { IflowSlashCommandConfigurator } from './iflow.js';
import { ContinueSlashCommandConfigurator } from './continue.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -44,6 +45,7 @@ export class SlashCommandRegistry {
const roocode = new RooCodeSlashCommandConfigurator();
const antigravity = new AntigravitySlashCommandConfigurator();
const iflow = new IflowSlashCommandConfigurator();
const continueTool = new ContinueSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(codeBuddy.toolId, codeBuddy);
@@ -65,6 +67,7 @@ export class SlashCommandRegistry {
this.configurators.set(roocode.toolId, roocode);
this.configurators.set(antigravity.toolId, antigravity);
this.configurators.set(iflow.toolId, iflow);
this.configurators.set(continueTool.toolId, continueTool);
}
static register(configurator: SlashCommandConfigurator): void {
+264
View File
@@ -0,0 +1,264 @@
import { existsSync, readFileSync, statSync } from 'fs';
import path from 'path';
import { parse as parseYaml } from 'yaml';
import { z } from 'zod';
/**
* Zod schema for project configuration.
*
* Purpose:
* 1. Documentation - clearly defines the config file structure
* 2. Type safety - TypeScript infers ProjectConfig type from schema
* 3. Runtime validation - uses safeParse() for resilient field-by-field validation
*
* Why Zod over manual validation:
* - Helps understand OpenSpec's data interfaces at a glance
* - Single source of truth for type and validation
* - Consistent with other OpenSpec schemas
*/
export const ProjectConfigSchema = z.object({
// Required: which schema to use (e.g., "spec-driven", "tdd", or project-local schema name)
schema: z
.string()
.min(1)
.describe('The workflow schema to use (e.g., "spec-driven", "tdd")'),
// Optional: project context (injected into all artifact instructions)
// Max size: 50KB (enforced during parsing)
context: z
.string()
.optional()
.describe('Project context injected into all artifact instructions'),
// Optional: per-artifact rules (additive to schema's built-in guidance)
rules: z
.record(
z.string(), // artifact ID
z.array(z.string()) // list of rules
)
.optional()
.describe('Per-artifact rules, keyed by artifact ID'),
});
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit
/**
* Read and parse openspec/config.yaml from project root.
* Uses resilient parsing - validates each field independently using Zod safeParse.
* Returns null if file doesn't exist.
* Returns partial config if some fields are invalid (with warnings).
*
* Performance note (Jan 2025):
* Benchmarks showed direct file reads are fast enough without caching:
* - Typical config (1KB): ~0.5ms per read
* - Large config (50KB): ~1.6ms per read
* - Missing config: ~0.01ms per read
* Config is read 1-2 times per command (schema resolution + instruction loading),
* adding ~1-3ms total overhead. Caching would add complexity (mtime checks,
* invalidation logic) for negligible benefit. Direct reads also ensure config
* changes are reflected immediately without stale cache issues.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @returns Parsed config or null if file doesn't exist
*/
export function readProjectConfig(projectRoot: string): ProjectConfig | null {
// Try both .yaml and .yml, prefer .yaml
let configPath = path.join(projectRoot, 'openspec', 'config.yaml');
if (!existsSync(configPath)) {
configPath = path.join(projectRoot, 'openspec', 'config.yml');
if (!existsSync(configPath)) {
return null; // No config is OK
}
}
try {
const content = readFileSync(configPath, 'utf-8');
const raw = parseYaml(content);
if (!raw || typeof raw !== 'object') {
console.warn(`openspec/config.yaml is not a valid YAML object`);
return null;
}
const config: Partial<ProjectConfig> = {};
// Parse schema field using Zod
const schemaField = z.string().min(1);
const schemaResult = schemaField.safeParse(raw.schema);
if (schemaResult.success) {
config.schema = schemaResult.data;
} else if (raw.schema !== undefined) {
console.warn(`Invalid 'schema' field in config (must be non-empty string)`);
}
// Parse context field with size limit
if (raw.context !== undefined) {
const contextField = z.string();
const contextResult = contextField.safeParse(raw.context);
if (contextResult.success) {
const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');
if (contextSize > MAX_CONTEXT_SIZE) {
console.warn(
`Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`
);
console.warn(`Ignoring context field`);
} else {
config.context = contextResult.data;
}
} else {
console.warn(`Invalid 'context' field in config (must be string)`);
}
}
// Parse rules field using Zod
if (raw.rules !== undefined) {
const rulesField = z.record(z.string(), z.array(z.string()));
// First check if it's an object structure (guard against null since typeof null === 'object')
if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {
const parsedRules: Record<string, string[]> = {};
let hasValidRules = false;
for (const [artifactId, rules] of Object.entries(raw.rules)) {
const rulesArrayResult = z.array(z.string()).safeParse(rules);
if (rulesArrayResult.success) {
// Filter out empty strings
const validRules = rulesArrayResult.data.filter((r) => r.length > 0);
if (validRules.length > 0) {
parsedRules[artifactId] = validRules;
hasValidRules = true;
}
if (validRules.length < rulesArrayResult.data.length) {
console.warn(
`Some rules for '${artifactId}' are empty strings, ignoring them`
);
}
} else {
console.warn(
`Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`
);
}
}
if (hasValidRules) {
config.rules = parsedRules;
}
} else {
console.warn(`Invalid 'rules' field in config (must be object)`);
}
}
// Return partial config even if some fields failed
return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;
} catch (error) {
console.warn(`Failed to parse openspec/config.yaml:`, error);
return null;
}
}
/**
* Validate artifact IDs in rules against a schema's artifacts.
* Called during instruction loading (when schema is known).
* Returns warnings for unknown artifact IDs.
*
* @param rules - The rules object from config
* @param validArtifactIds - Set of valid artifact IDs from the schema
* @param schemaName - Name of the schema for error messages
* @returns Array of warning messages for unknown artifact IDs
*/
export function validateConfigRules(
rules: Record<string, string[]>,
validArtifactIds: Set<string>,
schemaName: string
): string[] {
const warnings: string[] = [];
for (const artifactId of Object.keys(rules)) {
if (!validArtifactIds.has(artifactId)) {
const validIds = Array.from(validArtifactIds).sort().join(', ');
warnings.push(
`Unknown artifact ID in rules: "${artifactId}". ` +
`Valid IDs for schema "${schemaName}": ${validIds}`
);
}
}
return warnings;
}
/**
* Suggest valid schema names when user provides invalid schema.
* Uses fuzzy matching to find similar names.
*
* @param invalidSchemaName - The invalid schema name from config
* @param availableSchemas - List of available schemas with their type (built-in or project-local)
* @returns Error message with suggestions and available schemas
*/
export function suggestSchemas(
invalidSchemaName: string,
availableSchemas: { name: string; isBuiltIn: boolean }[]
): string {
// Simple fuzzy match: Levenshtein distance
function levenshtein(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[b.length][a.length];
}
// Find closest matches (distance <= 3)
const suggestions = availableSchemas
.map((s) => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))
.filter((s) => s.distance <= 3)
.sort((a, b) => a.distance - b.distance)
.slice(0, 3);
const builtIn = availableSchemas.filter((s) => s.isBuiltIn).map((s) => s.name);
const projectLocal = availableSchemas.filter((s) => !s.isBuiltIn).map((s) => s.name);
let message = `Schema '${invalidSchemaName}' not found in openspec/config.yaml\n\n`;
if (suggestions.length > 0) {
message += `Did you mean one of these?\n`;
suggestions.forEach((s) => {
const type = s.isBuiltIn ? 'built-in' : 'project-local';
message += ` - ${s.name} (${type})\n`;
});
message += '\n';
}
message += `Available schemas:\n`;
if (builtIn.length > 0) {
message += ` Built-in: ${builtIn.join(', ')}\n`;
}
if (projectLocal.length > 0) {
message += ` Project-local: ${projectLocal.join(', ')}\n`;
} else {
message += ` Project-local: (none found)\n`;
}
message += `\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;
return message;
}
+7 -7
View File
@@ -9,7 +9,7 @@ Instructions for AI coding assistants using OpenSpec for spec-driven development
- Pick a unique \`change-id\`: kebab-case, verb-led (\`add-\`, \`update-\`, \`remove-\`, \`refactor-\`)
- Scaffold: \`proposal.md\`, \`tasks.md\`, \`design.md\` (only if needed), and delta specs per affected capability
- Write deltas: use \`## ADDED|MODIFIED|REMOVED|RENAMED Requirements\`; include at least one \`#### Scenario:\` per requirement
- Validate: \`openspec validate [change-id] --strict\` and fix issues
- Validate: \`openspec validate [change-id] --strict --no-interactive\` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
@@ -44,7 +44,7 @@ Skip proposal for:
1. Review \`openspec/project.md\`, \`openspec list\`, and \`openspec list --specs\` to understand current context.
2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, optional \`design.md\`, and spec deltas under \`openspec/changes/<id>/\`.
3. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement.
4. Run \`openspec validate <id> --strict\` and resolve any issues before sharing the proposal.
4. Run \`openspec validate <id> --strict --no-interactive\` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
@@ -61,7 +61,7 @@ After deployment, create separate PR to:
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
- Update \`specs/\` if capabilities changed
- Use \`openspec archive <change-id> --skip-specs --yes\` for tooling-only changes (always pass the change ID explicitly)
- Run \`openspec validate --strict\` to confirm the archived change passes checks
- Run \`openspec validate --strict --no-interactive\` to confirm the archived change passes checks
## Before Any Task
@@ -108,7 +108,7 @@ openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
openspec validate [change] --strict --no-interactive
\`\`\`
### Command Flags
@@ -306,7 +306,7 @@ Example for RENAMED:
\`\`\`bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
openspec validate [change] --strict --no-interactive
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
@@ -343,7 +343,7 @@ Users MUST provide a second factor during login.
EOF
# 4) Validate
openspec validate $CHANGE --strict
openspec validate $CHANGE --strict --no-interactive
\`\`\`
## Multi-Capability Example
@@ -449,7 +449,7 @@ Only add complexity with:
\`\`\`bash
openspec list # What's in progress?
openspec show [item] # View details
openspec validate --strict # Is it correct?
openspec validate --strict --no-interactive # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
\`\`\`
+500 -85
View File
@@ -24,6 +24,8 @@ export function getExploreSkillTemplate(): SkillTemplate {
description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.',
instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with \`/opsx:new\` or \`/opsx:ff\`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
@@ -31,6 +33,7 @@ export function getExploreSkillTemplate(): SkillTemplate {
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
@@ -290,6 +293,7 @@ But this summary is optional. Sometimes the thinking IS the value.
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
@@ -385,7 +389,7 @@ export function getContinueChangeSkillTemplate(): SkillTemplate {
description: 'Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.',
instructions: `Continue working on a change by creating the next artifact.
**Input**: Optionally specify a change name. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -499,19 +503,18 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.',
instructions: `Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
1. **Select the change**
Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select.
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run \`openspec list --json\` to get available changes and use the **AskUserQuestion tool** to let the user select
Show changes that are implementation-ready (have tasks artifact).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:apply <other>\`).
2. **Check status to understand the schema**
\`\`\`bash
@@ -754,7 +757,7 @@ export function getSyncSpecsSkillTemplate(): SkillTemplate {
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify a change name. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -904,6 +907,8 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate {
tags: ['workflow', 'explore', 'experimental', 'thinking'],
content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with \`/opsx:new\` or \`/opsx:ff\`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after \`/opsx:explore\` is whatever the user wants to think about. Could be:
@@ -918,6 +923,7 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate {
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
@@ -1058,6 +1064,7 @@ When things crystallize, you might offer a summary - but it's optional. Sometime
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
@@ -1154,7 +1161,7 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate {
tags: ['workflow', 'artifacts', 'experimental'],
content: `Continue working on a change by creating the next artifact.
**Input**: Optionally specify \`--change <name>\` after \`/opsx:continue\`. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name after \`/opsx:continue\` (e.g., \`/opsx:continue add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -1269,19 +1276,18 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
tags: ['workflow', 'artifacts', 'experimental'],
content: `Implement tasks from an OpenSpec change.
**Input**: Optionally specify \`--change <name>\` after \`/opsx:apply\`. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name (e.g., \`/opsx:apply add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
1. **Select the change**
Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select.
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run \`openspec list --json\` to get available changes and use the **AskUserQuestion tool** to let the user select
Show changes that are implementation-ready (have tasks artifact).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:apply <other>\`).
2. **Check status to understand the schema**
\`\`\`bash
@@ -1524,7 +1530,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate {
description: 'Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.',
instructions: `Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -1563,38 +1569,20 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate {
**If no tasks file exists:** Proceed without task-related warning.
4. **Check if delta specs need syncing**
4. **Assess delta spec sync state**
Check if \`specs/\` directory exists in the change with spec files.
Check for delta specs at \`openspec/changes/<name>/specs/\`. If none exist, proceed without sync prompt.
**If delta specs exist, perform a quick sync check:**
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at \`openspec/specs/<capability>/spec.md\`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
a. **For each delta spec** at \`openspec/changes/<name>/specs/<capability>/spec.md\`:
- Extract requirement names (lines matching \`### Requirement: <name>\`)
- Note which sections exist (ADDED, MODIFIED, REMOVED)
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
b. **Check corresponding main spec** at \`openspec/specs/<capability>/spec.md\`:
- If main spec doesn't exist → needs sync
- If main spec exists, check if ADDED requirement names appear in it
- If any ADDED requirements are missing from main spec → needs sync
c. **Report findings:**
**If sync needed:**
\`\`\`
⚠️ Delta specs may not be synced:
- specs/auth/spec.md → Main spec missing requirement "Token Refresh"
- specs/api/spec.md → Main spec doesn't exist yet
Would you like to sync now before archiving?
\`\`\`
- Use **AskUserQuestion tool** with options: "Sync now", "Archive without syncing"
- If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill)
**If already synced (all requirements found):**
- Proceed without prompting (specs appear to be in sync)
**If no delta specs exist:** Proceed without sync-related checks.
If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
5. **Perform the archive**
@@ -1630,7 +1618,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate {
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "⚠️ Not synced")
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
\`\`\`
@@ -1642,7 +1630,7 @@ All artifacts complete. All tasks complete.
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- Quick sync check: look for requirement names in delta specs, verify they exist in main specs`
- If delta specs exist, always run the sync assessment and show the combined summary before prompting`
};
}
@@ -1659,7 +1647,7 @@ export function getOpsxSyncCommandTemplate(): CommandTemplate {
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify \`--change <name>\` after \`/opsx:sync\`. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name after \`/opsx:sync\` (e.g., \`/opsx:sync add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -1785,6 +1773,174 @@ Main specs are now updated. The change remains active - archive when implementat
};
}
/**
* Template for openspec-verify-change skill
* For verifying implementation matches change artifacts before archiving
*/
export function getVerifyChangeSkillTemplate(): SkillTemplate {
return {
name: 'openspec-verify-change',
description: 'Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.',
instructions: `Verify that an implementation matches the change artifacts (specs, tasks, design).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have implementation tasks (tasks artifact exists).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check status to understand the schema**
\`\`\`bash
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand:
- \`schemaName\`: The workflow being used (e.g., "spec-driven", "tdd")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
\`\`\`bash
openspec instructions apply --change "<name>" --json
\`\`\`
This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
4. **Initialize verification report structure**
Create a report structure with three dimensions:
- **Completeness**: Track tasks and spec coverage
- **Correctness**: Track requirement implementation and scenario coverage
- **Coherence**: Track design adherence and pattern consistency
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
5. **Verify Completeness**
**Task Completion**:
- If tasks.md exists in contextFiles, read it
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
**Spec Coverage**:
- If delta specs exist in \`openspec/changes/<name>/specs/\`:
- Extract all requirements (marked with "### Requirement:")
- For each requirement:
- Search codebase for keywords related to the requirement
- Assess if implementation likely exists
- If requirements appear unimplemented:
- Add CRITICAL issue: "Requirement not found: <requirement name>"
- Recommendation: "Implement requirement X: <description>"
6. **Verify Correctness**
**Requirement Implementation Mapping**:
- For each requirement from delta specs:
- Search codebase for implementation evidence
- If found, note file paths and line ranges
- Assess if implementation matches requirement intent
- If divergence detected:
- Add WARNING: "Implementation may diverge from spec: <details>"
- Recommendation: "Review <file>:<lines> against requirement X"
**Scenario Coverage**:
- For each scenario in delta specs (marked with "#### Scenario:"):
- Check if conditions are handled in code
- Check if tests exist covering the scenario
- If scenario appears uncovered:
- Add WARNING: "Scenario not covered: <scenario name>"
- Recommendation: "Add test or implementation for scenario: <description>"
7. **Verify Coherence**
**Design Adherence**:
- If design.md exists in contextFiles:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
- Add WARNING: "Design decision not followed: <decision>"
- Recommendation: "Update implementation or revise design.md to match reality"
- If no design.md: Skip design adherence check, note "No design.md to verify against"
**Code Pattern Consistency**:
- Review new code for consistency with project patterns
- Check file naming, directory structure, coding style
- If significant deviations found:
- Add SUGGESTION: "Code pattern deviation: <details>"
- Recommendation: "Consider following project pattern: <example>"
8. **Generate Verification Report**
**Summary Scorecard**:
\`\`\`
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|------------------|
| Completeness | X/Y tasks, N reqs|
| Correctness | M/N reqs covered |
| Coherence | Followed/Issues |
\`\`\`
**Issues by Priority**:
1. **CRITICAL** (Must fix before archive):
- Incomplete tasks
- Missing requirement implementations
- Each with specific, actionable recommendation
2. **WARNING** (Should fix):
- Spec/design divergences
- Missing scenario coverage
- Each with specific recommendation
3. **SUGGESTION** (Nice to fix):
- Pattern inconsistencies
- Minor improvements
- Each with specific recommendation
**Final Assessment**:
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
- If all clear: "All checks passed. Ready for archive."
**Verification Heuristics**
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
**Graceful Degradation**
- If only tasks.md exists: verify task completion only, skip spec/design checks
- If tasks + specs exist: verify completeness and correctness, skip design
- If full artifacts: verify all three dimensions
- Always note which checks were skipped and why
**Output Format**
Use clear markdown with:
- Table for summary scorecard
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
- Code references in format: \`file.ts:123\`
- Specific, actionable recommendations
- No vague suggestions like "consider reviewing"`
};
}
/**
* Template for /opsx:archive slash command
*/
@@ -1796,7 +1952,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate {
tags: ['workflow', 'archive', 'experimental'],
content: `Archive a completed change in the experimental workflow.
**Input**: Optionally specify \`--change <name>\` after \`/opsx:archive\`. If omitted, MUST prompt for available changes.
**Input**: Optionally specify a change name after \`/opsx:archive\` (e.g., \`/opsx:archive add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -1835,38 +1991,20 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate {
**If no tasks file exists:** Proceed without task-related warning.
4. **Check if delta specs need syncing**
4. **Assess delta spec sync state**
Check if \`specs/\` directory exists in the change with spec files.
Check for delta specs at \`openspec/changes/<name>/specs/\`. If none exist, proceed without sync prompt.
**If delta specs exist, perform a quick sync check:**
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at \`openspec/specs/<capability>/spec.md\`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
a. **For each delta spec** at \`openspec/changes/<name>/specs/<capability>/spec.md\`:
- Extract requirement names (lines matching \`### Requirement: <name>\`)
- Note which sections exist (ADDED, MODIFIED, REMOVED)
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
b. **Check corresponding main spec** at \`openspec/specs/<capability>/spec.md\`:
- If main spec doesn't exist → needs sync
- If main spec exists, check if ADDED requirement names appear in it
- If any ADDED requirements are missing from main spec → needs sync
c. **Report findings:**
**If sync needed:**
\`\`\`
⚠️ Delta specs may not be synced:
- specs/auth/spec.md → Main spec missing requirement "Token Refresh"
- specs/api/spec.md → Main spec doesn't exist yet
Would you like to sync now before archiving?
\`\`\`
- Use **AskUserQuestion tool** with options: "Sync now", "Archive without syncing"
- If user chooses sync, execute \`/opsx:sync\` logic
**If already synced (all requirements found):**
- Proceed without prompting (specs appear to be in sync)
**If no delta specs exist:** Proceed without sync-related checks.
If user chooses sync, execute \`/opsx:sync\` logic. Proceed to archive regardless of choice.
5. **Perform the archive**
@@ -1891,7 +2029,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate {
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / not synced / no delta specs)
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
@@ -1928,12 +2066,12 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ⚠️ Not synced
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta specs were not synced (user chose to skip)
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
\`\`\`
@@ -1959,8 +2097,285 @@ Target archive directory already exists.
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Quick sync check: look for requirement names in delta specs, verify they exist in main specs
- Show clear summary of what happened
- If sync is requested, use /opsx:sync approach (agent-driven)`
- If sync is requested, use /opsx:sync approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting`
};
}
/**
* Template for /opsx:verify slash command
*/
export function getOpsxVerifyCommandTemplate(): CommandTemplate {
return {
name: 'OPSX: Verify',
description: 'Verify implementation matches change artifacts before archiving',
category: 'Workflow',
tags: ['workflow', 'verify', 'experimental'],
content: `Verify that an implementation matches the change artifacts (specs, tasks, design).
**Input**: Optionally specify a change name after \`/opsx:verify\` (e.g., \`/opsx:verify add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have implementation tasks (tasks artifact exists).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check status to understand the schema**
\`\`\`bash
openspec status --change "<name>" --json
\`\`\`
Parse the JSON to understand:
- \`schemaName\`: The workflow being used (e.g., "spec-driven", "tdd")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
\`\`\`bash
openspec instructions apply --change "<name>" --json
\`\`\`
This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
4. **Initialize verification report structure**
Create a report structure with three dimensions:
- **Completeness**: Track tasks and spec coverage
- **Correctness**: Track requirement implementation and scenario coverage
- **Coherence**: Track design adherence and pattern consistency
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
5. **Verify Completeness**
**Task Completion**:
- If tasks.md exists in contextFiles, read it
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
**Spec Coverage**:
- If delta specs exist in \`openspec/changes/<name>/specs/\`:
- Extract all requirements (marked with "### Requirement:")
- For each requirement:
- Search codebase for keywords related to the requirement
- Assess if implementation likely exists
- If requirements appear unimplemented:
- Add CRITICAL issue: "Requirement not found: <requirement name>"
- Recommendation: "Implement requirement X: <description>"
6. **Verify Correctness**
**Requirement Implementation Mapping**:
- For each requirement from delta specs:
- Search codebase for implementation evidence
- If found, note file paths and line ranges
- Assess if implementation matches requirement intent
- If divergence detected:
- Add WARNING: "Implementation may diverge from spec: <details>"
- Recommendation: "Review <file>:<lines> against requirement X"
**Scenario Coverage**:
- For each scenario in delta specs (marked with "#### Scenario:"):
- Check if conditions are handled in code
- Check if tests exist covering the scenario
- If scenario appears uncovered:
- Add WARNING: "Scenario not covered: <scenario name>"
- Recommendation: "Add test or implementation for scenario: <description>"
7. **Verify Coherence**
**Design Adherence**:
- If design.md exists in contextFiles:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
- Add WARNING: "Design decision not followed: <decision>"
- Recommendation: "Update implementation or revise design.md to match reality"
- If no design.md: Skip design adherence check, note "No design.md to verify against"
**Code Pattern Consistency**:
- Review new code for consistency with project patterns
- Check file naming, directory structure, coding style
- If significant deviations found:
- Add SUGGESTION: "Code pattern deviation: <details>"
- Recommendation: "Consider following project pattern: <example>"
8. **Generate Verification Report**
**Summary Scorecard**:
\`\`\`
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|------------------|
| Completeness | X/Y tasks, N reqs|
| Correctness | M/N reqs covered |
| Coherence | Followed/Issues |
\`\`\`
**Issues by Priority**:
1. **CRITICAL** (Must fix before archive):
- Incomplete tasks
- Missing requirement implementations
- Each with specific, actionable recommendation
2. **WARNING** (Should fix):
- Spec/design divergences
- Missing scenario coverage
- Each with specific recommendation
3. **SUGGESTION** (Nice to fix):
- Pattern inconsistencies
- Minor improvements
- Each with specific recommendation
**Final Assessment**:
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
- If all clear: "All checks passed. Ready for archive."
**Verification Heuristics**
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
**Graceful Degradation**
- If only tasks.md exists: verify task completion only, skip spec/design checks
- If tasks + specs exist: verify completeness and correctness, skip design
- If full artifacts: verify all three dimensions
- Always note which checks were skipped and why
**Output Format**
Use clear markdown with:
- Table for summary scorecard
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
- Code references in format: \`file.ts:123\`
- Specific, actionable recommendations
- No vague suggestions like "consider reviewing"`
};
}
/**
* Template for feedback skill
* For collecting and submitting user feedback with context enrichment
*/
export function getFeedbackSkillTemplate(): SkillTemplate {
return {
name: 'feedback',
description: 'Collect and submit user feedback about OpenSpec with context enrichment and anonymization.',
instructions: `Help the user submit feedback about OpenSpec.
**Goal**: Guide the user through collecting, enriching, and submitting feedback while ensuring privacy through anonymization.
**Process**
1. **Gather context from the conversation**
- Review recent conversation history for context
- Identify what task was being performed
- Note what worked well or poorly
- Capture specific friction points or praise
2. **Draft enriched feedback**
- Create a clear, descriptive title (single sentence, no "Feedback:" prefix needed)
- Write a body that includes:
- What the user was trying to do
- What happened (good or bad)
- Relevant context from the conversation
- Any specific suggestions or requests
3. **Anonymize sensitive information**
- Replace file paths with \`<path>\` or generic descriptions
- Replace API keys, tokens, secrets with \`<redacted>\`
- Replace company/organization names with \`<company>\`
- Replace personal names with \`<user>\`
- Replace specific URLs with \`<url>\` unless public/relevant
- Keep technical details that help understand the issue
4. **Present draft for approval**
- Show the complete draft to the user
- Display both title and body clearly
- Ask for explicit approval before submitting
- Allow the user to request modifications
5. **Submit on confirmation**
- Use the \`openspec feedback\` command to submit
- Format: \`openspec feedback "title" --body "body content"\`
- The command will automatically add metadata (version, platform, timestamp)
**Example Draft**
\`\`\`
Title: Error handling in artifact workflow needs improvement
Body:
I was working on creating a new change and encountered an issue with
the artifact workflow. When I tried to continue after creating the
proposal, the system didn't clearly indicate that I needed to complete
the specs first.
Suggestion: Add clearer error messages that explain dependency chains
in the artifact workflow. Something like "Cannot create design.md
because specs are not complete (0/2 done)."
Context: Using the spec-driven schema with <path>/my-project
\`\`\`
**Anonymization Examples**
Before:
\`\`\`
Working on /Users/john/mycompany/auth-service/src/oauth.ts
Failed with API key: sk_live_abc123xyz
Working at Acme Corp
\`\`\`
After:
\`\`\`
Working on <path>/oauth.ts
Failed with API key: <redacted>
Working at <company>
\`\`\`
**Guardrails**
- MUST show complete draft before submitting
- MUST ask for explicit approval
- MUST anonymize sensitive information
- ALLOW user to modify draft before submitting
- DO NOT submit without user confirmation
- DO include relevant technical context
- DO keep conversation-specific insights
**User Confirmation Required**
Always ask:
\`\`\`
Here's the feedback I've drafted:
Title: [title]
Body:
[body]
Does this look good? I can modify it if you'd like, or submit it as-is.
\`\`\`
Only proceed with submission after user confirms.`
};
}
@@ -15,7 +15,7 @@ const proposalSteps = `**Steps**
4. Capture architectural reasoning in \`design.md\` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
5. Draft spec deltas in \`changes/<id>/specs/<capability>/spec.md\` (one folder per capability) using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement and cross-reference related capabilities when relevant.
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.`;
7. Validate with \`openspec validate <id> --strict --no-interactive\` and resolve every issue before sharing the proposal.`;
const proposalReferences = `**Reference**
@@ -43,7 +43,7 @@ const archiveSteps = `**Steps**
2. Validate the change ID by running \`openspec list\` (or \`openspec show <id>\`) and stop if the change is missing, already archived, or otherwise not ready to archive.
3. Run \`openspec archive <id> --yes\` so the CLI moves the change and applies spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
4. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
5. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
5. Validate with \`openspec validate --strict --no-interactive\` and inspect with \`openspec show <id>\` if anything looks off.`;
const archiveReferences = `**Reference**
- Use \`openspec list\` to confirm change IDs before archiving.
+35 -10
View File
@@ -3,6 +3,7 @@ import * as path from 'node:path';
import * as yaml from 'yaml';
import { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js';
import { listSchemas } from '../core/artifact-graph/resolver.js';
import { readProjectConfig } from '../core/project-config.js';
const METADATA_FILENAME = '.openspec.yaml';
@@ -24,11 +25,15 @@ export class ChangeMetadataError extends Error {
* Validates that a schema name is valid (exists in available schemas).
*
* @param schemaName - The schema name to validate
* @param projectRoot - Optional project root for project-local schema resolution
* @returns The validated schema name
* @throws Error if schema is not found
*/
export function validateSchemaName(schemaName: string): string {
const availableSchemas = listSchemas();
export function validateSchemaName(
schemaName: string,
projectRoot?: string
): string {
const availableSchemas = listSchemas(projectRoot);
if (!availableSchemas.includes(schemaName)) {
throw new Error(
`Unknown schema '${schemaName}'. Available: ${availableSchemas.join(', ')}`
@@ -42,16 +47,18 @@ export function validateSchemaName(schemaName: string): string {
*
* @param changeDir - The path to the change directory
* @param metadata - The metadata to write
* @param projectRoot - Optional project root for project-local schema resolution
* @throws ChangeMetadataError if validation fails or write fails
*/
export function writeChangeMetadata(
changeDir: string,
metadata: ChangeMetadata
metadata: ChangeMetadata,
projectRoot?: string
): void {
const metaPath = path.join(changeDir, METADATA_FILENAME);
// Validate schema exists
validateSchemaName(metadata.schema);
validateSchemaName(metadata.schema, projectRoot);
// Validate with Zod
const parseResult = ChangeMetadataSchema.safeParse(metadata);
@@ -80,10 +87,14 @@ export function writeChangeMetadata(
* Reads change metadata from .openspec.yaml in the change directory.
*
* @param changeDir - The path to the change directory
* @param projectRoot - Optional project root for project-local schema resolution
* @returns The validated metadata, or null if no metadata file exists
* @throws ChangeMetadataError if the file exists but is invalid
*/
export function readChangeMetadata(changeDir: string): ChangeMetadata | null {
export function readChangeMetadata(
changeDir: string,
projectRoot?: string
): ChangeMetadata | null {
const metaPath = path.join(changeDir, METADATA_FILENAME);
if (!fs.existsSync(metaPath)) {
@@ -124,7 +135,7 @@ export function readChangeMetadata(changeDir: string): ChangeMetadata | null {
}
// Validate that the schema exists
const availableSchemas = listSchemas();
const availableSchemas = listSchemas(projectRoot);
if (!availableSchemas.includes(parseResult.data.schema)) {
throw new ChangeMetadataError(
`Unknown schema '${parseResult.data.schema}'. Available: ${availableSchemas.join(', ')}`,
@@ -141,7 +152,8 @@ export function readChangeMetadata(changeDir: string): ChangeMetadata | null {
* Resolution order:
* 1. Explicit schema (if provided)
* 2. Schema from .openspec.yaml metadata (if exists)
* 3. Default 'spec-driven'
* 3. Schema from openspec/config.yaml (if exists)
* 4. Default 'spec-driven'
*
* @param changeDir - The path to the change directory
* @param explicitSchema - Optional explicit schema override
@@ -151,6 +163,9 @@ export function resolveSchemaForChange(
changeDir: string,
explicitSchema?: string
): string {
// Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name)
const projectRoot = path.resolve(changeDir, '../../..');
// 1. Explicit override wins
if (explicitSchema) {
return explicitSchema;
@@ -158,14 +173,24 @@ export function resolveSchemaForChange(
// 2. Try reading from metadata
try {
const metadata = readChangeMetadata(changeDir);
const metadata = readChangeMetadata(changeDir, projectRoot);
if (metadata?.schema) {
return metadata.schema;
}
} catch {
// If metadata read fails, fall back to default
// If metadata read fails, continue to next option
}
// 3. Default
// 3. Try reading from project config
try {
const config = readProjectConfig(projectRoot);
if (config?.schema) {
return config.schema;
}
} catch {
// If config read fails, fall back to default
}
// 4. Default
return 'spec-driven';
}
+36 -7
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
import { readProjectConfig } from '../core/project-config.js';
const DEFAULT_SCHEMA = 'spec-driven';
@@ -12,6 +13,14 @@ export interface CreateChangeOptions {
schema?: string;
}
/**
* Result of creating a change.
*/
export interface CreateChangeResult {
/** The schema that was actually used (resolved from options, config, or default) */
schema: string;
}
/**
* Result of validating a change name.
*/
@@ -88,28 +97,46 @@ export function validateChangeName(name: string): ValidationResult {
* @throws Error if the schema name is invalid
* @throws Error if the change directory already exists
*
* @returns Result containing the resolved schema name
*
* @example
* // Creates openspec/changes/add-auth/ with default schema
* await createChange('/path/to/project', 'add-auth')
* const result = await createChange('/path/to/project', 'add-auth')
* console.log(result.schema) // 'spec-driven' or value from config
*
* @example
* // Creates openspec/changes/add-auth/ with TDD schema
* await createChange('/path/to/project', 'add-auth', { schema: 'tdd' })
* const result = await createChange('/path/to/project', 'add-auth', { schema: 'tdd' })
* console.log(result.schema) // 'tdd'
*/
export async function createChange(
projectRoot: string,
name: string,
options: CreateChangeOptions = {}
): Promise<void> {
): Promise<CreateChangeResult> {
// Validate the name first
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
// Determine schema (validate if provided)
const schemaName = options.schema ?? DEFAULT_SCHEMA;
validateSchemaName(schemaName);
// Determine schema: explicit option → project config → hardcoded default
let schemaName: string;
if (options.schema) {
schemaName = options.schema;
} else {
// Try to read from project config
try {
const config = readProjectConfig(projectRoot);
schemaName = config?.schema ?? DEFAULT_SCHEMA;
} catch {
// If config read fails, use default
schemaName = DEFAULT_SCHEMA;
}
}
// Validate the resolved schema
validateSchemaName(schemaName, projectRoot);
// Build the change directory path
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
@@ -127,5 +154,7 @@ export async function createChange(
writeChangeMetadata(changeDir, {
schema: schemaName,
created: today,
});
}, projectRoot);
return { schema: schemaName };
}
+188
View File
@@ -576,4 +576,192 @@ artifacts:
expect(result.stdout).toContain('[Experimental]');
});
});
describe('project config integration', () => {
describe('new change uses config schema', () => {
it('creates change with schema from project config', async () => {
// Create project config with tdd schema
// Note: changesDir is already at tempDir/openspec/changes (created in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
'schema: tdd\n'
);
// Create a new change without specifying schema
const result = await runCLI(['new', 'change', 'test-change'], { cwd: tempDir, timeoutMs: 30000 });
expect(result.exitCode).toBe(0);
// Verify the change was created with tdd schema
const metadataPath = path.join(changesDir, 'test-change', '.openspec.yaml');
const metadata = await fs.readFile(metadataPath, 'utf-8');
expect(metadata).toContain('schema: tdd');
}, 60000);
it('CLI schema overrides config schema', async () => {
// Create project config with tdd schema
// Note: openspec directory already exists (from changesDir creation in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
'schema: tdd\n'
);
// Create change with explicit schema
const result = await runCLI(
['new', 'change', 'override-test', '--schema', 'spec-driven'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result.exitCode).toBe(0);
// Verify the change uses the CLI-specified schema
const metadataPath = path.join(changesDir, 'override-test', '.openspec.yaml');
const metadata = await fs.readFile(metadataPath, 'utf-8');
expect(metadata).toContain('schema: spec-driven');
}, 60000);
});
describe('instructions command with config', () => {
it('injects context and rules from config into instructions', async () => {
// Create project config with context and rules
// Note: openspec directory already exists (from changesDir creation in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
`schema: spec-driven
context: |
Tech stack: TypeScript, React
API style: RESTful
rules:
proposal:
- Include rollback plan
- Identify affected teams
`
);
// Create a test change
await createTestChange('config-test');
// Get instructions for proposal
const result = await runCLI(
['instructions', 'proposal', '--change', 'config-test'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result.exitCode).toBe(0);
// Verify context is injected
expect(result.stdout).toContain('Tech stack: TypeScript, React');
expect(result.stdout).toContain('API style: RESTful');
// Verify rules are injected for proposal
expect(result.stdout).toContain('Include rollback plan');
expect(result.stdout).toContain('Identify affected teams');
}, 60000);
it('does not inject rules for non-matching artifact', async () => {
// Create project config with rules only for proposal
// Note: openspec directory already exists (from changesDir creation in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Include rollback plan
`
);
// Create a test change
await createTestChange('non-matching-test');
// Get instructions for design (not proposal)
const result = await runCLI(
['instructions', 'design', '--change', 'non-matching-test'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result.exitCode).toBe(0);
// Verify rules are NOT injected for design
expect(result.stdout).not.toContain('Include rollback plan');
}, 60000);
});
describe('backwards compatibility', () => {
it('existing changes work without config file', async () => {
// Create change without any config file
await createTestChange('no-config-change', ['proposal']);
// Status command should work
const statusResult = await runCLI(
['status', '--change', 'no-config-change'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(statusResult.exitCode).toBe(0);
expect(statusResult.stdout).toContain('no-config-change');
expect(statusResult.stdout).toContain('spec-driven'); // Default schema
// Instructions command should work
const instrResult = await runCLI(
['instructions', 'design', '--change', 'no-config-change'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(instrResult.exitCode).toBe(0);
expect(instrResult.stdout).toContain('<artifact');
}, 60000);
it('changes with metadata work without config file', async () => {
// Create change with explicit schema in metadata
const changeDir = await createTestChange('metadata-only-change');
await fs.writeFile(
path.join(changeDir, '.openspec.yaml'),
'schema: tdd\ncreated: "2025-01-05"\n'
);
// Status should use schema from metadata
const result = await runCLI(
['status', '--change', 'metadata-only-change'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tdd');
}, 60000);
});
describe('config changes reflected immediately', () => {
it('config changes are reflected without restart', async () => {
// Create initial config
// Note: openspec directory already exists (from changesDir creation in beforeEach)
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
`schema: spec-driven
context: Initial context
`
);
// Create a test change
await createTestChange('immediate-test');
// Get instructions - should have initial context
const result1 = await runCLI(
['instructions', 'proposal', '--change', 'immediate-test'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result1.exitCode).toBe(0);
expect(result1.stdout).toContain('Initial context');
// Update config
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
`schema: spec-driven
context: Updated context
`
);
// Get instructions again - should have updated context
const result2 = await runCLI(
['instructions', 'proposal', '--change', 'immediate-test'],
{ cwd: tempDir, timeoutMs: 30000 }
);
expect(result2.exitCode).toBe(0);
expect(result2.stdout).toContain('Updated context');
expect(result2.stdout).not.toContain('Initial context');
}, 60000);
});
});
});
+429
View File
@@ -0,0 +1,429 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FeedbackCommand } from '../../src/commands/feedback.js';
import { execSync, execFileSync } from 'child_process';
// Mock child_process functions
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
}));
describe('FeedbackCommand', () => {
let feedbackCommand: FeedbackCommand;
let consoleLogSpy: any;
let consoleErrorSpy: any;
let processExitSpy: any;
const mockExecSync = execSync as unknown as ReturnType<typeof vi.fn>;
const mockExecFileSync = execFileSync as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
feedbackCommand = new FeedbackCommand();
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${code})`);
});
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('gh CLI availability check', () => {
it('should use which command on Unix/macOS platforms', async () => {
// Mock platform as darwin
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockExecSync.mockImplementation((cmd: string) => {
if (cmd === 'which gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\n');
await feedbackCommand.execute('Test');
// Verify 'which gh' was called
expect(mockExecSync).toHaveBeenCalledWith('which gh', expect.any(Object));
// Restore original platform
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should use where command on Windows platform', async () => {
// Mock platform as win32
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
mockExecSync.mockImplementation((cmd: string) => {
if (cmd === 'where gh') {
return Buffer.from('C:\\Program Files\\GitHub CLI\\gh.exe');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\n');
await feedbackCommand.execute('Test');
// Verify 'where gh' was called
expect(mockExecSync).toHaveBeenCalledWith('where gh', expect.any(Object));
// Restore original platform
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should handle missing gh CLI with fallback', async () => {
// Simulate gh not installed
mockExecSync.mockImplementation((cmd: string) => {
if (cmd === 'which gh' || cmd === 'where gh') {
throw new Error('Command not found');
}
});
try {
await feedbackCommand.execute('Test feedback');
} catch (error: any) {
// Should exit with code 0 (successful fallback)
expect(error.message).toBe('process.exit(0)');
}
// Should display warning
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('GitHub CLI not found')
);
// Should show formatted feedback
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('--- FORMATTED FEEDBACK ---')
);
// Should show manual submission URL
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('https://github.com/Fission-AI/OpenSpec/issues/new')
);
});
it('should handle unauthenticated gh CLI with fallback', async () => {
// Simulate gh installed but not authenticated
mockExecSync.mockImplementation((cmd: string) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
throw new Error('Not authenticated');
}
});
try {
await feedbackCommand.execute('Test feedback');
} catch (error: any) {
// Should exit with code 0 (successful fallback)
expect(error.message).toBe('process.exit(0)');
}
// Should display warning
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('GitHub authentication required')
);
// Should show auth instructions
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('To auto-submit in the future: gh auth login')
);
// Should show formatted feedback
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('--- FORMATTED FEEDBACK ---')
);
});
});
describe('successful feedback submission', () => {
it('should submit feedback via gh CLI when authenticated', async () => {
const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/123';
// Simulate gh installed and authenticated
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue(`${issueUrl}\n`);
await feedbackCommand.execute('Great tool!');
// Should call gh with correct arguments using execFileSync
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
[
'issue',
'create',
'--repo',
'Fission-AI/OpenSpec',
'--title',
'Feedback: Great tool!',
'--body',
expect.stringContaining('Submitted via OpenSpec CLI'),
'--label',
'feedback',
],
expect.objectContaining({
encoding: 'utf-8',
stdio: 'pipe',
})
);
// Should display success message
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Feedback submitted successfully')
);
// Should display issue URL
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining(issueUrl)
);
});
it('should include --body flag when body is provided', async () => {
const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/124';
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue(`${issueUrl}\n`);
await feedbackCommand.execute('Title here', { body: 'Detailed description' });
// Verify body is included in the arguments
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
expect.arrayContaining([
'--body',
expect.stringContaining('Detailed description'),
]),
expect.any(Object)
);
});
it('should format title with "Feedback:" prefix', async () => {
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/125\n');
await feedbackCommand.execute('Test message');
// Verify title has "Feedback:" prefix
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
expect.arrayContaining([
'--title',
'Feedback: Test message',
]),
expect.any(Object)
);
});
it('should include metadata in issue body', async () => {
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/126\n');
await feedbackCommand.execute('Test', { body: 'Body text' });
// Verify metadata is included in body
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
expect.arrayContaining([
'--body',
expect.stringMatching(/Submitted via OpenSpec CLI[\s\S]*Version:[\s\S]*Platform:[\s\S]*Timestamp:/),
]),
expect.any(Object)
);
});
it('should add feedback label to the issue', async () => {
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/127\n');
await feedbackCommand.execute('Test');
// Verify feedback label is added
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
expect.arrayContaining([
'--label',
'feedback',
]),
expect.any(Object)
);
});
});
describe('error handling', () => {
it('should handle gh CLI execution failure', async () => {
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
// Mock execFileSync to throw error
mockExecFileSync.mockImplementation(() => {
const error: any = new Error('Network error');
error.status = 1;
error.stderr = Buffer.from('Error: Network connectivity issue');
throw error;
});
try {
await feedbackCommand.execute('Test');
} catch (error: any) {
// Should exit with the same code as gh CLI
expect(error.message).toBe('process.exit(1)');
}
// Should display the error from gh CLI
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Network connectivity issue')
);
});
it('should handle quotes in title and body without escaping (no shell injection)', async () => {
mockExecSync.mockImplementation((cmd: string, options?: any) => {
if (cmd === 'which gh' || cmd === 'where gh') {
return Buffer.from('/usr/local/bin/gh');
}
if (cmd === 'gh auth status') {
return Buffer.from('Logged in');
}
return '';
});
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/128\n');
await feedbackCommand.execute('Test with "quotes"', {
body: 'Body with "quotes"',
});
// Verify quotes are passed as-is (no escaping needed with execFileSync)
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
expect.arrayContaining([
'--title',
'Feedback: Test with "quotes"',
'--body',
expect.stringContaining('Body with "quotes"'),
]),
expect.any(Object)
);
});
});
describe('formatted feedback output', () => {
it('should display formatted feedback with proper structure', async () => {
mockExecSync.mockImplementation((cmd: string) => {
if (cmd === 'which gh' || cmd === 'where gh') {
throw new Error('Command not found');
}
});
try {
await feedbackCommand.execute('Test message', { body: 'Test body' });
} catch (error: any) {
// Expected to exit
}
// Verify formatted output structure
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('--- FORMATTED FEEDBACK ---')
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Title: Feedback: Test message')
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Labels: feedback')
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('--- END FEEDBACK ---')
);
});
it('should generate correct manual submission URL', async () => {
mockExecSync.mockImplementation((cmd: string) => {
if (cmd === 'which gh' || cmd === 'where gh') {
throw new Error('Command not found');
}
});
try {
await feedbackCommand.execute('Test');
} catch (error: any) {
// Expected to exit
}
// Verify URL is shown
const urlCall = consoleLogSpy.mock.calls.find((call: any[]) =>
call[0]?.includes('https://github.com/Fission-AI/OpenSpec/issues/new')
);
expect(urlCall).toBeDefined();
// Verify URL has proper parameters
const url = urlCall?.[0];
expect(url).toContain('title=');
expect(url).toContain('body=');
expect(url).toContain('labels=feedback');
});
});
});
+467
View File
@@ -0,0 +1,467 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
describe('schema command', () => {
let tempDir: string;
let originalCwd: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Create unique temp directory for each test
tempDir = path.join(
os.tmpdir(),
`openspec-schema-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
fs.mkdirSync(tempDir, { recursive: true });
// Create openspec directory structure
fs.mkdirSync(path.join(tempDir, 'openspec', 'schemas'), { recursive: true });
// Save original cwd and env
originalCwd = process.cwd();
originalEnv = { ...process.env };
// Change to temp directory
process.chdir(tempDir);
// Set XDG paths to temp to avoid polluting user directories
process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data');
process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config');
// Spy on console
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// Restore cwd and env
process.chdir(originalCwd);
process.env = originalEnv;
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
// Restore spies
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
// Reset module cache
vi.resetModules();
});
describe('schema which', () => {
it('should show schema resolution from package', async () => {
const { getSchemaDir, listSchemas } = await import(
'../../src/core/artifact-graph/resolver.js'
);
// Verify spec-driven exists in package
const schemas = listSchemas(tempDir);
expect(schemas).toContain('spec-driven');
const schemaDir = getSchemaDir('spec-driven', tempDir);
expect(schemaDir).not.toBeNull();
expect(schemaDir).toContain('schemas');
});
it('should detect project schema shadowing package', async () => {
// Create a project-local spec-driven schema
const projectSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
`name: spec-driven
version: 1
description: Custom spec-driven
artifacts:
- id: proposal
generates: proposal.md
description: Proposal
template: proposal.md
`
);
fs.writeFileSync(path.join(projectSchemaDir, 'proposal.md'), '# Proposal');
const { getSchemaDir } = await import('../../src/core/artifact-graph/resolver.js');
// Should resolve to project
const schemaDir = getSchemaDir('spec-driven', tempDir);
expect(schemaDir).toBe(projectSchemaDir);
});
it('should list all schemas with --all flag', async () => {
const { listSchemas } = await import('../../src/core/artifact-graph/resolver.js');
const schemas = listSchemas(tempDir);
expect(schemas.length).toBeGreaterThan(0);
expect(schemas).toContain('spec-driven');
});
});
describe('schema validate', () => {
it('should validate a valid schema', async () => {
// Create a valid project schema
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'test-schema');
fs.mkdirSync(schemaDir, { recursive: true });
fs.writeFileSync(
path.join(schemaDir, 'schema.yaml'),
`name: test-schema
version: 1
description: Test schema
artifacts:
- id: proposal
generates: proposal.md
description: Proposal
template: proposal.md
`
);
fs.writeFileSync(path.join(schemaDir, 'proposal.md'), '# Proposal Template');
const { parseSchema } = await import('../../src/core/artifact-graph/schema.js');
const content = fs.readFileSync(path.join(schemaDir, 'schema.yaml'), 'utf-8');
const schema = parseSchema(content);
expect(schema.name).toBe('test-schema');
expect(schema.artifacts).toHaveLength(1);
});
it('should detect missing template file', async () => {
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'bad-schema');
fs.mkdirSync(schemaDir, { recursive: true });
fs.writeFileSync(
path.join(schemaDir, 'schema.yaml'),
`name: bad-schema
version: 1
description: Bad schema
artifacts:
- id: proposal
generates: proposal.md
description: Proposal
template: missing-template.md
`
);
// Template file doesn't exist, validation should report this
const templatePath = path.join(schemaDir, 'missing-template.md');
expect(fs.existsSync(templatePath)).toBe(false);
});
it('should detect circular dependencies', async () => {
const { parseSchema, SchemaValidationError } = await import(
'../../src/core/artifact-graph/schema.js'
);
const content = `name: circular-schema
version: 1
description: Schema with circular deps
artifacts:
- id: a
generates: a.md
description: A
template: a.md
requires:
- b
- id: b
generates: b.md
description: B
template: b.md
requires:
- a
`;
expect(() => parseSchema(content)).toThrow(SchemaValidationError);
expect(() => parseSchema(content)).toThrow(/[Cc]yclic/);
});
it('should detect unknown dependency reference', async () => {
const { parseSchema, SchemaValidationError } = await import(
'../../src/core/artifact-graph/schema.js'
);
const content = `name: bad-ref-schema
version: 1
description: Schema with bad ref
artifacts:
- id: a
generates: a.md
description: A
template: a.md
requires:
- nonexistent
`;
expect(() => parseSchema(content)).toThrow(SchemaValidationError);
expect(() => parseSchema(content)).toThrow(/nonexistent/);
});
});
describe('schema fork', () => {
it('should copy schema to project directory', async () => {
const { getSchemaDir } = await import('../../src/core/artifact-graph/resolver.js');
// Get the package spec-driven schema
const sourceDir = getSchemaDir('spec-driven', tempDir);
expect(sourceDir).not.toBeNull();
// Copy manually to simulate fork
const destDir = path.join(tempDir, 'openspec', 'schemas', 'my-custom');
fs.mkdirSync(destDir, { recursive: true });
// Copy files
const files = fs.readdirSync(sourceDir!);
for (const file of files) {
const srcPath = path.join(sourceDir!, file);
const destPath = path.join(destDir, file);
const stat = fs.statSync(srcPath);
if (stat.isFile()) {
fs.copyFileSync(srcPath, destPath);
}
}
// Verify destination exists
expect(fs.existsSync(path.join(destDir, 'schema.yaml'))).toBe(true);
});
it('should reject invalid schema names', () => {
// Test kebab-case validation
const isValidSchemaName = (name: string): boolean => {
return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);
};
expect(isValidSchemaName('my-schema')).toBe(true);
expect(isValidSchemaName('my-schema-v2')).toBe(true);
expect(isValidSchemaName('schema123')).toBe(true);
expect(isValidSchemaName('My Schema')).toBe(false);
expect(isValidSchemaName('my_schema')).toBe(false);
expect(isValidSchemaName('MySchema')).toBe(false);
expect(isValidSchemaName('-my-schema')).toBe(false);
expect(isValidSchemaName('123schema')).toBe(false);
});
});
describe('schema init', () => {
it('should create schema directory with schema.yaml', async () => {
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'new-schema');
fs.mkdirSync(schemaDir, { recursive: true });
const { stringify: stringifyYaml } = await import('yaml');
const schema = {
name: 'new-schema',
version: 1,
description: 'A new schema',
artifacts: [
{
id: 'proposal',
generates: 'proposal.md',
description: 'Proposal',
template: 'proposal.md',
requires: [],
},
],
};
fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema));
fs.writeFileSync(path.join(schemaDir, 'proposal.md'), '# Proposal');
// Verify
expect(fs.existsSync(path.join(schemaDir, 'schema.yaml'))).toBe(true);
expect(fs.existsSync(path.join(schemaDir, 'proposal.md'))).toBe(true);
});
it('should validate schema name format', () => {
const isValidSchemaName = (name: string): boolean => {
return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);
};
expect(isValidSchemaName('valid-name')).toBe(true);
expect(isValidSchemaName('Invalid Name')).toBe(false);
});
it('should set up artifact dependencies correctly', async () => {
const { parseSchema } = await import('../../src/core/artifact-graph/schema.js');
// Create schema with standard artifact chain
const content = `name: test-workflow
version: 1
description: Test workflow
artifacts:
- id: proposal
generates: proposal.md
description: Proposal
template: proposal.md
- id: specs
generates: specs/**/*.md
description: Specs
template: specs/spec.md
requires:
- proposal
- id: design
generates: design.md
description: Design
template: design.md
requires:
- specs
- id: tasks
generates: tasks.md
description: Tasks
template: tasks.md
requires:
- design
`;
const schema = parseSchema(content);
expect(schema.artifacts[0].requires).toEqual([]);
expect(schema.artifacts[1].requires).toEqual(['proposal']);
expect(schema.artifacts[2].requires).toEqual(['specs']);
expect(schema.artifacts[3].requires).toEqual(['design']);
});
});
describe('JSON output format', () => {
it('should output valid JSON for schema which', async () => {
const { listSchemas } = await import('../../src/core/artifact-graph/resolver.js');
const schemas = listSchemas(tempDir);
const jsonOutput = JSON.stringify(schemas);
expect(() => JSON.parse(jsonOutput)).not.toThrow();
});
it('should include expected fields in validation JSON', () => {
const validationResult = {
valid: true,
name: 'test-schema',
path: '/path/to/schema',
issues: [],
};
const json = JSON.stringify(validationResult);
const parsed = JSON.parse(json);
expect(parsed).toHaveProperty('valid');
expect(parsed).toHaveProperty('name');
expect(parsed).toHaveProperty('path');
expect(parsed).toHaveProperty('issues');
});
it('should include expected fields in fork JSON', () => {
const forkResult = {
forked: true,
source: 'spec-driven',
sourcePath: '/path/to/source',
sourceLocation: 'package',
destination: 'my-custom',
destinationPath: '/path/to/dest',
};
const json = JSON.stringify(forkResult);
const parsed = JSON.parse(json);
expect(parsed).toHaveProperty('forked');
expect(parsed).toHaveProperty('source');
expect(parsed).toHaveProperty('sourceLocation');
expect(parsed).toHaveProperty('destination');
});
it('should include expected fields in init JSON', () => {
const initResult = {
created: true,
path: '/path/to/schema',
schema: 'new-schema',
artifacts: ['proposal', 'specs'],
setAsDefault: false,
};
const json = JSON.stringify(initResult);
const parsed = JSON.parse(json);
expect(parsed).toHaveProperty('created');
expect(parsed).toHaveProperty('path');
expect(parsed).toHaveProperty('schema');
expect(parsed).toHaveProperty('artifacts');
});
});
});
describe('schema command shell completion registry', () => {
it('should have schema command in registry', async () => {
const { COMMAND_REGISTRY } = await import(
'../../src/core/completions/command-registry.js'
);
const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');
expect(schemaCmd).toBeDefined();
expect(schemaCmd?.description).toBe('Manage workflow schemas');
});
it('should have all schema subcommands in registry', async () => {
const { COMMAND_REGISTRY } = await import(
'../../src/core/completions/command-registry.js'
);
const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');
const subcommandNames = schemaCmd?.subcommands?.map((s) => s.name) ?? [];
expect(subcommandNames).toContain('which');
expect(subcommandNames).toContain('validate');
expect(subcommandNames).toContain('fork');
expect(subcommandNames).toContain('init');
});
it('should have --json flag on all subcommands', async () => {
const { COMMAND_REGISTRY } = await import(
'../../src/core/completions/command-registry.js'
);
const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');
const subcommands = schemaCmd?.subcommands ?? [];
for (const subcmd of subcommands) {
const flagNames = subcmd.flags?.map((f) => f.name) ?? [];
expect(flagNames).toContain('json');
}
});
it('should have --all flag on which subcommand', async () => {
const { COMMAND_REGISTRY } = await import(
'../../src/core/completions/command-registry.js'
);
const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');
const whichCmd = schemaCmd?.subcommands?.find((s) => s.name === 'which');
const flagNames = whichCmd?.flags?.map((f) => f.name) ?? [];
expect(flagNames).toContain('all');
});
it('should have --verbose flag on validate subcommand', async () => {
const { COMMAND_REGISTRY } = await import(
'../../src/core/completions/command-registry.js'
);
const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');
const validateCmd = schemaCmd?.subcommands?.find((s) => s.name === 'validate');
const flagNames = validateCmd?.flags?.map((f) => f.name) ?? [];
expect(flagNames).toContain('verbose');
});
it('should have --force flag on fork and init subcommands', async () => {
const { COMMAND_REGISTRY } = await import(
'../../src/core/completions/command-registry.js'
);
const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');
const forkCmd = schemaCmd?.subcommands?.find((s) => s.name === 'fork');
const initCmd = schemaCmd?.subcommands?.find((s) => s.name === 'init');
expect(forkCmd?.flags?.map((f) => f.name)).toContain('force');
expect(initCmd?.flags?.map((f) => f.name)).toContain('force');
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -196,6 +196,329 @@ describe('instruction-loader', () => {
"Artifact 'nonexistent' not found"
);
});
describe('project config integration', () => {
it('should inject context for all artifacts', () => {
// Create project config
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
Tech stack: TypeScript, React
API style: RESTful
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).toContain('<context>');
expect(instructions.template).toContain('Tech stack: TypeScript, React');
expect(instructions.template).toContain('API style: RESTful');
expect(instructions.template).toContain('</context>');
});
it('should not inject context when config is absent', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).not.toContain('<context>');
// Template field should not include <template> wrapper (CLI handles that)
expect(instructions.template).not.toContain('<template>');
expect(instructions.template).toContain('## Why'); // Actual template content
});
it('should preserve multi-line context', () => {
// Create project config with multi-line context
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
Line 1
Line 2
Line 3
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).toContain('Line 1\nLine 2\nLine 3');
});
it('should preserve special characters in context', () => {
// Create project config with special characters
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
Special: < > & " ' @ # $ % [ ] { }
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).toContain('Special: < > & " \' @ # $ % [ ] { }');
});
it('should inject rules only for matching artifact', () => {
// Create project config with rules
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Include rollback plan
- Identify affected teams
specs:
- Use Given/When/Then format
`
);
const context = loadChangeContext(tempDir, 'my-change');
// Check proposal artifact has its rules
const proposalInstructions = generateInstructions(context, 'proposal', tempDir);
expect(proposalInstructions.template).toContain('<rules>');
expect(proposalInstructions.template).toContain('- Include rollback plan');
expect(proposalInstructions.template).toContain('- Identify affected teams');
expect(proposalInstructions.template).not.toContain('Given/When/Then');
expect(proposalInstructions.template).toContain('</rules>');
// Check specs artifact has its rules
const specsInstructions = generateInstructions(context, 'specs', tempDir);
expect(specsInstructions.template).toContain('<rules>');
expect(specsInstructions.template).toContain('- Use Given/When/Then format');
expect(specsInstructions.template).not.toContain('rollback plan');
expect(specsInstructions.template).toContain('</rules>');
});
it('should not inject rules for non-matching artifact', () => {
// Create project config with rules only for proposal
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Include rollback plan
`
);
const context = loadChangeContext(tempDir, 'my-change');
// Check design artifact (no rules configured) has no rules section
const designInstructions = generateInstructions(context, 'design', tempDir);
expect(designInstructions.template).not.toContain('<rules>');
});
it('should not inject rules when empty array', () => {
// Create project config with empty rules array
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: Some context
rules:
proposal: []
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).toContain('<context>');
expect(instructions.template).not.toContain('<rules>');
});
it('should maintain correct order: context -> rules -> template', () => {
// Create project config with both context and rules
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: Project context here
rules:
proposal:
- Rule 1
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
const contextIdx = instructions.template.indexOf('<context>');
const rulesIdx = instructions.template.indexOf('<rules>');
const templateContentIdx = instructions.template.indexOf('## Why');
expect(contextIdx).toBeGreaterThan(-1);
expect(rulesIdx).toBeGreaterThan(-1);
expect(templateContentIdx).toBeGreaterThan(-1);
expect(contextIdx).toBeLessThan(rulesIdx);
expect(rulesIdx).toBeLessThan(templateContentIdx);
// Template field should not include <template> wrapper (CLI handles that)
expect(instructions.template).not.toContain('<template>');
});
it('should handle context without rules', () => {
// Create project config with only context
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: Project context only
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).toContain('<context>');
expect(instructions.template).not.toContain('<rules>');
// Template field should not include <template> wrapper (CLI handles that)
expect(instructions.template).not.toContain('<template>');
expect(instructions.template).toContain('## Why'); // Actual template content
});
it('should handle rules without context', () => {
// Create project config with only rules
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Rule only
`
);
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal', tempDir);
expect(instructions.template).not.toContain('<context>');
expect(instructions.template).toContain('<rules>');
// Template field should not include <template> wrapper (CLI handles that)
expect(instructions.template).not.toContain('<template>');
expect(instructions.template).toContain('## Why'); // Actual template content
});
it('should work without project root parameter', () => {
const context = loadChangeContext(tempDir, 'my-change');
const instructions = generateInstructions(context, 'proposal'); // No projectRoot
expect(instructions.template).not.toContain('<context>');
// Template field should not include <template> wrapper (CLI handles that)
expect(instructions.template).not.toContain('<template>');
expect(instructions.template).toContain('## Why'); // Actual template content
});
});
describe('validation and warnings', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
consoleWarnSpy.mockRestore();
});
it('should warn about unknown artifact IDs in rules', () => {
// Create project config with invalid artifact ID
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Valid rule
invalid-artifact:
- Invalid rule
`
);
const context = loadChangeContext(tempDir, 'my-change');
generateInstructions(context, 'proposal', tempDir);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Unknown artifact ID in rules: "invalid-artifact"')
);
});
it('should deduplicate validation warnings within session', () => {
// Create a fresh temp directory to avoid cache pollution
const freshTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
try {
// Create project config with a uniquely named invalid artifact ID
const configDir = path.join(freshTempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
unique-invalid-artifact-${Date.now()}:
- Invalid rule
`
);
const context = loadChangeContext(freshTempDir, 'my-change');
// Call multiple times
generateInstructions(context, 'proposal', freshTempDir);
generateInstructions(context, 'specs', freshTempDir);
generateInstructions(context, 'design', freshTempDir);
// Warning should be shown only once (deduplication works)
// Note: We may have gotten warnings from other tests, so check that
// the count didn't increase by more than 1 from the first call
const callCount = consoleWarnSpy.mock.calls.filter(call =>
call[0]?.includes('Unknown artifact ID in rules')
).length;
expect(callCount).toBeGreaterThanOrEqual(1);
} finally {
fs.rmSync(freshTempDir, { recursive: true, force: true });
}
});
it('should not warn for valid artifact IDs', () => {
// Create project config with valid artifact IDs
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Rule 1
specs:
- Rule 2
`
);
const context = loadChangeContext(tempDir, 'my-change');
generateInstructions(context, 'proposal', tempDir);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});
});
describe('formatChangeStatus', () => {
+334
View File
@@ -5,10 +5,12 @@ import * as os from 'node:os';
import {
resolveSchema,
listSchemas,
listSchemasWithInfo,
SchemaLoadError,
getSchemaDir,
getPackageSchemasDir,
getUserSchemasDir,
getProjectSchemasDir,
} from '../../../src/core/artifact-graph/resolver.js';
describe('artifact-graph/resolver', () => {
@@ -324,4 +326,336 @@ version: [[[invalid yaml
expect(schemas).not.toContain('empty-dir');
});
});
// =========================================================================
// Project-local schema tests
// =========================================================================
describe('getProjectSchemasDir', () => {
it('should return correct path', () => {
const projectRoot = '/path/to/project';
const schemasDir = getProjectSchemasDir(projectRoot);
expect(schemasDir).toBe(path.join('/path/to/project', 'openspec', 'schemas'));
});
it('should work with relative-looking paths', () => {
const schemasDir = getProjectSchemasDir('./my-project');
expect(schemasDir).toBe(path.join('my-project', 'openspec', 'schemas'));
});
});
describe('getSchemaDir with projectRoot', () => {
it('should return null for non-existent project schema', () => {
const dir = getSchemaDir('nonexistent-schema', tempDir);
expect(dir).toBeNull();
});
it('should prefer project-local schema over user override', () => {
// Set up user override
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
'name: user-version\nversion: 1\nartifacts: []'
);
// Set up project-local schema
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
'name: project-version\nversion: 2\nartifacts: []'
);
const dir = getSchemaDir('my-schema', projectRoot);
expect(dir).toBe(projectSchemaDir);
});
it('should prefer project-local schema over package built-in', () => {
// Set up project-local schema that overrides built-in
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'spec-driven');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
'name: project-spec-driven\nversion: 99\nartifacts: []\n'
);
const dir = getSchemaDir('spec-driven', projectRoot);
expect(dir).toBe(projectSchemaDir);
});
it('should fall back to user override when no project-local schema', () => {
// Set up user override only
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'user-only-schema');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
'name: user-only\nversion: 1\nartifacts: []'
);
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(projectRoot, { recursive: true });
const dir = getSchemaDir('user-only-schema', projectRoot);
expect(dir).toBe(userSchemaDir);
});
it('should fall back to package built-in when no project or user schema', () => {
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(projectRoot, { recursive: true });
const dir = getSchemaDir('spec-driven', projectRoot);
expect(dir).not.toBeNull();
// Should be package path, not project or user
expect(dir).not.toContain(projectRoot);
});
it('should maintain backward compatibility when projectRoot not provided', () => {
// Set up user override
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
'name: user-version\nversion: 1\nartifacts: []'
);
// Set up project-local schema (should be ignored when projectRoot not provided)
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
'name: project-version\nversion: 2\nartifacts: []'
);
// Without projectRoot, should get user version
const dir = getSchemaDir('my-schema');
expect(dir).toBe(userSchemaDir);
});
});
describe('resolveSchema with projectRoot', () => {
it('should resolve project-local schema', () => {
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
`name: team-workflow
version: 1
description: Team workflow
artifacts:
- id: spec
generates: spec.md
description: Specification
template: spec.md
`
);
const schema = resolveSchema('team-workflow', projectRoot);
expect(schema.name).toBe('team-workflow');
expect(schema.version).toBe(1);
});
it('should prefer project-local over user override when resolving', () => {
// Set up user override
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'shared-schema');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
`name: user-version
version: 1
artifacts:
- id: user-artifact
generates: user.md
description: User artifact
template: user.md
`
);
// Set up project-local schema
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'shared-schema');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
`name: project-version
version: 2
artifacts:
- id: project-artifact
generates: project.md
description: Project artifact
template: project.md
`
);
const schema = resolveSchema('shared-schema', projectRoot);
expect(schema.name).toBe('project-version');
expect(schema.version).toBe(2);
});
});
describe('listSchemas with projectRoot', () => {
it('should include project-local schemas', () => {
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
'name: team-workflow\nversion: 1\nartifacts: []'
);
const schemas = listSchemas(projectRoot);
expect(schemas).toContain('team-workflow');
expect(schemas).toContain('spec-driven'); // built-in still included
});
it('should deduplicate project-local schema that shadows user override', () => {
// Set up user override
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
'name: user\nversion: 1\nartifacts: []'
);
// Set up project-local schema with same name
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
'name: project\nversion: 2\nartifacts: []'
);
const schemas = listSchemas(projectRoot);
const count = schemas.filter(s => s === 'my-schema').length;
expect(count).toBe(1);
});
it('should maintain backward compatibility when projectRoot not provided', () => {
// Set up project-local schema
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'project-only');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
'name: project-only\nversion: 1\nartifacts: []'
);
// Without projectRoot, project-only schema should not appear
const schemas = listSchemas();
expect(schemas).not.toContain('project-only');
});
});
describe('listSchemasWithInfo with projectRoot', () => {
it('should return source: project for project-local schemas', () => {
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
`name: team-workflow
version: 1
description: Team workflow
artifacts:
- id: spec
generates: spec.md
description: Specification
template: spec.md
`
);
const schemas = listSchemasWithInfo(projectRoot);
const teamSchema = schemas.find(s => s.name === 'team-workflow');
expect(teamSchema).toBeDefined();
expect(teamSchema!.source).toBe('project');
});
it('should return source: package for built-in schemas', () => {
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(projectRoot, { recursive: true });
const schemas = listSchemasWithInfo(projectRoot);
const specDriven = schemas.find(s => s.name === 'spec-driven');
expect(specDriven).toBeDefined();
expect(specDriven!.source).toBe('package');
});
it('should return source: user for user override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'user-custom');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
`name: user-custom
version: 1
description: User custom
artifacts:
- id: artifact
generates: artifact.md
description: Artifact
template: artifact.md
`
);
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(projectRoot, { recursive: true });
const schemas = listSchemasWithInfo(projectRoot);
const userSchema = schemas.find(s => s.name === 'user-custom');
expect(userSchema).toBeDefined();
expect(userSchema!.source).toBe('user');
});
it('should show project source when project-local shadows user override', () => {
// Set up user override
process.env.XDG_DATA_HOME = tempDir;
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'shared');
fs.mkdirSync(userSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(userSchemaDir, 'schema.yaml'),
`name: user-shared
version: 1
description: User shared
artifacts:
- id: a
generates: a.md
description: A
template: a.md
`
);
// Set up project-local with same name
const projectRoot = path.join(tempDir, 'project');
const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'shared');
fs.mkdirSync(projectSchemaDir, { recursive: true });
fs.writeFileSync(
path.join(projectSchemaDir, 'schema.yaml'),
`name: project-shared
version: 2
description: Project shared
artifacts:
- id: b
generates: b.md
description: B
template: b.md
`
);
const schemas = listSchemasWithInfo(projectRoot);
const sharedSchema = schemas.find(s => s.name === 'shared');
expect(sharedSchema).toBeDefined();
expect(sharedSchema!.source).toBe('project');
expect(sharedSchema!.description).toBe('Project shared'); // project version wins
});
});
});
@@ -1,6 +1,6 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {PowerShellGenerator} from '../../../../src/core/completions/generators/powershell-generator.js';
import {CommandDefinition} from '../../../../src/core/completions/types.js';
import { describe, it, expect, beforeEach } from 'vitest';
import { PowerShellGenerator } from '../../../../src/core/completions/generators/powershell-generator.js';
import { CommandDefinition } from '../../../../src/core/completions/types.js';
describe('PowerShellGenerator', () => {
let generator: PowerShellGenerator;
@@ -444,6 +444,34 @@ describe('PowerShellGenerator', () => {
expect(script).toContain('Get-OpenSpecSpecs');
});
it('should not emit trailing commas in @() arrays', () => {
const commands: CommandDefinition[] = [
{
name: 'config',
description: 'Manage configuration',
flags: [
{
name: 'scope',
short: 's',
description: 'Configuration scope',
},
],
subcommands: [
{
name: 'get',
description: 'Get a config value',
flags: [],
},
],
},
];
const script = generator.generate(commands);
// PowerShell array literals (@(...)) can't have a trailing comma on the last element.
expect(script).not.toMatch(/\},\s*\r?\n\s*\)/);
});
it('should handle empty command list', () => {
const commands: CommandDefinition[] = [];
+55
View File
@@ -1151,6 +1151,61 @@ describe('InitCommand', () => {
expect(codeBuddyChoice.configured).toBe(true);
});
it('should create Continue slash command files with templates', async () => {
queueSelections('continue', DONE);
await initCommand.execute(testDir);
const continueProposal = path.join(
testDir,
'.continue/prompts/openspec-proposal.prompt'
);
const continueApply = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
const continueArchive = path.join(
testDir,
'.continue/prompts/openspec-archive.prompt'
);
expect(await fileExists(continueProposal)).toBe(true);
expect(await fileExists(continueApply)).toBe(true);
expect(await fileExists(continueArchive)).toBe(true);
const proposalContent = await fs.readFile(continueProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('name: openspec-proposal');
expect(proposalContent).toContain('invokable: true');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
const applyContent = await fs.readFile(continueApply, '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('invokable: true');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(continueArchive, '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('invokable: true');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
it('should mark Continue as already configured during extend mode', async () => {
queueSelections('continue', DONE, 'continue', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const continueChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'continue'
);
expect(continueChoice.configured).toBe(true);
});
it('should create CODEBUDDY.md when CodeBuddy is selected', async () => {
queueSelections('codebuddy', DONE);
+612
View File
@@ -0,0 +1,612 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
readProjectConfig,
validateConfigRules,
suggestSchemas,
} from '../../src/core/project-config.js';
describe('project-config', () => {
let tempDir: string;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-config-'));
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
consoleWarnSpy.mockRestore();
});
describe('readProjectConfig', () => {
describe('resilient parsing', () => {
it('should parse complete valid config', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
Tech stack: TypeScript, React
API style: RESTful
rules:
proposal:
- Include rollback plan
- Identify affected teams
specs:
- Use Given/When/Then format
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
context: 'Tech stack: TypeScript, React\nAPI style: RESTful\n',
rules: {
proposal: ['Include rollback plan', 'Identify affected teams'],
specs: ['Use Given/When/Then format'],
},
});
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should parse minimal config with schema only', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: spec-driven\n');
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
});
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return partial config when schema is invalid', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: ""
context: Valid context here
rules:
proposal:
- Valid rule
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
context: 'Valid context here',
rules: {
proposal: ['Valid rule'],
},
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid 'schema' field")
);
});
it('should return partial config when context is invalid', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: 123
rules:
proposal:
- Valid rule
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
rules: {
proposal: ['Valid rule'],
},
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid 'context' field")
);
});
it('should return partial config when rules is not an object', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: Valid context
rules: ["not", "an", "object"]
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
context: 'Valid context',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid 'rules' field")
);
});
it('should handle rules: null without aborting config parsing', () => {
// YAML `rules:` with no value parses to null
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: Valid context
rules:
`
);
const config = readProjectConfig(tempDir);
// Should still parse schema and context despite null rules
expect(config).toEqual({
schema: 'spec-driven',
context: 'Valid context',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid 'rules' field")
);
});
it('should filter out invalid rules for specific artifact', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Valid rule
specs: "not an array"
design:
- Another valid rule
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
rules: {
proposal: ['Valid rule'],
design: ['Another valid rule'],
},
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Rules for 'specs' must be an array of strings")
);
});
it('should filter out empty string rules', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- Valid rule
- ""
- Another valid rule
- ""
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
rules: {
proposal: ['Valid rule', 'Another valid rule'],
},
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Some rules for 'proposal' are empty strings")
);
});
it('should skip artifact if all rules are empty strings', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- ""
- ""
specs:
- Valid rule
`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({
schema: 'spec-driven',
rules: {
specs: ['Valid rule'],
},
});
});
it('should handle completely invalid YAML gracefully', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: [unclosed');
const config = readProjectConfig(tempDir);
expect(config).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse openspec/config.yaml'),
expect.anything()
);
});
it('should warn when config is not a YAML object', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.yaml'), '"just a string"');
const config = readProjectConfig(tempDir);
expect(config).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('not a valid YAML object')
);
});
it('should handle empty config file', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.yaml'), '');
const config = readProjectConfig(tempDir);
expect(config).toBeNull();
});
});
describe('context size limit enforcement', () => {
it('should accept context under 50KB limit', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
const smallContext = 'a'.repeat(1000); // 1KB
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven\ncontext: "${smallContext}"\n`
);
const config = readProjectConfig(tempDir);
expect(config?.context).toBe(smallContext);
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Context too large')
);
});
it('should reject context over 50KB limit', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
const largeContext = 'a'.repeat(51 * 1024); // 51KB
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven\ncontext: "${largeContext}"\n`
);
const config = readProjectConfig(tempDir);
expect(config).toEqual({ schema: 'spec-driven' });
expect(config?.context).toBeUndefined();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Context too large (51.0KB, limit: 50KB)')
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Ignoring context field')
);
});
it('should handle context exactly at 50KB limit', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
const exactContext = 'a'.repeat(50 * 1024); // Exactly 50KB
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven\ncontext: "${exactContext}"\n`
);
const config = readProjectConfig(tempDir);
expect(config?.context).toBe(exactContext);
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Context too large')
);
});
it('should handle multi-byte UTF-8 characters in size calculation', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
// Unicode snowman is 3 bytes in UTF-8
const contextWithUnicode = '☃'.repeat(18000); // ~54KB in UTF-8 (18000 * 3 bytes)
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
${contextWithUnicode}
`
);
const config = readProjectConfig(tempDir);
expect(config?.context).toBeUndefined();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Context too large')
);
});
});
describe('.yml/.yaml precedence', () => {
it('should prefer .yaml when both exist', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
'schema: spec-driven\ncontext: from yaml\n'
);
fs.writeFileSync(
path.join(configDir, 'config.yml'),
'schema: tdd\ncontext: from yml\n'
);
const config = readProjectConfig(tempDir);
expect(config?.schema).toBe('spec-driven');
expect(config?.context).toBe('from yaml');
});
it('should use .yml when .yaml does not exist', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yml'),
'schema: tdd\ncontext: from yml\n'
);
const config = readProjectConfig(tempDir);
expect(config?.schema).toBe('tdd');
expect(config?.context).toBe('from yml');
});
it('should return null when neither .yaml nor .yml exist', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
const config = readProjectConfig(tempDir);
expect(config).toBeNull();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return null when openspec directory does not exist', () => {
const config = readProjectConfig(tempDir);
expect(config).toBeNull();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});
describe('multi-line and special characters', () => {
it('should preserve multi-line context', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
Line 1: Tech stack
Line 2: API conventions
Line 3: Testing approach
`
);
const config = readProjectConfig(tempDir);
expect(config?.context).toBe(
'Line 1: Tech stack\nLine 2: API conventions\nLine 3: Testing approach\n'
);
});
it('should preserve special YAML characters in context', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
context: |
Special chars: : @ # $ % & * [ ] { }
Quotes: "double" 'single'
Symbols: < > | \\ /
`
);
const config = readProjectConfig(tempDir);
expect(config?.context).toContain('Special chars: : @ # $ % & * [ ] { }');
expect(config?.context).toContain('"double"');
expect(config?.context).toContain("'single'");
expect(config?.context).toContain('Symbols: < > | \\ /');
});
it('should preserve special characters in rule strings', () => {
const configDir = path.join(tempDir, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
`schema: spec-driven
rules:
proposal:
- "Use <template> tags in docs"
- "Reference @mentions and #channels"
- "Follow {variable} naming"
`
);
const config = readProjectConfig(tempDir);
expect(config?.rules?.proposal).toEqual([
'Use <template> tags in docs',
'Reference @mentions and #channels',
'Follow {variable} naming',
]);
});
});
});
describe('validateConfigRules', () => {
it('should return no warnings for valid artifact IDs', () => {
const rules = {
proposal: ['Rule 1'],
specs: ['Rule 2'],
design: ['Rule 3'],
};
const validIds = new Set(['proposal', 'specs', 'design', 'tasks']);
const warnings = validateConfigRules(rules, validIds, 'spec-driven');
expect(warnings).toEqual([]);
});
it('should warn about unknown artifact IDs', () => {
const rules = {
proposal: ['Rule 1'],
testplan: ['Rule 2'], // Invalid
documentation: ['Rule 3'], // Invalid
};
const validIds = new Set(['proposal', 'specs', 'design', 'tasks']);
const warnings = validateConfigRules(rules, validIds, 'spec-driven');
expect(warnings).toHaveLength(2);
expect(warnings[0]).toContain('Unknown artifact ID in rules: "testplan"');
expect(warnings[0]).toContain('Valid IDs for schema "spec-driven": design, proposal, specs, tasks');
expect(warnings[1]).toContain('Unknown artifact ID in rules: "documentation"');
});
it('should return warnings for all unknown artifact IDs', () => {
const rules = {
invalid1: ['Rule 1'],
invalid2: ['Rule 2'],
invalid3: ['Rule 3'],
};
const validIds = new Set(['proposal', 'specs']);
const warnings = validateConfigRules(rules, validIds, 'spec-driven');
expect(warnings).toHaveLength(3);
});
it('should handle empty rules object', () => {
const rules = {};
const validIds = new Set(['proposal', 'specs']);
const warnings = validateConfigRules(rules, validIds, 'spec-driven');
expect(warnings).toEqual([]);
});
});
describe('suggestSchemas', () => {
const availableSchemas = [
{ name: 'spec-driven', isBuiltIn: true },
{ name: 'tdd', isBuiltIn: true },
{ name: 'custom-workflow', isBuiltIn: false },
{ name: 'team-process', isBuiltIn: false },
];
it('should suggest close matches using fuzzy matching', () => {
const message = suggestSchemas('spec-drven', availableSchemas); // Missing 'i'
expect(message).toContain("Schema 'spec-drven' not found");
expect(message).toContain('Did you mean one of these?');
expect(message).toContain('spec-driven (built-in)');
});
it('should suggest tdd for tdd typo', () => {
const message = suggestSchemas('td', availableSchemas);
expect(message).toContain('Did you mean one of these?');
expect(message).toContain('tdd (built-in)');
});
it('should list all available schemas', () => {
const message = suggestSchemas('nonexistent', availableSchemas);
expect(message).toContain('Available schemas:');
expect(message).toContain('Built-in: spec-driven, tdd');
expect(message).toContain('Project-local: custom-workflow, team-process');
});
it('should handle case when no project-local schemas exist', () => {
const builtInOnly = [
{ name: 'spec-driven', isBuiltIn: true },
{ name: 'tdd', isBuiltIn: true },
];
const message = suggestSchemas('invalid', builtInOnly);
expect(message).toContain('Built-in: spec-driven, tdd');
expect(message).toContain('Project-local: (none found)');
});
it('should include fix instruction', () => {
const message = suggestSchemas('wrong-schema', availableSchemas);
expect(message).toContain(
"Fix: Edit openspec/config.yaml and change 'schema: wrong-schema' to a valid schema name"
);
});
it('should limit suggestions to top 3 matches', () => {
const manySchemas = [
{ name: 'test-a', isBuiltIn: true },
{ name: 'test-b', isBuiltIn: true },
{ name: 'test-c', isBuiltIn: true },
{ name: 'test-d', isBuiltIn: true },
{ name: 'test-e', isBuiltIn: true },
];
const message = suggestSchemas('test', manySchemas);
// Should suggest at most 3
const suggestionCount = (message.match(/test-/g) || []).length;
expect(suggestionCount).toBeGreaterThanOrEqual(3);
expect(suggestionCount).toBeLessThanOrEqual(3 + 5); // 3 in suggestions + 5 in "Available" list
});
it('should not suggest schemas with distance > 3', () => {
const message = suggestSchemas('abcdefghijk', availableSchemas);
// 'abcdefghijk' has large Levenshtein distance from all schemas
expect(message).not.toContain('Did you mean');
expect(message).toContain('Available schemas:');
});
});
});
+83 -9
View File
@@ -133,7 +133,7 @@ Old slash content
expect(updated).toContain('name: OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old slash content');
@@ -317,7 +317,7 @@ Old slash content
expect(updated).toContain('# OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old slash content');
@@ -368,6 +368,80 @@ Old body
consoleSpy.mockRestore();
});
it('should refresh existing Continue prompt files', async () => {
const continuePath = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
await fs.mkdir(path.dirname(continuePath), { recursive: true });
const initialContent = `---
name: openspec-apply
description: Old description
invokable: true
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(continuePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(continuePath, 'utf-8');
expect(updated).toContain('name: openspec-apply');
expect(updated).toContain('invokable: true');
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: .continue/prompts/openspec-apply.prompt'
);
consoleSpy.mockRestore();
});
it('should not create missing Continue prompt files on update', async () => {
const continueApply = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(continueApply), { recursive: true });
await fs.writeFile(
continueApply,
`---
name: openspec-apply
description: Old description
invokable: true
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const continueProposal = path.join(
testDir,
'.continue/prompts/openspec-proposal.prompt'
);
const continueArchive = path.join(
testDir,
'.continue/prompts/openspec-archive.prompt'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(continueProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(continueArchive)).resolves.toBe(false);
});
it('should refresh existing OpenCode slash command files', async () => {
const openCodePath = path.join(
testDir,
@@ -933,7 +1007,7 @@ Old slash content
expect(updated).toContain('name: OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old slash content');
@@ -1011,7 +1085,7 @@ Old slash content
expect(updated).toContain('name: OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old slash content');
@@ -1089,7 +1163,7 @@ Old body
expect(updated).toContain('argument-hint: old-hint');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old body');
@@ -1130,7 +1204,7 @@ Old slash content
expect(updated).toContain('name: OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old slash content');
@@ -1170,7 +1244,7 @@ Old body
expect(updated).toContain('# OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
'Validate with `openspec validate <id> --strict --no-interactive`'
);
expect(updated).not.toContain('Old body');
@@ -1357,7 +1431,7 @@ More instructions after.`;
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`');
expect(updated).toContain('Validate with `openspec validate <id> --strict --no-interactive`');
});
it('should handle configurator errors gracefully for CoStrict', async () => {
@@ -1413,7 +1487,7 @@ More instructions after.`;
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`');
expect(updated).toContain('Validate with `openspec validate <id> --strict --no-interactive`');
});
it('should not create missing Windsurf workflows on update', async () => {
+7 -2
View File
@@ -14,20 +14,25 @@ import {
describe('telemetry/config', () => {
let tempDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
// Create temp directory for tests
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Mock HOME to point to temp dir
// Mock HOME/USERPROFILE to point to temp dir
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;
});
afterEach(() => {
// Restore HOME
// Restore HOME/USERPROFILE
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
+77
View File
@@ -209,6 +209,83 @@ describe('resolveSchemaForChange', () => {
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
it('should use project config schema when no metadata exists', async () => {
// Create project config
const configDir = path.join(testDir, 'openspec');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'utf-8'
);
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('tdd');
});
it('should prefer change metadata over project config', async () => {
// Create project config
const configDir = path.join(testDir, 'openspec');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'utf-8'
);
// Create change metadata with different schema
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven'); // Change metadata wins
});
it('should prefer explicit schema over all config sources', async () => {
// Create project config
const configDir = path.join(testDir, 'openspec');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'utf-8'
);
// Create change metadata
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
// Explicit schema should win
const result = resolveSchemaForChange(changeDir, 'tdd');
expect(result).toBe('tdd');
});
it('should test full precedence order: CLI > metadata > config > default', async () => {
// Setup all levels
const configDir = path.join(testDir, 'openspec');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'config.yaml'),
'schema: tdd\n',
'utf-8'
);
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
// Test each level
expect(resolveSchemaForChange(changeDir, 'tdd')).toBe('tdd'); // CLI wins
expect(resolveSchemaForChange(changeDir)).toBe('spec-driven'); // Metadata wins when no CLI
// Remove metadata, config should win
await fs.unlink(metaPath);
expect(resolveSchemaForChange(changeDir)).toBe('tdd'); // Config wins
// Remove config, default should win
await fs.unlink(path.join(configDir, 'config.yaml'));
expect(resolveSchemaForChange(changeDir)).toBe('spec-driven'); // Default wins
});
});
describe('validateSchemaName', () => {
+23 -2
View File
@@ -1,12 +1,33 @@
import { defineConfig } from 'vitest/config';
import os from 'node:os';
function resolveMaxWorkers(): number | undefined {
// Allow callers (CI/agents) to override without editing config.
const raw = process.env.VITEST_MAX_WORKERS;
if (raw) {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
// Vitest v3 defaults to `pool: "forks"` and scales worker processes with CPU.
// This repo's tests can spawn many Node processes (CLI invocations, temp FS),
// so cap parallelism to avoid runaway CPU/memory usage in automation.
const cpuCount = typeof os.availableParallelism === 'function'
? os.availableParallelism()
: os.cpus().length;
return Math.min(4, Math.max(1, cpuCount));
}
export default defineConfig({
test: {
globals: true,
environment: 'node',
globalSetup: './vitest.setup.ts',
// Keep default pool settings; some tests rely on process.chdir,
// which is not supported in worker threads
// Tests rely on per-file process isolation (e.g., `process.cwd()` assumptions).
pool: 'forks',
maxWorkers: resolveMaxWorkers(),
include: ['test/**/*.test.ts'],
coverage: {
reporter: ['text', 'json', 'html'],