Compare commits

..
Author SHA1 Message Date
TabishB 3a88186909 test: align path assertions with canonical helper 2026-04-14 17:53:30 +10:00
TabishB 493605756f fix: prefer native realpath for canonical paths 2026-04-14 17:31:35 +10:00
Tabish Bidiwale 7d07101363 fix: canonicalize workflow artifact paths (#971) 2026-04-14 07:14:11 +00:00
Alfred c0f29044f9 docs: clarify initiative-first workspace model (#969)
* docs: split workspace initiatives from repo-local changes

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

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

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

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

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

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

Closes #895

* fix: clear CI env var in telemetry fetch tests

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

* docs: add telemetry env vars to Environment Variables table

Addresses CodeRabbit review comment.

* docs: remove unnecessary firewall telemetry warnings

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

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

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

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

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

* feat: add Bob Shell adapter support

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

* docs: add Bob Shell to supported tools documentation

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

* docs: add Bob Shell support proposal and design documentation

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

* chore: update dependencies and gitignore

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

* chore: update gitignore to exclude .bob directory

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

* Update reference from Bob Shell to IBM Bob Shell

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

* test: update template parity hashes for formatting changes

---------

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

Co-authored-by: Marck <6992917@qq.com>
2026-04-09 06:25:27 +00:00
61 changed files with 4795 additions and 272 deletions
@@ -1,5 +0,0 @@
---
"@fission-ai/openspec": patch
---
fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.
-5
View File
@@ -1,5 +0,0 @@
---
"@fission-ai/openspec": patch
---
fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.
+3
View File
@@ -156,3 +156,6 @@ opencode.json
# Codex
.codex/
# Bob
.bob/
+23
View File
@@ -1,5 +1,28 @@
# @fission-ai/openspec
## 1.3.0
### Minor Changes
- [#952](https://github.com/Fission-AI/OpenSpec/pull/952) [`cce787e`](https://github.com/Fission-AI/OpenSpec/commit/cce787ec4083da2b27781f6786f5ce0002909a7b) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
- **Junie support** — Added tool and command generation for JetBrains Junie
- **Lingma IDE support** — Added configuration support for Lingma IDE
- **ForgeCode support** — Added tool support for ForgeCode
- **IBM Bob support** — Added support for IBM Bob coding assistant
### Bug Fixes
- **Shell completions opt-in** — Completion install is now opt-in, fixing PowerShell encoding corruption
- **Copilot auto-detection** — Prevented false GitHub Copilot detection from a bare `.github/` directory
- **pi.dev command generation** — Fixed command reference transforms and template argument passing
### Patch Changes
- [#760](https://github.com/Fission-AI/OpenSpec/pull/760) [`61eb999`](https://github.com/Fission-AI/OpenSpec/commit/61eb999f7c6c0fc98d2e7f3678756fce6a3f4378) Thanks [@fsilvaortiz](https://github.com/fsilvaortiz)! - fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.
- [#759](https://github.com/Fission-AI/OpenSpec/pull/759) [`afdca0d`](https://github.com/Fission-AI/OpenSpec/commit/afdca0d5dab1aa109cfd8848b2512333ccad60c3) Thanks [@fsilvaortiz](https://github.com/fsilvaortiz)! - fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.
## 1.2.0
### Minor Changes
+1 -1
View File
@@ -103,7 +103,7 @@ Now tell your AI: `/opsx:propose <what-you-want-to-build>`
If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
> [!NOTE]
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 20+ tools and growing.
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 25+ tools and growing.
>
> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
+2
View File
@@ -920,6 +920,8 @@ openspec completion uninstall
| Variable | Description |
|----------|-------------|
| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry |
| `DO_NOT_TRACK` | Set to `1` to disable telemetry (standard DNT signal) |
| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |
| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |
| `NO_COLOR` | Disable color output when set |
+3 -1
View File
@@ -24,6 +24,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b
| Amazon Q Developer (`amazon-q`) | `.amazonq/skills/openspec-*/SKILL.md` | `.amazonq/prompts/opsx-<id>.md` |
| Antigravity (`antigravity`) | `.agent/skills/openspec-*/SKILL.md` | `.agent/workflows/opsx-<id>.md` |
| Auggie (`auggie`) | `.augment/skills/openspec-*/SKILL.md` | `.augment/commands/opsx-<id>.md` |
| IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-<id>.md` |
| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |
| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.md` |
| CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/<id>.md` |
@@ -37,6 +38,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
@@ -69,7 +71,7 @@ openspec init --tools none
openspec init --profile core
```
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `forgecode`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `forgecode`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
## Workflow-Dependent Installation
+15 -2
View File
@@ -6,6 +6,8 @@ The explore workflow is part of the core loop (`propose`, `explore`, `apply`, `a
Currently, explore references `/opsx:new` and `/opsx:ff` which are being replaced with `/opsx:propose`. But beyond just updating references, there are deeper UX questions about how explore should work.
This exploration is also affected by the emerging workspace direction: for larger cross-team or cross-repo work, OpenSpec may need to treat the **initiative** as the first-class planning object and repo-local changes as execution artifacts. That means explore may sometimes be seeding an initiative, not just a single change.
## Open Questions
### Exploration Artifacts
@@ -17,6 +19,7 @@ Currently, explore references `/opsx:new` and `/opsx:ff` which are being replace
2. **Where should exploration files live?**
- `openspec/explorations/<name>.md`?
- `openspec/changes/<change>/explorations/`?
- `.openspec-workspace/initiatives/<initiative>/explorations/` for coordinated work?
- Somewhere else?
3. **What should the format be?**
@@ -30,15 +33,17 @@ Currently, explore references `/opsx:new` and `/opsx:ff` which are being replace
- e.g., exploring auth approaches separately from UI approaches
- How would these relate to each other?
5. **How do explorations relate to changes?**
5. **How do explorations relate to changes or initiatives?**
- Before change exists: standalone exploration
- After change exists: exploration linked to change?
- After repo-local change exists: exploration linked to change?
- For coordinated work: exploration linked to initiative first, then optionally referenced by repo-local changes?
### Lifecycle & Transitions
6. **What happens before a change proposal exists?**
- Exploration is standalone
- When ready, user runs `/opsx:propose`
- For coordinated work, should exploration context seed an initiative first?
- Should exploration context automatically seed the proposal?
7. **What happens after a change proposal exists?**
@@ -90,11 +95,19 @@ Currently, explore references `/opsx:new` and `/opsx:ff` which are being replace
- **Pro:** Clear relationship to changes
- **Con:** Where do pre-change explorations go?
### Approach E: Initiative-First Explorations for Coordinated Work
- Local work can stay standalone or change-linked
- Coordinated work saves exploration notes under an initiative in the coordination workspace
- Repo-local changes can reference the shared exploration when execution starts
- **Pro:** Matches the emerging split between shared planning and repo-local execution
- **Con:** Adds another context where exploration artifacts may live
## Next Steps
- [ ] User research: How do people actually use explore today?
- [ ] Prototype: Try saving explorations and see if propose benefits
- [ ] Decide: Pick an approach based on findings
- [ ] Reconcile explore UX with initiative-first coordinated planning
- [ ] Implement: Update explore workflow accordingly
## Related
+181 -5
View File
@@ -645,23 +645,199 @@ To avoid losing this in exploration notes, codify it in:
---
## Part 10: Design Decisions (April 2026)
After evaluating the models above against real multi-repo use cases (see [#725](https://github.com/Fission-AI/OpenSpec/issues/725)), we converged on the following design direction.
### Core Insight
The workspace itself is not the durable thing. For large teams, the durable planning object is the **initiative** or **plan**, while repo-local specs and changes remain the execution artifacts owned by each repo. The set of repos involved in a feature is typically feature-scoped and changes over time, so a static workspace manifest that must be configured before work begins creates ceremony that doesn't match how teams actually work.
### Decision: Model D with Lazy Workspace
Choose Model D (Hybrid) from Part 4, but make the workspace manifest **optional and lazy, not prerequisite**.
- **Each repo keeps its own canonical `openspec/`** — no change to the fundamental storage model.
- **Cross-root work can be coordinated through an initiative in a coordination workspace** — this is where shared planning lives when the work stops being cleanly repo-scoped.
- **"Workspace" is a derived or explicit coordination view** over linked repos and linked changes, not something users must register up front.
- **Persist a workspace manifest only when someone explicitly wants a reusable cross-repo bundle** — this is an opt-in convenience, not a requirement.
### Decision: Initiative-First Planning with Linked Repo-Local Changes
For larger multi-team work, repo-centric planning is the wrong primary abstraction. Teams and repos are many-to-many facets over the same work. OpenSpec should treat the **initiative / plan** as the first-class planning object, then link repo-local changes to it.
This is especially important because a change today bundles:
- `proposal.md`
- `design.md`
- `tasks.md`
- delta specs
- `.openspec.yaml`
That bundled shape works well for repo-local work, but becomes awkward when one piece of work spans multiple repos or teams. In those cases, a single repo-local change is trying to act as both:
- the shared planning object
- the repo-specific execution artifact
Those should be split.
The preferred model is:
```text
coordination workspace /
.openspec-workspace/
workspace.yaml
initiatives/
add-3ds/
initiative.yaml
proposal.md
design.md
links.yaml
repo-A/
openspec/
changes/
add-3ds-api/
.openspec.yaml
tasks.md
specs/
repo-B/
openspec/
changes/
add-3ds-web/
.openspec.yaml
tasks.md
specs/
```
The initiative holds the shared planning layer:
- proposal / intent
- shared design and tradeoffs
- participating teams
- impacted repos
- milestones, risks, and dependencies
- links to repo-local changes
Each repo-local change holds the execution layer for that repo:
- repo-specific tasks
- delta specs
- local implementation status
- optional local notes that should archive with that repo's work
Cross-repo linking still matters, but it should hang off the initiative and the repo-local changes:
```yaml
# billing-service/openspec/changes/add-3ds/.openspec.yaml
schema: spec-driven
created: 2026-04-12
initiative: add-3ds
links:
- project: github.com/fission/web-client
change: add-3ds-checkout
- project: github.com/fission/ios-client
change: add-3ds-checkout
```
Each repo still holds its own change with its own deltas. A cross-repo effort is represented as one initiative plus N linked single-repo changes. This is preferable to a single mega-change because:
- Shared planning has one truthful home
- Each repo's change goes through its own archive cycle
- No need to resolve cross-repo file paths in delta specs
- Teams can move at different speeds (web ships before iOS)
For small single-repo work, a repo-local change may still be "good enough" as both plan and execution bundle. The initiative-first split matters once work becomes cross-team, cross-module, cross-repo, or otherwise coordination-heavy.
### Decision: Stable Project Identifiers, Not Paths
Cross-repo links must use **stable project identifiers**, not filesystem paths.
- **Canonical form:** A normalized `host/org/repo` tuple (e.g., `github.com/fission/web-client`).
- **Authoring shorthand:** The CLI accepts `org/repo` (e.g., `fission/web-client`) and infers the host from the current repo's remote.
- **Relative paths are never the durable identifier.** They may exist only as cached local resolution results.
### Decision: Offline-First Resolution
The CLI resolves project identifiers to local paths using an offline-first chain:
1. **Explicit paths** passed for the current run (e.g., CLI flags, ad-hoc multi-root).
2. **Local OpenSpec repo registry** — a persistent mapping in `~/.config/openspec/` or `~/.local/share/openspec/` (see `src/core/global-config.ts`).
3. **Parent directory scanning** — scan known parent directories for git checkouts whose remotes match the target identifier.
4. **Unresolved** — if no local path is found, leave the target unresolved and continue with a partial workspace. The CLI must not fail.
The registry is populated progressively: when the CLI discovers a clone (via scanning or user prompt), it persists the mapping for future resolution. The registry also stores "known scan roots" (e.g., `~/work/`) so scanning improves over time without upfront configuration.
### Decision: Informational References Only (v1)
Spec-level cross-repo references are **documentation-only pointers**:
```yaml
# web-client/openspec/specs/checkout/spec.md frontmatter
references:
- project: github.com/fission/contracts-service
spec: checkout-contract
```
- The CLI does **not** fail validation because a referenced cross-repo spec is missing or unresolved.
- The CLI **does** surface references to humans and agents when planning, viewing, or applying changes.
- Stronger guarantees (e.g., staleness warnings, cross-repo validation) are an opt-in layer added later — via `lint`, `doctor`, or a feature flag — not baseline behavior.
This avoids accidentally committing OpenSpec to a full dependency graph system before the use cases justify it.
### Decision: Explicit Owner Repo for Shared Contracts
When a spec cannot be mapped to a single implementation repo (e.g., a shared API contract):
- **One repo must be the explicit owner.** This can be a dedicated "contracts" repo, or whichever repo is the natural source of truth.
- **Other repos reference the owning repo's spec** via informational references (see above).
- **There is no default "pure spec repo" pattern.** Separating spec ownership from code ownership too aggressively makes agent execution awkward and diffuses responsibility.
### Monorepo vs. Multi-Repo Summary
| Concern | Monorepo | Multi-Repo |
|---------|----------|------------|
| **Spec organization** | Nested specs inside one `openspec/` (Model B) | Each repo has its own `openspec/` |
| **Cross-cutting specs** | Nested under a `contracts/` or `shared/` directory | Dedicated owner repo, others reference it |
| **Planning object** | Initiative optional for simple work, useful for large cross-team efforts | Initiative is the primary coordination object |
| **Changes** | One or more repo-local changes can implement one initiative | Linked per-repo changes implement one initiative |
| **Relationships** | References (no inheritance in v1) | Project identifier links, informational only |
| **Workspace** | Usually not needed, but can host initiative planning for complex work | Coordination workspace hosts initiative planning; optional manifest for reuse |
### Implementation Path
1. **Define initiative artifacts** — add an initiative format for shared planning in coordination workspaces.
2. **Extend change metadata** — let repo-local changes point at an initiative and linked sibling changes.
3. **Extend spec metadata** — add `references` field for cross-repo spec pointers.
4. **Build project resolution** — implement the offline-first resolution chain and local registry.
5. **Build initiative and link views** — commands that resolve and display the initiative graph plus linked repo-local changes.
6. **Support ad-hoc multi-root** — "add these dirs for this run" or "derive roots from this initiative's links."
7. **Optional workspace manifest** — add saved workspaces only if teams demonstrate reuse patterns.
Nested specs (Model B inside a single repo) are a prerequisite for clean monorepo support and should be tackled first, as outlined in #662.
---
## Summary
| Question | Status | Notes |
|----------|--------|-------|
| Profile UX | Decided | `openspec config profile` with presets |
| Config layering | Decided | Two layers: global + project (no workspace layer) |
| Spec organization | Open | Four models under consideration (including hybrid Model D) |
| Spec organization | **Direction set** | Nested specs per repo, explicit owner repos for shared contracts, references for cross-repo context |
| Spec philosophy | Direction set | Behavior-first contracts, progressive rigor, and agent-aligned authoring |
| Spec inheritance | Open | Inheritance vs references vs none |
| Multi-repo support | Open | Workspace concept TBD |
| Dependency tracking | Open | Probably out of scope initially |
| Spec inheritance | **Decided** | References only, no inheritance in v1 |
| Initiative / planning model | **Direction set** | Initiative-first planning for larger work, with repo-local changes as execution artifacts |
| Multi-repo support | **Direction set** | Linked per-repo changes under shared initiatives; workspace is coordination, not canonical execution storage |
| Dependency tracking | **Decided** | Out of scope for v1; references are informational only |
| Cross-repo resolution | **Decided** | Offline-first resolution chain with local registry |
| Shared contracts | **Decided** | Explicit owner repo required; no default pure-spec-repo pattern |
### Key Insight
The "workspace" question is really two separate questions:
1. **Config/profile scope** → Solved with global + project (no workspace needed)
2. **Spec/change organization** → Unsolved, needs deeper design work
2. **Plan vs. execution organization** → Direction set: initiatives coordinate, repo-local changes implement, workspace remains a coordination layer
These should be separate changes with separate explorations.
+367
View File
@@ -0,0 +1,367 @@
# Workspace Roadmap
## Purpose
This document proposes a lightweight roadmap for workspace, monorepo, and multi-repo support in OpenSpec.
It assumes:
- single-repo is the current default experience
- monorepo pain is already real
- multi-repo coordination is not hypothetical
- large engineering organizations already need this
This roadmap is intentionally staged.
The goal is not to build the full conceptual system at once.
The goal is to ship the smallest credible version of cross-boundary support while preserving a path to a stronger long-term model.
---
## Product Principle
> Prefer the smallest feature set that solves real cross-boundary work without blocking the likely long-term direction.
This means:
- do not overbuild governance before usage proves it
- do not underbuild coordination if real teams already need it
- do not add complexity to the single-repo path unless it clearly pays for itself
---
## What We Believe Now
Based on the current exploration work, several things look increasingly clear.
### 1. Nested spec organization is needed
OpenSpec needs a better way to organize:
- shared contracts
- local implementation specs
- multi-area behavior inside one root
### 2. Informational references are low-risk and useful
References help agents and humans navigate related specs without requiring OpenSpec to build a dependency graph system on day one.
### 3. Initiatives plus linked per-repo changes are the right primitive
For true multi-repo work, the likely durable primitive is:
- one initiative as the shared planning object
- one linked change per owning repo as the execution artifact
- stable identifiers connecting them
### 4. Cross-repo work needs a neutral planning location
For multi-repo work, a single repo is not an honest home for the whole planning artifact.
Some form of coordination workspace or coordination repo is needed for the initiative-level plan.
### 5. Team-shared coordination is a real requirement
This is not just a solo-user thought experiment.
Real teams and large engineering orgs already need a way to coordinate multi-repo work.
### 6. The risk is shipping too much at once
Even though the need is real, the full model has many moving parts:
- nested spec paths
- shared contracts
- linked changes
- cross-root planning
- partial repo resolution
- agent capability differences
- team-shared coordination state
The roadmap should sequence these carefully.
---
## Phase 1: Better Structure Inside One Root
### Goal
Reduce pain in single-repo and monorepo setups without introducing coordination machinery yet.
### Ship
1. Nested spec paths within one `openspec/` root
2. Informational `references` in specs
3. Better filtering of relevant spec paths during planning
4. Better handling of multi-area changes inside one root
### User value
- monorepo users can organize shared and local specs more naturally
- large roots become less noisy
- shared contracts inside one root become easier to model
### Do not ship yet
- coordination workspaces
- linked multi-repo changes
- team-shared coordination repos
- sponsor/owner workflow machinery
### Success criteria
- users can model large monorepos without flattening everything at the top level
- users can represent shared contracts inside one root
- planning context gets smaller and more relevant
---
## Phase 2: Thin Cross-Repo Coordination
### Goal
Support real multi-repo planning demand with the thinnest credible coordination layer.
### Ship
1. Initiative artifacts for shared planning in a neutral coordination workspace or coordination repo
2. Linked per-repo changes using stable project identifiers
3. Explicit repo linking via project IDs
4. Resolution through:
- explicit input
- git remote matching
5. Partial-resolution support
6. Basic agent handoff instructions for coordinated planning
### User value
- users have an honest place to stand for multi-repo work
- cross-repo plans are no longer buried in one repo
- shared planning and repo-local execution are clearly separated
- ownership stays with the real repos
- agents can be told what roots matter
### Key constraints
This phase should remain thin.
Avoid:
- dependency validation across repos
- rich governance flows
- too many new abstractions in the CLI
- heavyweight local/shared state semantics
### v1 shape
This phase should feel like:
- local planning by default
- upgrade to a coordinated initiative when needed
- initiative-level planning in the coordination workspace
- linked repo-local changes underneath
Not like:
- a whole second product mode with many admin concepts
### Success criteria
- teams can coordinate multi-repo changes without inventing ad hoc spreadsheets or naming conventions
- users understand where planning lives and where implementation lives
- agents can plan across roots in a way that is operationally usable
---
## Phase 3: Team-Shared Coordination Hardening
### Goal
Make coordinated planning work cleanly across teammates and teams.
### Ship
1. Shared coordination repo/workspace support as a first-class pattern
2. Clear split between:
- committed shared initiative state
- local machine-specific path resolution
3. Lightweight relinking / repair flows
4. Better onboarding for teammates joining an initiative
5. Better agent instruction generation for shared workspaces
### User value
- teams can share a stable cross-repo initiative
- each teammate can map project IDs to their own local clones
- new participants can join without reverse-engineering how the initiative is set up
### Important constraint
The local side of this model should stay as thin as possible.
The ideal local layer is:
- regenerable
- non-authoritative
- not semantically important beyond path resolution
### Success criteria
- team-shared coordination works without local path leakage into committed state
- joining an initiative feels lightweight
- maintenance cost stays acceptable
---
## Phase 4: Shared Contract and Governance Maturity
### Goal
Support organizations that need stronger contract ownership and more formal cross-boundary planning.
### Ship only if demand justifies it
1. Guided shared contract ownership flows
2. Promotion of initiative-only draft behavior into canonical shared contracts
3. Stronger role visibility:
- canonical shared contract owner
- initiative sponsor/driver
4. Optional linting or policy checks
5. Optional validation around missing owners or unresolved references
### User value
- larger orgs can create durable shared contracts cleanly
- governance becomes explicit where needed
- cross-team ownership becomes easier to understand
### Important constraint
This should not become mandatory for normal users.
These features should remain:
- opt-in
- advanced
- proportional to org complexity
### Success criteria
- shared-contract workflows solve real org-scale problems without making normal planning feel bureaucratic
---
## What Should Not Be Delayed
Because demand is already real, some things should not be treated as purely future work.
### Should happen soon
- nested spec paths
- references
- initiative artifact + linked change primitive
- stable project identifiers
- thin coordination layer for multi-repo planning
### Can wait
- rich ownership workflows
- strong dependency semantics
- broad policy and governance features
- too much agent-specific machinery
---
## UX Guardrails Across All Phases
No matter the phase, the UX should follow these rules.
### 1. Default local
Users should start where they already are.
### 2. Escalate only when necessary
Coordinated planning should appear as an upgrade path, not the default mode.
### 3. Keep advanced concepts mostly implicit
Only expose concepts like shared owners, sponsor roles, overlays, and manifests when the user truly needs to decide something.
### 4. Canonical storage follows ownership
Specs and repo-local changes stay with the owning root.
### 5. Shared coordination is not canonical spec storage
Coordination data helps planning, but does not replace the source of truth.
### 6. Hidden local state must stay thin
If OpenSpec uses local path caches or machine-specific mappings, they should be:
- repairable
- replaceable
- non-authoritative
---
## Likely Deliverable Sequence
If this roadmap were translated into actual change proposals, the sequence would likely be:
1. nested spec paths + references
2. monorepo scope filtering and multi-area planning improvements
3. initiative artifact for shared planning
4. linked change metadata across repos
5. thin coordination workspace / repo for multi-repo planning
6. team-shared coordination hardening
7. optional shared contract maturity features
---
## Open Risks
### 1. Coordination may still be too heavy in v1
Even a thin coordination layer may feel like too much if the handoff is clumsy.
### 2. Hidden local state may become more important than intended
If path resolution or local repo linking becomes semantically important, the system will become harder to trust and debug.
### 3. Monorepo and multi-repo may diverge unintentionally
The product should resist evolving two completely separate mental models.
### 4. Agent capability differences may distort the design
The UX should not assume every coding agent handles multi-root planning equally well.
### 5. Team-scale needs may pressure early governance
Large orgs may quickly ask for ownership, permissions, and review structures. That should not force all users into heavyweight flows.
---
## Summary
The roadmap should not be:
- "wait on multi-repo until later"
Because the demand is already real.
It also should not be:
- "build the full workspace model now"
Because the complexity surface is too large.
The right roadmap is:
1. improve structure inside one root
2. ship a thin but real coordination layer for multi-repo work
3. harden team-shared coordination
4. add more formal shared contract and governance support only as justified
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,491 @@
# Workspace UX Simplification
## Purpose
This document focuses on one UX goal:
> OpenSpec should have one default path, one escalation path, and fewer explicit concepts shown to the user unless the system actually needs a decision from them.
This is a follow-up to `workspace-user-journeys.md`. That document is useful for completeness, but it exposes too much of the conceptual model too early.
This document is about how the product should **feel**.
---
## The UX Problem
The current user-journey exploration is coherent, but it is too heavy at first contact.
The main issues are:
1. Too many concepts appear before the user has done anything:
- scope
- project
- owning root
- shared contract owner
- coordination workspace
- initiative sponsor
- shared manifest vs local overlay
2. Cross-root work feels like a workflow restart:
- user starts in one repo
- OpenSpec says this is multi-repo
- user creates a workspace
- user reopens the agent there
- user effectively starts again
3. Shared contract decisions are asked too explicitly and too early.
4. Team-scale coordination is conceptually right, but reads more like infra setup than a lightweight workflow.
The system is internally clean, but the product experience should be more progressive.
---
## Design Goal
The user should feel:
- "I just start where I am"
- "OpenSpec figures out whether this stays local or needs to expand"
- "If it expands, it carries me forward instead of making me restart"
- "I only see advanced concepts when OpenSpec needs a real decision from me"
---
## The Core UX Shape
### One default path
The default path should always be:
1. Enter a repo or monorepo root
2. Run `/opsx:explore` or `/opsx:propose`
3. OpenSpec plans locally unless it has a strong reason not to
This should work for:
- single repo
- normal monorepo work
- many users in many situations
The default assumption should be:
> This is a local change until proven otherwise.
### One escalation path
The only escalation path should be:
> This work spans multiple owned areas strongly enough that OpenSpec needs to upgrade it into a coordinated initiative.
That escalation may happen for:
- large monorepo cross-team work
- true multi-repo work
- creation of a shared cross-boundary contract
The important UX point is that these should all feel like the same escalation:
- "OpenSpec is upgrading this into a coordinated initiative"
Not:
- one flow for multi-repo
- another flow for large monorepos
- another flow for shared contracts
---
## Progressive Disclosure
Users should not have to understand the full data model up front.
### Concepts users should see by default
At the start, users should mostly see:
- change
- affected area
- maybe repo if relevant
That is enough for the first planning step.
### Concepts OpenSpec should keep implicit until needed
These should usually stay hidden until escalation:
- scope
- coordination workspace
- initiative
- shared contract owner
- sponsor/driver
- manifest vs local overlay
### Concepts OpenSpec should only show when a real decision is needed
Show these only at the point of action:
- "This spans multiple repos. Create a coordinated initiative?"
- "This looks like shared behavior. Where should the canonical contract live?"
- "This initiative is team-shared. Do you want to commit it in a shared coordination repo?"
The system should not front-load these concepts as theory.
---
## The Simplest User Story
This is the baseline story the UX should optimize for.
### Story
The user is in a repo and types:
```text
/opsx:propose add-3ds
```
OpenSpec should:
1. inspect local context
2. infer likely affected areas
3. ask for confirmation only if needed
4. continue immediately
The user should feel like they are doing one thing:
```text
I am proposing a change.
```
Not:
```text
I am selecting between multiple planning abstractions.
```
---
## The Escalation Story
If OpenSpec realizes the work is no longer local, it should escalate in one motion.
### Desired feel
```text
This change affects multiple owned areas.
I can upgrade it into a coordinated initiative and carry your current planning context forward.
```
That wording matters.
It should feel like:
- an upgrade
- a continuation
- a convenience
It should not feel like:
- an error
- a hard stop
- a separate setup workflow
### What should happen during escalation
If escalation is needed, OpenSpec should do as much as possible automatically:
1. carry forward the current change name / description
2. preserve the already inferred affected areas
3. create the coordination artifact
4. resolve any local roots it can
5. generate agent instructions
6. then tell the user the next step
### Example escalation UX
```text
This work spans multiple owned areas:
- contracts
- billing-service
- web-client
- ios-client
OpenSpec can upgrade this into a coordinated initiative.
Suggested next step:
- create a coordination workspace at ~/work/openspec-workspaces/add-3ds
I’ll carry forward:
- your current change description
- affected repos
- any planning notes already gathered
```
This is much better than making the user feel they must restart.
---
## The Minimum Decision Set
When OpenSpec has to ask questions, it should ask the smallest useful set.
### Decision 1: Is this local or coordinated?
Most important product question.
User-facing form:
```text
This appears to span multiple owned areas.
How should I proceed?
- Keep this as one local change
- Upgrade to a coordinated initiative
```
This should be used sparingly and only when ambiguity matters.
### Decision 2: What areas are affected?
User-facing form:
```text
Which areas are affected?
```
This is much more intuitive than asking users about "scopes" first.
Internally this is scope selection, but the user does not need that term unless advanced users want it.
### Decision 3: Is this shared behavior?
Only ask if OpenSpec has strong evidence of a cross-boundary contract.
User-facing form:
```text
This looks like behavior that multiple areas need to follow.
Should I treat this as:
- local changes only
- a shared contract
- draft coordination notes for now
```
### Decision 4: Where should shared ownership live?
Only ask if the user confirms shared contract behavior and no obvious existing owner exists.
User-facing form:
```text
Where should the canonical shared contract live?
```
This should appear late, not early.
---
## Recommended Terminology
The internal model may use many precise terms. The UI should use simpler terms.
### Prefer in user-facing UX
- "area" instead of "scope" by default
- "coordinated initiative" instead of "workspace model"
- "shared contract" instead of "cross-boundary canonical spec"
- "owner" instead of "owning root"
- "team-shared initiative" instead of "shared coordination manifest"
### Reserve for advanced UX or docs
- scope
- project root
- local overlay
- sponsor/driver
- coordination workspace
These terms are useful, but not ideal as the first thing users must absorb.
---
## Recommended Default Behavior
To keep the UX intuitive, OpenSpec should aggressively choose defaults.
### Default 1: Stay local
Unless there is strong evidence otherwise, planning stays in the current root.
### Default 2: Infer affected areas
OpenSpec should infer affected areas from:
- request wording
- current repo
- known spec layout
- recent initiative context
Ask the user only when there is meaningful ambiguity.
### Default 3: Reuse existing shared owners
If an existing shared contract owner already exists, OpenSpec should suggest it instead of asking an abstract ownership question.
### Default 4: Treat unresolved roots as partial, not fatal
For coordinated initiatives, unresolved repos should not block planning unless the user explicitly needs implementation there now.
### Default 5: Team-shared only when collaboration is real
Do not force team/shared setup for solo or exploratory work.
OpenSpec can start with a local coordination workspace and later offer:
```text
This now looks collaborative. Do you want to move it into a shared coordination repo?
```
---
## How To Make Team UX Feel Light
The team story should not feel like an admin ceremony.
### Desired team experience
1. One person starts planning normally
2. OpenSpec upgrades to a coordinated initiative if needed
3. When the work becomes collaborative, OpenSpec offers to make it team-shared
4. Teammates clone the initiative repo and run one linking command
5. Everyone starts from the same shared initiative context
### Team onboarding should feel like this
```text
Clone the initiative repo.
Run `openspec workspace doctor`.
Open your agent here.
```
Not like this:
```text
Learn a new planning model, understand manifests, configure overlays, and attach roots manually.
```
The implementation may require those concepts, but the UX should compress them into a few actions.
---
## UX Heuristics For Prompting
OpenSpec should avoid asking users to classify work in abstract ways if it can infer a reasonable default.
### Good prompt
```text
This affects:
- web checkout
- billing API
- shared checkout behavior
I think this should become a coordinated initiative.
Proceed?
```
Why this is good:
- concrete
- recommendation included
- low cognitive load
### Weaker prompt
```text
Would you like to create a coordination workspace with linked changes and shared ownership metadata?
```
Why this is weaker:
- too much internal machinery exposed
- user has to parse product architecture before saying yes
### Good ownership prompt
```text
I found an existing shared contracts area: `contracts/checkout`.
Use that as the canonical owner?
```
### Weaker ownership prompt
```text
Choose a canonical shared contract owner for this cross-boundary behavior.
```
The latter is precise, but too abstract unless the user is already deep in the workflow.
---
## The Experience We Should Aim For
By default, OpenSpec should feel like:
- "Start here"
- "Describe the work"
- "I’ll handle the shape unless I need your judgment"
When the system escalates, it should feel like:
- "This got bigger than one local change"
- "I’ve prepared the coordinated setup for you"
- "Here is the next obvious step"
When collaboration expands, it should feel like:
- "This is now team-shared"
- "Commit the stable plan"
- "Everyone links their own local clones"
The user should not feel like they are constantly switching conceptual frameworks.
---
## Recommended Follow-Up Changes To The Journeys
To make `workspace-user-journeys.md` simpler and more intuitive, the next revision should:
1. Move the simplest single-repo and monorepo journey to the top.
2. Move most terminology and internal model sections later or into an appendix.
3. Reframe "coordination workspace" as an escalation artifact, not a starting abstraction.
4. Replace many uses of "scope" with "area" in user-facing examples.
5. Convert abstract ownership questions into recommendation-first prompts.
6. Compress the team-scale setup into one simple story:
- shared initiative repo
- local link command
- open agent here
7. Make the escalation flow explicitly preserve user context so it reads as continuation, not restart.
---
## Summary
The current workspace thinking is directionally right, but the UX should become much more opinionated and much less explanatory up front.
The simplest product shape is:
- one default path: local planning from where the user already is
- one escalation path: upgrade into a coordinated initiative when needed
- progressive disclosure: only show advanced concepts when OpenSpec needs a real decision
If OpenSpec does this well, the same system can feel intuitive for:
- solo users
- small teams
- large monorepos
- multi-repo teams
- cross-team initiatives
+2 -2
View File
@@ -203,7 +203,7 @@ The system SHALL generate schema-aware apply instructions via `openspec instruct
- **WHEN** user runs `openspec instructions apply --change <id>`
- **AND** all required artifacts (per schema's `apply.requires`) exist
- **THEN** the system outputs:
- Context files from all existing artifacts
- `contextFiles` mapping artifact IDs to arrays of concrete paths for all existing artifacts
- Schema-specific instruction text
- Progress tracking file path (if `apply.tracks` is set)
@@ -218,7 +218,7 @@ The system SHALL generate schema-aware apply instructions via `openspec instruct
- **WHEN** user runs `openspec instructions apply --change <id> --json`
- **THEN** the system outputs JSON with:
- `contextFiles`: array of paths to existing artifacts
- `contextFiles`: object mapping artifact IDs to arrays of concrete paths for existing artifacts
- `instruction`: the apply instruction text
- `tracks`: path to progress file or null
- `applyRequires`: list of required artifact IDs
+1 -1
View File
@@ -168,7 +168,7 @@ The archive slash command template SHALL support optional change ID arguments fo
## Edge Cases
### Requirement: Error Handling
### Error Handling
The command SHALL handle edge cases gracefully.
+7 -7
View File
@@ -255,7 +255,7 @@ The system SHALL follow these principles:
## Directory Structure
### Requirement: Project Structure
### Project Structure
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
@@ -285,7 +285,7 @@ openspec/
## Specification Format
### Requirement: Structured Format for Behavioral Specs
### Behavioral Spec Format
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
@@ -316,7 +316,7 @@ Behavioral specifications SHALL use a structured format with consistent section
## Change Storage Convention
### Requirement: Header-Based Requirement Identification
### Header-Based Requirement Identification
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
@@ -345,7 +345,7 @@ Requirement headers SHALL serve as unique identifiers for programmatic matching
- **THEN** ensure no duplicate headers exist within a spec
- **AND** validation tools SHALL flag duplicate headers as errors
### Requirement: Change Storage Convention
### Change Storage Convention
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
@@ -388,7 +388,7 @@ The `changes/[name]/specs/` directory SHALL contain:
- `-` for REMOVED (red)
- `→` for RENAMED (cyan)
### Requirement: Archive Process Enhancement
### Archive Process Enhancement
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
@@ -411,7 +411,7 @@ The archive process SHALL programmatically apply delta changes to current specif
- **AND** require manual resolution before proceeding
- **AND** provide clear guidance on resolving conflicts
### Requirement: Proposal Format
### Proposal Format
Proposals SHALL explicitly document all changes with clear from/to comparisons.
@@ -444,7 +444,7 @@ The change process SHALL follow these states:
## Viewing Changes
### Requirement: Change Review
### Change Review
The system SHALL support multiple methods for reviewing proposed changes.
+13 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@fission-ai/openspec",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@fission-ai/openspec",
"version": "1.1.1",
"version": "1.2.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1803,6 +1803,7 @@
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1852,6 +1853,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -2182,6 +2184,7 @@
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"fflate": "^0.8.2",
@@ -2219,6 +2222,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2685,6 +2689,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4448,6 +4453,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4546,6 +4552,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4611,6 +4618,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4727,6 +4735,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4740,6 +4749,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -4919,6 +4929,7 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.2.0",
"version": "1.3.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+8 -72
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node
/**
* Postinstall script for auto-installing shell completions
* Postinstall script that hints about shell completions
*
* This script runs automatically after npm install unless:
* Completion installation is opt-in: the user must run
* `openspec completion install` explicitly. This script only
* prints a one-line tip after npm install.
*
* The tip is suppressed when:
* - CI=true environment variable is set
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
* - dist/ directory doesn't exist (dev setup scenario)
@@ -48,65 +52,6 @@ async function distExists() {
}
}
/**
* Detect the user's shell
*/
async function detectShell() {
try {
const { detectShell } = await import('../dist/utils/shell-detection.js');
const result = detectShell();
return result.shell;
} catch (error) {
// Fail silently if detection module doesn't exist
return undefined;
}
}
/**
* Install completions for the detected shell
*/
async function installCompletions(shell) {
try {
const { CompletionFactory } = await import('../dist/core/completions/factory.js');
const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');
// Check if shell is supported
if (!CompletionFactory.isSupported(shell)) {
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
return;
}
// Generate completion script
const generator = CompletionFactory.createGenerator(shell);
const script = generator.generate(COMMAND_REGISTRY);
// Install completion script
const installer = CompletionFactory.createInstaller(shell);
const result = await installer.install(script);
if (result.success) {
// Show success message based on installation type
if (result.isOhMyZsh) {
console.log(`✓ Shell completions installed`);
console.log(` Restart shell: exec zsh`);
} else if (result.zshrcConfigured) {
console.log(`✓ Shell completions installed and configured`);
console.log(` Restart shell: exec zsh`);
} else {
console.log(`✓ Shell completions installed to ~/.zsh/completions/`);
console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);
console.log(` Then: exec zsh`);
}
} else {
// Installation failed, show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
} catch (error) {
// Fail gracefully - show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
}
/**
* Main function
*/
@@ -124,19 +69,10 @@ async function main() {
return;
}
// Detect shell
const shell = await detectShell();
if (!shell) {
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
return;
}
// Install completions
await installCompletions(shell);
// Completions are opt-in — just print a hint
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
} catch (error) {
// Fail gracefully - never break npm install
// Show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ ORIGINAL_CI="${CI:-}"
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
# Test 1: Normal install
echo "Test 1: Normal install (should attempt to install completions)"
echo "Test 1: Normal install (should print tip about completions)"
echo "--------------------------------------"
unset CI
unset OPENSPEC_NO_COMPLETIONS
+19 -77
View File
@@ -12,6 +12,7 @@ import {
loadChangeContext,
generateInstructions,
resolveSchema,
resolveArtifactOutputs,
type ArtifactInstructions,
} from '../../core/artifact-graph/index.js';
import {
@@ -45,7 +46,7 @@ export async function instructionsCommand(
artifactId: string | undefined,
options: InstructionsOptions
): Promise<void> {
const spinner = ora('Generating instructions...').start();
const spinner = options.json ? undefined : ora('Generating instructions...').start();
try {
const projectRoot = process.cwd();
@@ -60,7 +61,7 @@ export async function instructionsCommand(
const context = loadChangeContext(projectRoot, changeName, options.schema);
if (!artifactId) {
spinner.stop();
spinner?.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
@@ -70,7 +71,7 @@ export async function instructionsCommand(
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
spinner.stop();
spinner?.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
@@ -80,7 +81,7 @@ export async function instructionsCommand(
const instructions = generateInstructions(context, artifactId, projectRoot);
const isBlocked = instructions.dependencies.some((d) => !d.done);
spinner.stop();
spinner?.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
@@ -89,7 +90,7 @@ export async function instructionsCommand(
printInstructionsText(instructions, isBlocked);
} catch (error) {
spinner.stop();
spinner?.stop();
throw error;
}
}
@@ -237,68 +238,6 @@ function parseTasksFile(content: string): TaskItem[] {
return tasks;
}
/**
* Checks if an artifact output exists in the change directory.
* Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
*/
function artifactOutputExists(changeDir: string, generates: string): boolean {
// Normalize the generates path to use platform-specific separators
const normalizedGenerates = generates.split('/').join(path.sep);
const fullPath = path.join(changeDir, normalizedGenerates);
// If it's a glob pattern (contains ** or *), check for matching files
if (generates.includes('*')) {
// Extract the directory part before the glob pattern
const parts = normalizedGenerates.split(path.sep);
const dirParts: string[] = [];
let patternPart = '';
for (const part of parts) {
if (part.includes('*')) {
patternPart = part;
break;
}
dirParts.push(part);
}
const dirPath = path.join(changeDir, ...dirParts);
// Check if directory exists
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return false;
}
// Extract expected extension from pattern (e.g., "*.md" -> ".md")
const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
const expectedExt = extMatch ? extMatch[1] : null;
// Recursively check for matching files
const hasMatchingFiles = (dir: string): boolean => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// For ** patterns, recurse into subdirectories
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
return true;
}
} else if (entry.isFile()) {
// Check if file matches expected extension (or any file if no extension specified)
if (!expectedExt || entry.name.endsWith(expectedExt)) {
return true;
}
}
}
} catch {
return false;
}
return false;
};
return hasMatchingFiles(dirPath);
}
return fs.existsSync(fullPath);
}
/**
* Generates apply instructions for implementing tasks from a change.
* Schema-aware: reads apply phase configuration from schema to determine
@@ -311,7 +250,7 @@ export async function generateApplyInstructions(
): Promise<ApplyInstructions> {
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, schemaName);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
const changeDir = context.changeDir;
// Get the full schema to access the apply phase configuration
const schema = resolveSchema(context.schemaName, projectRoot);
@@ -327,16 +266,17 @@ export async function generateApplyInstructions(
const missingArtifacts: string[] = [];
for (const artifactId of requiredArtifactIds) {
const artifact = schema.artifacts.find((a) => a.id === artifactId);
if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {
if (artifact && resolveArtifactOutputs(changeDir, artifact.generates).length === 0) {
missingArtifacts.push(artifactId);
}
}
// Build context files from all existing artifacts in schema
const contextFiles: Record<string, string> = {};
const contextFiles: Record<string, string[]> = {};
for (const artifact of schema.artifacts) {
if (artifactOutputExists(changeDir, artifact.generates)) {
contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
const outputs = resolveArtifactOutputs(changeDir, artifact.generates);
if (outputs.length > 0) {
contextFiles[artifact.id] = outputs;
}
}
@@ -400,7 +340,7 @@ export async function generateApplyInstructions(
}
export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {
const spinner = ora('Generating apply instructions...').start();
const spinner = options.json ? undefined : ora('Generating apply instructions...').start();
try {
const projectRoot = process.cwd();
@@ -414,7 +354,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions
// generateApplyInstructions uses loadChangeContext which auto-detects schema
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
spinner.stop();
spinner?.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
@@ -423,7 +363,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions
printApplyInstructionsText(instructions);
} catch (error) {
spinner.stop();
spinner?.stop();
throw error;
}
}
@@ -448,8 +388,10 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi
const contextFileEntries = Object.entries(contextFiles);
if (contextFileEntries.length > 0) {
console.log('### Context Files');
for (const [artifactId, filePath] of contextFileEntries) {
console.log(`- ${artifactId}: ${filePath}`);
for (const [artifactId, filePaths] of contextFileEntries) {
for (const filePath of filePaths) {
console.log(`- ${artifactId}: ${filePath}`);
}
}
console.log();
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface ApplyInstructions {
changeName: string;
changeDir: string;
schemaName: string;
contextFiles: Record<string, string>;
contextFiles: Record<string, string[]>;
progress: {
total: number;
complete: number;
+5 -5
View File
@@ -34,7 +34,7 @@ export interface StatusOptions {
// -----------------------------------------------------------------------------
export async function statusCommand(options: StatusOptions): Promise<void> {
const spinner = ora('Loading change status...').start();
const spinner = options.json ? undefined : ora('Loading change status...').start();
try {
const projectRoot = process.cwd();
@@ -44,7 +44,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
if (!options.change) {
const available = await getAvailableChanges(projectRoot);
if (available.length === 0) {
spinner.stop();
spinner?.stop();
if (options.json) {
console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));
return;
@@ -53,7 +53,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
return;
}
// Changes exist but --change not provided
spinner.stop();
spinner?.stop();
throw new Error(
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
);
@@ -70,7 +70,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
const context = loadChangeContext(projectRoot, changeName, options.schema);
const status = formatChangeStatus(context);
spinner.stop();
spinner?.stop();
if (options.json) {
console.log(JSON.stringify(status, null, 2));
@@ -79,7 +79,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
printStatusText(status);
} catch (error) {
spinner.stop();
spinner?.stop();
throw error;
}
}
+7 -4
View File
@@ -11,6 +11,7 @@ import {
getSchemaDir,
ArtifactGraph,
} from '../../core/artifact-graph/index.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
// -----------------------------------------------------------------------------
@@ -33,7 +34,7 @@ export interface TemplateInfo {
// -----------------------------------------------------------------------------
export async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();
const spinner = options.json ? undefined : ora('Loading templates...').start();
try {
const projectRoot = process.cwd();
@@ -68,11 +69,13 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: path.join(schemaDir, 'templates', artifact.template),
templatePath: FileSystemUtils.canonicalizeExistingPath(
path.join(schemaDir, 'templates', artifact.template)
),
source,
}));
spinner.stop();
spinner?.stop();
if (options.json) {
const output: Record<string, { path: string; source: string }> = {};
@@ -92,7 +95,7 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>
console.log(` ${t.templatePath}`);
}
} catch (error) {
spinner.stop();
spinner?.stop();
throw error;
}
}
+1
View File
@@ -16,6 +16,7 @@ export { ArtifactGraph } from './graph.js';
// State detection
export { detectCompleted } from './state.js';
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
// Schema resolution
export {
+10 -5
View File
@@ -4,6 +4,7 @@ import { getSchemaDir, resolveSchema } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { readProjectConfig, validateConfigRules } from '../project-config.js';
import type { Artifact, CompletedSet } from './types.js';
@@ -137,15 +138,17 @@ export function loadTemplate(
);
}
const fullPath = path.join(schemaDir, 'templates', templatePath);
const templatePathOnDisk = path.join(schemaDir, 'templates', templatePath);
if (!fs.existsSync(fullPath)) {
if (!fs.existsSync(templatePathOnDisk)) {
throw new TemplateLoadError(
`Template not found: ${fullPath}`,
fullPath
`Template not found: ${templatePathOnDisk}`,
templatePathOnDisk
);
}
const fullPath = FileSystemUtils.canonicalizeExistingPath(templatePathOnDisk);
try {
return fs.readFileSync(fullPath, 'utf-8');
} catch (err) {
@@ -175,7 +178,9 @@ export function loadChangeContext(
changeName: string,
schemaName?: string
): ChangeContext {
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
const changeDir = FileSystemUtils.canonicalizeExistingPath(
path.join(projectRoot, 'openspec', 'changes', changeName)
);
// Resolve schema: explicit > metadata > default
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
+43
View File
@@ -0,0 +1,43 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import fg from 'fast-glob';
import { FileSystemUtils } from '../../utils/file-system.js';
/**
* Checks if a path contains glob pattern characters.
*/
export function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}
/**
* Resolves an artifact's output path(s) to concrete files that currently exist.
* Returns absolute file paths. Glob matches are sorted for deterministic output.
*/
export function resolveArtifactOutputs(changeDir: string, generates: string): string[] {
const fullPattern = path.join(changeDir, generates);
if (!isGlobPattern(generates)) {
try {
return fs.statSync(fullPattern).isFile()
? [FileSystemUtils.canonicalizeExistingPath(fullPattern)]
: [];
} catch {
return [];
}
}
const normalizedPattern = FileSystemUtils.toPosixPath(fullPattern);
const matches = fg
.sync(normalizedPattern, { onlyFiles: true })
.map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
return Array.from(new Set(matches)).sort();
}
/**
* Checks if an artifact has at least one resolved output file.
*/
export function artifactOutputExists(changeDir: string, generates: string): boolean {
return resolveArtifactOutputs(changeDir, generates).length > 0;
}
+2 -29
View File
@@ -1,9 +1,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import fg from 'fast-glob';
import type { CompletedSet } from './types.js';
import type { ArtifactGraph } from './graph.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { artifactOutputExists } from './outputs.js';
/**
* Detects which artifacts are completed by checking file existence in the change directory.
@@ -35,30 +33,5 @@ export function detectCompleted(graph: ArtifactGraph, changeDir: string): Comple
* Supports both simple paths and glob patterns.
*/
function isArtifactComplete(generates: string, changeDir: string): boolean {
const fullPattern = path.join(changeDir, generates);
// Check if it's a glob pattern
if (isGlobPattern(generates)) {
return hasGlobMatches(fullPattern);
}
// Simple file path - check if file exists
return fs.existsSync(fullPattern);
}
/**
* Checks if a path contains glob pattern characters.
*/
function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}
/**
* Checks if a glob pattern has any matches.
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
*/
function hasGlobMatches(pattern: string): boolean {
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
return matches.length > 0;
return artifactOutputExists(changeDir, generates);
}
@@ -0,0 +1,51 @@
/**
* Bob Shell Command Adapter
*
* Formats commands for Bob Shell following its markdown specification.
* Commands are stored in .bob/commands/ directory.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';
/**
* Escapes a string value for safe YAML output.
* Quotes the string if it contains special YAML characters.
*/
function escapeYamlValue(value: string): string {
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
// Use double quotes and escape internal double quotes and backslashes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
}
return value;
}
/**
* Bob Shell adapter for command generation.
* File path: .bob/commands/opsx-<id>.md
* Frontmatter: description, argument-hint
*/
export const bobAdapter: ToolCommandAdapter = {
toolId: 'bob',
getFilePath(commandId: string): string {
return path.join('.bob', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
// Transform command references from colon to hyphen format for Bob
const transformedBody = transformToHyphenCommands(content.body);
return `---
description: ${escapeYamlValue(content.description)}
argument-hint: command arguments
---
${transformedBody}
`;
},
};
@@ -7,6 +7,7 @@
export { amazonQAdapter } from './amazon-q.js';
export { antigravityAdapter } from './antigravity.js';
export { auggieAdapter } from './auggie.js';
export { bobAdapter } from './bob.js';
export { claudeAdapter } from './claude.js';
export { clineAdapter } from './cline.js';
export { codexAdapter } from './codex.js';
@@ -19,11 +20,13 @@ export { factoryAdapter } from './factory.js';
export { geminiAdapter } from './gemini.js';
export { githubCopilotAdapter } from './github-copilot.js';
export { iflowAdapter } from './iflow.js';
export { junieAdapter } from './junie.js';
export { kilocodeAdapter } from './kilocode.js';
export { kiroAdapter } from './kiro.js';
export { opencodeAdapter } from './opencode.js';
export { piAdapter } from './pi.js';
export { qoderAdapter } from './qoder.js';
export { lingmaAdapter } from './lingma.js';
export { qwenAdapter } from './qwen.js';
export { roocodeAdapter } from './roocode.js';
export { windsurfAdapter } from './windsurf.js';
@@ -0,0 +1,30 @@
/**
* Junie Command Adapter
*
* Formats commands for Junie following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Junie adapter for command generation.
* File path: .junie/commands/opsx-<id>.md
* Frontmatter: description
*/
export const junieAdapter: ToolCommandAdapter = {
toolId: 'junie',
getFilePath(commandId: string): string {
return path.join('.junie', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
---
${content.body}
`;
},
};
@@ -0,0 +1,34 @@
/**
* Lingma Command Adapter
*
* Formats commands for Lingma following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Lingma adapter for command generation.
* File path: .lingma/commands/opsx/<id>.md
* Frontmatter: name, description, category, tags
*/
export const lingmaAdapter: ToolCommandAdapter = {
toolId: 'lingma',
getFilePath(commandId: string): string {
return path.join('.lingma', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
const tagsStr = content.tags.join(', ');
return `---
name: ${content.name}
description: ${content.description}
category: ${content.category}
tags: [${tagsStr}]
---
${content.body}
`;
},
};
+22 -1
View File
@@ -7,6 +7,20 @@
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';
const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
function injectPiArgs(body: string): string {
if (body.includes('$@') || body.includes('$ARGUMENTS')) {
return body;
}
return body.replace(
PI_INPUT_HEADING,
(heading) => `${heading}\n**Provided arguments**: $@`
);
}
/**
* Escapes a string value for safe YAML output.
@@ -27,6 +41,10 @@ function escapeYamlValue(value: string): string {
* Pi adapter for prompt template generation.
* File path: .pi/prompts/opsx-<id>.md
* Frontmatter: description
*
* Pi uses the filename (minus .md) as the slash command name, so
* opsx-propose.md → /opsx-propose. Command references in the body
* are transformed from /opsx: to /opsx- for consistency.
*/
export const piAdapter: ToolCommandAdapter = {
toolId: 'pi',
@@ -36,11 +54,14 @@ export const piAdapter: ToolCommandAdapter = {
},
formatFile(content: CommandContent): string {
// Transform /opsx: references to /opsx- and inject $@ for template args
const transformedBody = transformToHyphenCommands(content.body);
return `---
description: ${escapeYamlValue(content.description)}
---
${content.body}
${injectPiArgs(transformedBody)}
`;
},
};
+6
View File
@@ -9,6 +9,7 @@ import type { ToolCommandAdapter } from './types.js';
import { amazonQAdapter } from './adapters/amazon-q.js';
import { antigravityAdapter } from './adapters/antigravity.js';
import { auggieAdapter } from './adapters/auggie.js';
import { bobAdapter } from './adapters/bob.js';
import { claudeAdapter } from './adapters/claude.js';
import { clineAdapter } from './adapters/cline.js';
import { codexAdapter } from './adapters/codex.js';
@@ -21,11 +22,13 @@ import { factoryAdapter } from './adapters/factory.js';
import { geminiAdapter } from './adapters/gemini.js';
import { githubCopilotAdapter } from './adapters/github-copilot.js';
import { iflowAdapter } from './adapters/iflow.js';
import { junieAdapter } from './adapters/junie.js';
import { kilocodeAdapter } from './adapters/kilocode.js';
import { kiroAdapter } from './adapters/kiro.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { piAdapter } from './adapters/pi.js';
import { qoderAdapter } from './adapters/qoder.js';
import { lingmaAdapter } from './adapters/lingma.js';
import { qwenAdapter } from './adapters/qwen.js';
import { roocodeAdapter } from './adapters/roocode.js';
import { windsurfAdapter } from './adapters/windsurf.js';
@@ -41,6 +44,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(amazonQAdapter);
CommandAdapterRegistry.register(antigravityAdapter);
CommandAdapterRegistry.register(auggieAdapter);
CommandAdapterRegistry.register(bobAdapter);
CommandAdapterRegistry.register(claudeAdapter);
CommandAdapterRegistry.register(clineAdapter);
CommandAdapterRegistry.register(codexAdapter);
@@ -53,11 +57,13 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(geminiAdapter);
CommandAdapterRegistry.register(githubCopilotAdapter);
CommandAdapterRegistry.register(iflowAdapter);
CommandAdapterRegistry.register(junieAdapter);
CommandAdapterRegistry.register(kilocodeAdapter);
CommandAdapterRegistry.register(kiroAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(piAdapter);
CommandAdapterRegistry.register(qoderAdapter);
CommandAdapterRegistry.register(lingmaAdapter);
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
CommandAdapterRegistry.register(windsurfAdapter);
@@ -23,6 +23,49 @@ export class PowerShellInstaller {
this.homeDir = homeDir;
}
/**
* Detect the encoding of a file by inspecting its BOM (Byte Order Mark).
* Returns the Node.js BufferEncoding and the raw BOM bytes to preserve on write.
*/
private detectEncoding(buffer: Buffer): { encoding: BufferEncoding; bom: Buffer } {
// UTF-16 LE BOM: FF FE
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return { encoding: 'utf16le', bom: Buffer.from([0xff, 0xfe]) };
}
// UTF-16 BE BOM: FE FF — not natively supported by Node
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
throw new Error(
'File is encoded as UTF-16 BE which is not supported. ' +
'Please re-save as UTF-8 or UTF-16 LE, then retry.',
);
}
// UTF-8 BOM: EF BB BF
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
return { encoding: 'utf-8', bom: Buffer.from([0xef, 0xbb, 0xbf]) };
}
// No BOM → default UTF-8
return { encoding: 'utf-8', bom: Buffer.alloc(0) };
}
/**
* Read a profile file, preserving its encoding metadata for round-trip writes.
* Throws if the file uses UTF-16 BE (unsupported by Node).
*/
private async readProfileFile(filePath: string): Promise<{ content: string; encoding: BufferEncoding; bom: Buffer }> {
const raw = await fs.readFile(filePath);
const { encoding, bom } = this.detectEncoding(raw);
const content = raw.subarray(bom.length).toString(encoding);
return { content, encoding, bom };
}
/**
* Write a profile file, preserving the original BOM and encoding.
*/
private async writeProfileFile(filePath: string, content: string, encoding: BufferEncoding, bom: Buffer): Promise<void> {
const body = Buffer.from(content, encoding);
await fs.writeFile(filePath, Buffer.concat([bom, body]));
}
/**
* Get PowerShell profile path
* Prefers $PROFILE environment variable, falls back to platform defaults
@@ -132,10 +175,22 @@ export class PowerShellInstaller {
await fs.mkdir(profileDir, { recursive: true });
let profileContent = '';
let fileEncoding: BufferEncoding = 'utf-8';
let fileBom: Buffer = Buffer.alloc(0);
try {
profileContent = await fs.readFile(profilePath, 'utf-8');
} catch {
// Profile doesn't exist yet, that's fine
const file = await this.readProfileFile(profilePath);
profileContent = file.content;
fileEncoding = file.encoding;
fileBom = file.bom;
} catch (err: any) {
// If the file doesn't exist that's fine — we'll create it as UTF-8.
// Any other read error (permissions, unsupported encoding, etc.) → skip this profile.
if (err?.code === 'ENOENT') {
// keep defaults
} else {
console.warn(`Warning: Skipping ${profilePath}: ${err?.message ?? String(err)}`);
continue;
}
}
// Check if already configured
@@ -154,7 +209,7 @@ export class PowerShellInstaller {
].join('\n');
const newContent = profileContent + openspecBlock;
await fs.writeFile(profilePath, newContent, 'utf-8');
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
anyConfigured = true;
} catch (error) {
// Continue to next profile if this one fails
@@ -177,12 +232,21 @@ export class PowerShellInstaller {
for (const profilePath of profilePaths) {
try {
// Read profile content
// Read profile content with encoding detection
let profileContent: string;
let fileEncoding: BufferEncoding = 'utf-8';
let fileBom: Buffer = Buffer.alloc(0);
try {
profileContent = await fs.readFile(profilePath, 'utf-8');
} catch {
continue; // Profile doesn't exist, nothing to remove
const file = await this.readProfileFile(profilePath);
profileContent = file.content;
fileEncoding = file.encoding;
fileBom = file.bom;
} catch (err: any) {
if (err?.code === 'ENOENT') {
continue; // Profile doesn't exist, nothing to remove
}
console.warn(`Warning: Could not read ${profilePath}: ${err?.message ?? String(err)}`);
continue;
}
// Remove OPENSPEC:START -> OPENSPEC:END block
@@ -207,7 +271,7 @@ export class PowerShellInstaller {
// Clean up extra newlines
const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n';
await fs.writeFile(profilePath, newContent, 'utf-8');
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
anyRemoved = true;
} catch (error) {
console.warn(`Warning: Could not clean ${profilePath}: ${error}`);
+3
View File
@@ -22,6 +22,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },
@@ -35,11 +36,13 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
+2 -2
View File
@@ -537,8 +537,8 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');
// Generate SKILL.md content with YAML frontmatter including generatedBy
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
// Use hyphen-based command references for tools where filename = command name
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
// Write the skill file
+2
View File
@@ -34,6 +34,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'claude': { type: 'directory', path: '.claude/commands/openspec' },
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
'crush': { type: 'directory', path: '.crush/commands/openspec' },
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
@@ -53,6 +54,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
};
+13 -4
View File
@@ -179,17 +179,21 @@ export class ChangeParser extends MarkdownParser {
private parseSectionsFromContent(content: string): Section[] {
const normalizedContent = ChangeParser.normalizeContent(content);
const lines = normalizedContent.split('\n');
const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines);
const sections: Section[] = [];
const stack: Section[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (codeFenceLineMask[i]) {
continue;
}
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const title = headerMatch[2].trim();
const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
const section = {
level,
@@ -215,12 +219,17 @@ export class ChangeParser extends MarkdownParser {
return sections;
}
private getContentUntilNextHeaderFromLines(lines: string[], startLine: number, currentLevel: number): string[] {
private getContentUntilNextHeaderFromLines(
lines: string[],
codeFenceLineMask: boolean[],
startLine: number,
currentLevel: number
): string[] {
const contentLines: string[] = [];
for (let i = startLine; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(/^(#{1,6})\s+/);
const headerMatch = codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
if (headerMatch && headerMatch[1].length <= currentLevel) {
break;
@@ -231,4 +240,4 @@ export class ChangeParser extends MarkdownParser {
return contentLines;
}
}
}
+55 -2
View File
@@ -9,11 +9,13 @@ export interface Section {
export class MarkdownParser {
private lines: string[];
private codeFenceLineMask: boolean[];
private currentLine: number;
constructor(content: string) {
const normalized = MarkdownParser.normalizeContent(content);
this.lines = normalized.split('\n');
this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines);
this.currentLine = 0;
}
@@ -21,6 +23,54 @@ export class MarkdownParser {
return content.replace(/\r\n?/g, '\n');
}
protected static buildCodeFenceMask(lines: string[]): boolean[] {
const mask = new Array(lines.length).fill(false);
let activeFence: { marker: '`' | '~'; length: number } | null = null;
for (let i = 0; i < lines.length; i++) {
const fence = MarkdownParser.getFenceMarker(lines[i]);
if (!activeFence) {
if (fence) {
activeFence = fence;
mask[i] = true;
}
continue;
}
mask[i] = true;
if (MarkdownParser.isClosingFence(lines[i], activeFence)) {
activeFence = null;
}
}
return mask;
}
private static getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (!fenceMatch) {
return null;
}
return {
marker: fenceMatch[1][0] as '`' | '~',
length: fenceMatch[1].length,
};
}
private static isClosingFence(
line: string,
activeFence: { marker: '`' | '~'; length: number }
): boolean {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
return Boolean(
fenceMatch &&
fenceMatch[1][0] === activeFence.marker &&
fenceMatch[1].length >= activeFence.length
);
}
parseSpec(name: string): Spec {
const sections = this.parseSections();
const purpose = this.findSection(sections, 'Purpose')?.content || '';
@@ -81,6 +131,9 @@ export class MarkdownParser {
for (let i = 0; i < this.lines.length; i++) {
const line = this.lines[i];
if (this.codeFenceLineMask[i]) {
continue;
}
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
@@ -117,7 +170,7 @@ export class MarkdownParser {
for (let i = startLine; i < this.lines.length; i++) {
const line = this.lines[i];
const headerMatch = line.match(/^(#{1,6})\s+/);
const headerMatch = this.codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
if (headerMatch && headerMatch[1].length <= currentLevel) {
break;
@@ -234,4 +287,4 @@ export class MarkdownParser {
return deltas;
}
}
}
+117
View File
@@ -0,0 +1,117 @@
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/;
export interface MainSpecStructureIssue {
kind: 'delta-header' | 'requirement-outside-requirements';
line: number;
header: string;
message: string;
}
export function findMainSpecStructureIssues(content: string): MainSpecStructureIssue[] {
const normalized = content.replace(/\r\n?/g, '\n');
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
const lines = stripped.split('\n');
const issues: MainSpecStructureIssue[] = [];
const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
let requirementsEndIndex = lines.length;
if (requirementsHeaderIndex !== -1) {
for (let i = requirementsHeaderIndex + 1; i < lines.length; i++) {
if (TOP_LEVEL_SECTION_HEADER.test(lines[i])) {
requirementsEndIndex = i;
break;
}
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (DELTA_HEADER.test(line)) {
issues.push({
kind: 'delta-header',
line: i + 1,
header: trimmed,
message:
`Main spec contains delta header "${trimmed}". ` +
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
'and truncate the parsed ## Requirements section.',
});
continue;
}
const requirementMatch = line.match(REQUIREMENT_HEADER);
if (!requirementMatch) {
continue;
}
const insideRequirements =
requirementsHeaderIndex !== -1 &&
i > requirementsHeaderIndex &&
i < requirementsEndIndex;
if (!insideRequirements) {
issues.push({
kind: 'requirement-outside-requirements',
line: i + 1,
header: trimmed,
message:
`Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
});
}
}
return issues;
}
export function stripFencedCodeBlocksPreservingLines(content: string): string {
const lines = content.split('\n');
const output: string[] = [];
let activeFence: { marker: '`' | '~'; length: number } | null = null;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
if (!activeFence) {
if (fenceMatch) {
activeFence = {
marker: fenceMatch[1][0] as '`' | '~',
length: fenceMatch[1].length,
};
output.push('');
} else {
output.push(line);
}
continue;
}
output.push('');
if (isClosingFence(line, activeFence)) {
activeFence = null;
}
}
return output.join('\n');
}
function isClosingFence(
line: string,
activeFence: { marker: '`' | '~'; length: number }
): boolean {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
return Boolean(
fenceMatch &&
fenceMatch[1][0] === activeFence.marker &&
fenceMatch[1].length >= activeFence.length
);
}
+11
View File
@@ -14,6 +14,7 @@ import {
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
import { Validator } from './validation/validator.js';
// -----------------------------------------------------------------------------
@@ -223,6 +224,16 @@ export async function buildUpdatedSpec(
targetContent = buildSpecSkeleton(specName, changeName);
}
const structureIssues = findMainSpecStructureIssues(targetContent);
if (structureIssues.length > 0) {
const details = structureIssues
.map(issue => `line ${issue.line}: ${issue.message}`)
.join('\n');
throw new Error(
`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`
);
}
// Extract requirements section and build name->block map
const parts = extractRequirementsSection(targetContent);
const nameToBlock = new Map<string, RequirementBlock>();
+4 -4
View File
@@ -40,7 +40,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
\`\`\`
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@@ -52,7 +52,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate {
4. **Read context files**
Read the files listed in \`contextFiles\` from the apply instructions output.
Read every file path listed under \`contextFiles\` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
@@ -197,7 +197,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
\`\`\`
This returns:
- Context file paths (varies by schema)
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@@ -209,7 +209,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate {
4. **Read context files**
Read the files listed in \`contextFiles\` from the apply instructions output.
Read every file path listed under \`contextFiles\` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
@@ -40,7 +40,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
openspec instructions apply --change "<name>" --json
\`\`\`
This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
4. **Initialize verification report structure**
@@ -54,7 +54,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
5. **Verify Completeness**
**Task Completion**:
- If tasks.md exists in contextFiles, read it
- If \`contextFiles.tasks\` exists, read every file path in it
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
@@ -93,7 +93,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate {
7. **Verify Coherence**
**Design Adherence**:
- If design.md exists in contextFiles:
- If \`contextFiles.design\` exists:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
@@ -209,7 +209,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
openspec instructions apply --change "<name>" --json
\`\`\`
This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
4. **Initialize verification report structure**
@@ -223,7 +223,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
5. **Verify Completeness**
**Task Completion**:
- If tasks.md exists in contextFiles, read it
- If \`contextFiles.tasks\` exists, read every file path in it
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
@@ -262,7 +262,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate {
7. **Verify Coherence**
**Design Adherence**:
- If design.md exists in contextFiles:
- If \`contextFiles.design\` exists:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
+2 -2
View File
@@ -195,7 +195,7 @@ export class UpdateCommand {
const skillFile = path.join(skillDir, 'SKILL.md');
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
@@ -666,7 +666,7 @@ export class UpdateCommand {
const skillFile = path.join(skillDir, 'SKILL.md');
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
+10
View File
@@ -11,6 +11,7 @@ import {
VALIDATION_MESSAGES
} from './constants.js';
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
import { FileSystemUtils } from '../../utils/file-system.js';
export class Validator {
@@ -288,6 +289,15 @@ export class Validator {
private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const structuralIssue of findMainSpecStructureIssues(content)) {
issues.push({
level: 'ERROR',
path: 'file',
line: structuralIssue.line,
message: structuralIssue.message,
});
}
if (spec.overview.length < MIN_PURPOSE_LENGTH) {
issues.push({
+20
View File
@@ -17,10 +17,24 @@ import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
const POSTHOG_HOST = 'https://edge.openspec.dev';
const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
let posthogClient: PostHog | null = null;
let anonymousId: string | null = null;
async function safeTelemetryFetch(url: string, options: RequestInit): Promise<Response> {
try {
const response = await fetch(url, options);
if (response.ok) {
return response;
}
} catch {
// Silent failure - telemetry should never surface network noise
}
return new Response(null, { status: 204 });
}
/**
* Check if telemetry is enabled.
*
@@ -81,6 +95,12 @@ function getClient(): PostHog {
host: POSTHOG_HOST,
flushAt: 1, // Send immediately, don't batch
flushInterval: 0, // No timer-based flushing
fetchRetryCount: 0,
requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
preloadFeatureFlags: false,
disableRemoteConfig: true,
disableSurveys: true,
fetch: safeTelemetryFetch,
});
}
return posthogClient;
+21 -1
View File
@@ -1,6 +1,9 @@
import { promises as fs, constants as fsConstants } from 'fs';
import * as nodeFs from 'fs';
import path from 'path';
const fs = nodeFs.promises;
const { constants: fsConstants } = nodeFs;
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
let leftIndex = markerIndex - 1;
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
@@ -50,6 +53,23 @@ export class FileSystemUtils {
return p.replace(/\\/g, '/');
}
/**
* Returns a canonical absolute path when the target exists.
* Falls back to path.resolve() so callers can still produce a stable absolute path.
*/
static canonicalizeExistingPath(targetPath: string): string {
try {
// Prefer the native resolver so Windows short-path aliases are expanded.
return nodeFs.realpathSync.native(targetPath);
} catch {
try {
return nodeFs.realpathSync(targetPath);
} catch {
return path.resolve(targetPath);
}
}
}
private static isWindowsBasePath(basePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
}
+46
View File
@@ -26,6 +26,12 @@ async function prepareFixture(fixtureName: string): Promise<string> {
return projectDir;
}
function expectJsonOnlyOutput(result: Awaited<ReturnType<typeof runCLI>>) {
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
expect(() => JSON.parse(result.stdout)).not.toThrow();
}
afterAll(async () => {
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
@@ -71,6 +77,46 @@ describe('openspec CLI e2e basics', () => {
expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);
});
it('keeps list --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['list', '--json'], { cwd: projectDir });
expectJsonOnlyOutput(result);
});
it('keeps schemas --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['schemas', '--json'], { cwd: projectDir });
expectJsonOnlyOutput(result);
});
it('keeps status --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['status', '--change', 'c1', '--json'], { cwd: projectDir });
expectJsonOnlyOutput(result);
});
it('keeps instructions --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['instructions', 'proposal', '--change', 'c1', '--json'], {
cwd: projectDir,
});
expectJsonOnlyOutput(result);
});
it('keeps instructions apply --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['instructions', 'apply', '--change', 'c1', '--json'], {
cwd: projectDir,
});
expectJsonOnlyOutput(result);
});
it('keeps templates --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['templates', '--json'], { cwd: projectDir });
expectJsonOnlyOutput(result);
});
it('returns an error for unknown items in the fixture', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir });
+67
View File
@@ -3,11 +3,14 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { runCLI } from '../helpers/run-cli.js';
import { FileSystemUtils } from '../../src/utils/file-system.js';
describe('artifact-workflow CLI commands', () => {
let tempDir: string;
let changesDir: string;
const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath);
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
changesDir = path.join(tempDir, 'openspec', 'changes');
@@ -110,6 +113,7 @@ describe('artifact-workflow CLI commands', () => {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
const json = JSON.parse(result.stdout);
expect(json.changeName).toBe('json-change');
@@ -256,6 +260,7 @@ describe('artifact-workflow CLI commands', () => {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
const json = JSON.parse(result.stdout);
expect(json.artifactId).toBe('design');
@@ -309,6 +314,7 @@ describe('artifact-workflow CLI commands', () => {
it('outputs JSON mapping of templates', async () => {
const result = await runCLI(['templates', '--json'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
const json = JSON.parse(result.stdout);
expect(json.proposal).toBeDefined();
@@ -405,13 +411,74 @@ describe('artifact-workflow CLI commands', () => {
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
const json = JSON.parse(result.stdout);
const expectedProposalPath = canonical(path.join(changesDir, 'json-apply', 'proposal.md'));
const expectedSpecPath = canonical(path.join(changesDir, 'json-apply', 'specs', 'test-spec.md'));
expect(json.changeName).toBe('json-apply');
expect(json.schemaName).toBe('spec-driven');
expect(json.state).toBe('ready');
expect(json.contextFiles).toBeDefined();
expect(typeof json.contextFiles).toBe('object');
expect(json.contextFiles.proposal).toEqual([expectedProposalPath]);
expect(json.contextFiles.specs).toEqual([expectedSpecPath]);
});
it('resolves single-star glob artifacts consistently between status and apply', async () => {
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-test');
const templatesDir = path.join(schemaDir, 'templates');
await fs.mkdir(templatesDir, { recursive: true });
await fs.writeFile(
path.join(schemaDir, 'schema.yaml'),
`name: glob-test
version: 1
description: Test schema for single-star globs
artifacts:
- id: specs
generates: specs/*/spec.md
description: Nested specs
template: spec.md
requires: []
apply:
requires: [specs]
instruction: Ready when specs exist.
`
);
await fs.writeFile(path.join(templatesDir, 'spec.md'), '# Spec\n');
const changeDir = path.join(changesDir, 'single-star-glob');
const specPath = path.join(changeDir, 'specs', 'single-star-glob', 'spec.md');
await fs.mkdir(path.dirname(specPath), { recursive: true });
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-test\n');
await fs.writeFile(specPath, '# Nested spec\n');
const statusResult = await runCLI(['status', '--change', 'single-star-glob', '--json'], {
cwd: tempDir,
});
expect(statusResult.exitCode).toBe(0);
const statusJson = JSON.parse(statusResult.stdout);
expect(statusJson.artifacts).toEqual([
{
id: 'specs',
outputPath: 'specs/*/spec.md',
status: 'done',
},
]);
const applyResult = await runCLI(
['instructions', 'apply', '--change', 'single-star-glob', '--json'],
{ cwd: tempDir }
);
expect(applyResult.exitCode).toBe(0);
const applyJson = JSON.parse(applyResult.stdout);
const resolvedSpecPath = canonical(specPath);
expect(applyJson.state).toBe('ready');
expect(applyJson.missingArtifacts).toBeUndefined();
expect(applyJson.contextFiles).toEqual({
specs: [resolvedSpecPath],
});
});
it('shows schema instruction from apply block', async () => {
+62
View File
@@ -561,6 +561,68 @@ new text
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should abort with a structural error when target spec hides requirements outside ## Requirements', async () => {
const changeName = 'hidden-requirement-target';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'delta-target');
await fs.mkdir(changeSpecDir, { recursive: true });
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta-target');
await fs.mkdir(mainSpecDir, { recursive: true });
const malformedMain = `# delta-target Specification
## Purpose
Delta target purpose.
## Requirements
### Requirement: A
The system SHALL do A.
#### Scenario: A works
- **WHEN** foo
- **THEN** bar
## Edge Cases
### Requirement: B
The system SHALL do B.
#### Scenario: B works
- **WHEN** baz
- **THEN** qux`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), malformedMain);
const deltaContent = `# Delta Target Changes
## MODIFIED Requirements
### Requirement: B
The system SHALL do B differently.
#### Scenario: B changes
- **WHEN** baz changes
- **THEN** qux changes`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('delta-target: target spec is structurally invalid and cannot be updated until fixed:')
);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Requirement header "### Requirement: B" appears outside the main ## Requirements section.')
);
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(still).toBe(malformedMain);
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.some(a => a.includes(changeName))).toBe(false);
});
it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => {
const changeName = 'rename-modify-new-header';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileSystemUtils } from '../../../src/utils/file-system.js';
import { artifactOutputExists, resolveArtifactOutputs } from '../../../src/core/artifact-graph/outputs.js';
describe('artifact-graph/outputs', () => {
let tempDir: string;
const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath);
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-outputs-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves a direct file path when it exists', () => {
const filePath = path.join(tempDir, 'proposal.md');
fs.writeFileSync(filePath, 'content');
expect(resolveArtifactOutputs(tempDir, 'proposal.md')).toEqual([canonical(filePath)]);
expect(artifactOutputExists(tempDir, 'proposal.md')).toBe(true);
});
it('does not treat a directory as a resolved literal artifact output', () => {
const dirPath = path.join(tempDir, 'proposal.md');
fs.mkdirSync(dirPath, { recursive: true });
expect(resolveArtifactOutputs(tempDir, 'proposal.md')).toEqual([]);
expect(artifactOutputExists(tempDir, 'proposal.md')).toBe(false);
});
it('resolves single-star nested globs to concrete files', () => {
const nestedDir = path.join(tempDir, 'specs', 'change-a');
const filePath = path.join(nestedDir, 'spec.md');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(filePath, 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([canonical(filePath)]);
expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(true);
});
it('matches basename-sensitive glob patterns correctly', () => {
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
const matching = path.join(specsDir, 'foo-auth.md');
const nonMatching = path.join(specsDir, 'bar-auth.md');
fs.writeFileSync(matching, 'content');
fs.writeFileSync(nonMatching, 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/foo*.md')).toEqual([canonical(matching)]);
});
it('supports question-mark glob patterns', () => {
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
const matching = path.join(specsDir, 'a1.md');
fs.writeFileSync(matching, 'content');
fs.writeFileSync(path.join(specsDir, 'a10.md'), 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/a?.md')).toEqual([canonical(matching)]);
});
it('supports character class glob patterns', () => {
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
const aPath = path.join(specsDir, 'a.md');
const bPath = path.join(specsDir, 'b.md');
fs.writeFileSync(aPath, 'content');
fs.writeFileSync(bPath, 'content');
fs.writeFileSync(path.join(specsDir, 'c.md'), 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/[ab].md')).toEqual([
canonical(aPath),
canonical(bPath),
]);
});
it('canonicalizes resolved paths when the change directory is accessed through an alias', () => {
const rootDir = path.join(tempDir, 'workspace');
const realChangeDir = path.join(rootDir, 'real-change');
const aliasChangeDir = path.join(rootDir, 'alias-change');
const specDir = path.join(realChangeDir, 'specs', 'change-a');
const proposalPath = path.join(realChangeDir, 'proposal.md');
const specPath = path.join(specDir, 'spec.md');
fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(proposalPath, 'content');
fs.writeFileSync(specPath, 'content');
fs.symlinkSync(realChangeDir, aliasChangeDir, process.platform === 'win32' ? 'junction' : 'dir');
expect(resolveArtifactOutputs(aliasChangeDir, 'proposal.md')).toEqual([
canonical(proposalPath),
]);
expect(resolveArtifactOutputs(aliasChangeDir, 'specs/*/spec.md')).toEqual([
canonical(specPath),
]);
});
it('returns an empty list when no files match the artifact output', () => {
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([]);
expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(false);
});
});
+89 -1
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js';
import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js';
import { auggieAdapter } from '../../../src/core/command-generation/adapters/auggie.js';
import { bobAdapter } from '../../../src/core/command-generation/adapters/bob.js';
import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js';
import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js';
import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js';
@@ -183,6 +184,71 @@ describe('command-generation/adapters', () => {
});
});
describe('bobAdapter', () => {
it('should have correct toolId', () => {
expect(bobAdapter.toolId).toBe('bob');
});
it('should generate correct file path', () => {
const filePath = bobAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.bob', 'commands', 'opsx-explore.md'));
});
it('should generate correct file paths for different commands', () => {
expect(bobAdapter.getFilePath('new')).toBe(path.join('.bob', 'commands', 'opsx-new.md'));
expect(bobAdapter.getFilePath('bulk-archive')).toBe(path.join('.bob', 'commands', 'opsx-bulk-archive.md'));
});
it('should format file with description and argument-hint frontmatter', () => {
const output = bobAdapter.formatFile(sampleContent);
expect(output).toContain('---\n');
expect(output).toContain('description: Enter explore mode for thinking');
expect(output).toContain('argument-hint: command arguments');
expect(output).toContain('---\n\n');
expect(output).toContain('This is the command body.\n\nWith multiple lines.');
});
it('should transform colon command references to hyphen format', () => {
const contentWithRefs: CommandContent = {
...sampleContent,
body: 'Run /opsx:apply to implement. Then use /opsx:verify.',
};
const output = bobAdapter.formatFile(contentWithRefs);
expect(output).toContain('/opsx-apply');
expect(output).toContain('/opsx-verify');
expect(output).not.toContain('/opsx:apply');
expect(output).not.toContain('/opsx:verify');
});
it('should escape YAML special characters in description', () => {
const contentWithSpecialChars: CommandContent = {
...sampleContent,
description: 'Fix: regression in "auth" feature',
};
const output = bobAdapter.formatFile(contentWithSpecialChars);
expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"');
});
it('should escape newlines in description', () => {
const contentWithNewline: CommandContent = {
...sampleContent,
description: 'Line 1\nLine 2',
};
const output = bobAdapter.formatFile(contentWithNewline);
expect(output).toContain('description: "Line 1\\nLine 2"');
});
it('should handle empty description', () => {
const contentEmptyDesc: CommandContent = {
...sampleContent,
description: '',
};
const output = bobAdapter.formatFile(contentEmptyDesc);
expect(output).toContain('description: \n');
});
});
describe('clineAdapter', () => {
it('should have correct toolId', () => {
expect(clineAdapter.toolId).toBe('cline');
@@ -547,6 +613,28 @@ describe('command-generation/adapters', () => {
expect(output).toContain('This is the command body.');
});
it('should transform command references from colon to hyphen format', () => {
const contentWithRefs: CommandContent = {
...sampleContent,
body: 'Run /opsx:apply to implement. Then /opsx:archive when done.',
};
const output = piAdapter.formatFile(contentWithRefs);
expect(output).toContain('/opsx-apply');
expect(output).toContain('/opsx-archive');
expect(output).not.toContain('/opsx:apply');
});
it('should inject template arguments into the input section', () => {
const contentWithInput: CommandContent = {
...sampleContent,
body: '**Input**: The argument after `/opsx:explore` is the topic.\n\n**Steps**\n1. Think.',
};
const output = piAdapter.formatFile(contentWithInput);
expect(output).toContain('**Provided arguments**: $@');
});
it('should escape YAML special characters in description', () => {
const contentWithSpecialChars: CommandContent = {
...sampleContent,
@@ -606,7 +694,7 @@ describe('command-generation/adapters', () => {
it('All adapters use path.join for paths', () => {
// Verify all adapters produce valid paths
const adapters = [
amazonQAdapter, antigravityAdapter, auggieAdapter, clineAdapter,
amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter,
codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter,
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,
iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter,
@@ -21,6 +21,12 @@ describe('command-generation/registry', () => {
expect(adapter?.toolId).toBe('windsurf');
});
it('should return Junie adapter for "junie"', () => {
const adapter = CommandAdapterRegistry.get('junie');
expect(adapter).toBeDefined();
expect(adapter?.toolId).toBe('junie');
});
it('should return undefined for unregistered tool', () => {
const adapter = CommandAdapterRegistry.get('unknown-tool');
expect(adapter).toBeUndefined();
@@ -54,6 +60,7 @@ describe('command-generation/registry', () => {
expect(CommandAdapterRegistry.has('claude')).toBe(true);
expect(CommandAdapterRegistry.has('cursor')).toBe(true);
expect(CommandAdapterRegistry.has('windsurf')).toBe(true);
expect(CommandAdapterRegistry.has('junie')).toBe(true);
});
it('should return false for unregistered tools', () => {
@@ -544,6 +544,173 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
});
});
describe('encoding preservation', () => {
const mockScriptPath = '/path/to/OpenSpecCompletion.ps1';
const utf16leBom = Buffer.from([0xff, 0xfe]);
const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]);
/**
* Helper: write a file in UTF-16 LE with BOM, the way Windows PowerShell does.
*/
function writeUtf16LeFile(filePath: string, text: string): Promise<void> {
const body = Buffer.from(text, 'utf16le');
return fs.writeFile(filePath, Buffer.concat([utf16leBom, body]));
}
/**
* Helper: write a file in UTF-8 with BOM.
*/
function writeUtf8BomFile(filePath: string, text: string): Promise<void> {
const body = Buffer.from(text, 'utf-8');
return fs.writeFile(filePath, Buffer.concat([utf8Bom, body]));
}
it('should preserve UTF-16 LE BOM when configuring profile', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n';
await writeUtf16LeFile(profilePath, originalText);
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
// Read back raw bytes and verify BOM is preserved
const raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
// Decode and verify content is intact
const content = raw.subarray(2).toString('utf16le');
expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain(`. "${mockScriptPath}"`);
expect(content).toContain('# OPENSPEC:END');
});
it('should preserve UTF-16 LE BOM when removing profile config', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const textWithBlock = [
'. "C:\\Code\\profile.ps1"',
'# OPENSPEC:START',
'. "/path/to/OpenSpecCompletion.ps1"',
'# OPENSPEC:END',
'',
].join('\n');
await writeUtf16LeFile(profilePath, textWithBlock);
const result = await installer.removeProfileConfig();
expect(result).toBe(true);
// Verify BOM is preserved
const raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
// Verify content: original line kept, OpenSpec block removed
const content = raw.subarray(2).toString('utf16le');
expect(content).toContain('. "C:\\Code\\profile.ps1"');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).not.toContain('# OPENSPEC:END');
});
it('should preserve UTF-8 BOM when configuring profile', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await writeUtf8BomFile(profilePath, '# My profile\n');
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xef);
expect(raw[1]).toBe(0xbb);
expect(raw[2]).toBe(0xbf);
const content = raw.subarray(3).toString('utf-8');
expect(content).toContain('# My profile');
expect(content).toContain('# OPENSPEC:START');
});
it('should skip UTF-16 BE profile and leave it unchanged', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
process.env.PROFILE = path.join(testHomeDir, 'custom-profile.ps1');
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
// Write a fake UTF-16 BE file (FE FF BOM + some bytes)
const utf16beBom = Buffer.from([0xfe, 0xff]);
const body = Buffer.from([0x00, 0x23]); // '#' in UTF-16 BE
const originalBytes = Buffer.concat([utf16beBom, body]);
await fs.writeFile(profilePath, originalBytes);
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(false);
// File should be untouched
const raw = await fs.readFile(profilePath);
expect(Buffer.compare(raw, originalBytes)).toBe(0);
});
it('should handle plain UTF-8 files without BOM (no regression)', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await fs.writeFile(profilePath, '# Plain UTF-8\n', 'utf-8');
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const raw = await fs.readFile(profilePath);
// Should NOT have any BOM
expect(raw[0]).not.toBe(0xff);
expect(raw[0]).not.toBe(0xfe);
expect(raw[0]).not.toBe(0xef);
const content = raw.toString('utf-8');
expect(content).toContain('# Plain UTF-8');
expect(content).toContain('# OPENSPEC:START');
});
it('should round-trip UTF-16 LE through install → uninstall without corruption', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n';
await writeUtf16LeFile(profilePath, originalText);
// Install adds the OpenSpec block
const mockScript = '# completion script';
await installer.install(mockScript);
// Verify the profile was modified but encoding preserved
let raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
let content = raw.subarray(2).toString('utf16le');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain(originalText.trimEnd());
// Uninstall removes the OpenSpec block
await installer.uninstall();
raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
content = raw.subarray(2).toString('utf16le');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"');
});
});
describe('uninstall', () => {
const mockCompletionScript = `# PowerShell completion script
$openspecCompleter = {}
+65 -1
View File
@@ -102,6 +102,70 @@ This is a test spec`;
const parser = new MarkdownParser(content);
expect(() => parser.parseSpec('test')).toThrow('must have a Requirements section');
});
it('should ignore headings that appear inside fenced code blocks', () => {
const content = `# Test Spec
## Purpose
This spec documents delta syntax with a fenced example.
## Requirements
### Requirement: Explain delta syntax
The system SHALL allow quoted markdown examples without changing parsed structure.
\`\`\`markdown
## ADDED Requirements
### Requirement: Example
The system SHALL ...
\`\`\`
#### Scenario: reader follows the example
- **WHEN** a reader reviews the documentation
- **THEN** the fenced heading stays part of the example`;
const parser = new MarkdownParser(content);
const spec = parser.parseSpec('test');
expect(spec.requirements).toHaveLength(1);
expect(spec.requirements[0].text).toBe(
'The system SHALL allow quoted markdown examples without changing parsed structure.'
);
expect(spec.requirements[0].scenarios).toHaveLength(1);
expect(spec.requirements[0].scenarios[0].rawText).toContain('- **WHEN** a reader reviews the documentation');
});
it('should not treat fence-like lines with trailing content as closing fences', () => {
const content = `# Test Spec
## Purpose
This spec includes a fence-like line with trailing content inside a fenced block.
## Requirements
### Requirement: Explain fence parsing
The system SHALL keep fenced examples isolated until a real closing fence appears.
\`\`\`markdown
\`\`\` still inside the example
## ADDED Requirements
### Requirement: Example
The system SHALL remain part of the example.
\`\`\`
#### Scenario: reader follows the example
- **WHEN** a reader reviews the documentation
- **THEN** the parser ignores headings until the real closing fence`;
const parser = new MarkdownParser(content);
const spec = parser.parseSpec('test');
expect(spec.requirements).toHaveLength(1);
expect(spec.requirements[0].scenarios).toHaveLength(1);
expect(spec.requirements[0].scenarios[0].rawText).toContain('parser ignores headings until the real closing fence');
});
});
describe('parseChange', () => {
@@ -288,4 +352,4 @@ Then result`;
expect(spec.requirements[0].text).toBe('This is the actual requirement text.');
});
});
});
});
@@ -33,23 +33,23 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getExploreSkillTemplate: '3f73b4d7ab189ef6367fccc9d99308bee35c6a89dae4c8044582a01cb01b335b',
getNewChangeSkillTemplate: '5989672758eccf54e3bb554ab97f2c129a192b12bbb7688cc1ffcf6bccb1ae9d',
getContinueChangeSkillTemplate: 'f2e413f0333dfd6641cc2bd1a189273fdea5c399eecdde98ef528b5216f097b3',
getApplyChangeSkillTemplate: '26e52e67693e93fbcdd40dcd3e20949c07ce019183d55a8149d0260c791cd7f4',
getApplyChangeSkillTemplate: '6238712ba8cd2fd099c4f3bac13436f758fc6ac776fb8be19547f2b195240bfd',
getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9',
getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b',
getOnboardSkillTemplate: 'c9e719a02d2ae7f74a0e978f9ad4e767c1921248a9e3724c3321c58a15c38ba9',
getOpsxExploreCommandTemplate: 'b421b88c7a532385f7b1404736d7893eb35a05573b4a04a96f72379ac1bbf148',
getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364',
getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa',
getOpsxApplyCommandTemplate: 'a9d631a07fcd832b67d263ff3800b98604ab8d378baf1b0d545907ef3affa3b5',
getOpsxApplyCommandTemplate: 'f59cfe9482a1b29f64b9cd7396397991a2f00a5cb1abde4ab8b4757acf1678b9',
getOpsxFfCommandTemplate: 'cdebe872cc8e0fcc25c8864b98ffd66a93484c0657db94bd1285b8113092702a',
getArchiveChangeSkillTemplate: '6f8ca383fdb5a4eb9872aca81e07bf0ba7f25e4de8617d7a047ca914ca7f14b9',
getBulkArchiveChangeSkillTemplate: '8049897ce1ddb2ff6c0d4b72e22636f9ecfd083b5f2c2a30cf3bb1cb828a2f93',
getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf',
getVerifyChangeSkillTemplate: '63a213ba3b42af54a1cd56f5072234a03b265c3fe4a1da12cd6fbbef5ee46c4b',
getVerifyChangeSkillTemplate: '40dde29051a0ba204295b74e49e87b6e9ff30c8b89ff0e791b4f955b4595de59',
getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493',
getOpsxOnboardCommandTemplate: 'fce531f952e939ee85a41848fc21e4cc720b0f3eb62737adc3a51ee6ad2dfc57',
getOpsxBulkArchiveCommandTemplate: '0d77c82de43840a28c74f5181cb21e33b9a9d00454adf4bc92bdc9e69817d6f5',
getOpsxVerifyCommandTemplate: '9b4d3ca422553b7534764eb3a009da87a051612c5238e9baab294c7b1233e9a2',
getOpsxVerifyCommandTemplate: 'd7c0444863faabb16abb091bc40ee56d985ae4bfa9a4db1e622ca8ba03c32fed',
getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999',
getOpsxProposeCommandTemplate: '41ad59b37eafd7a161bab5c6e41997a37368f9c90b194451295ede5cd42e4d46',
getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d',
@@ -59,12 +59,12 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
'openspec-explore': '08e1ec9958eb04653707dd3e198c3fd69cf1b3acd3cf95a1022693cca83c60fc',
'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f',
'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960',
'openspec-apply-change': 'a0084442b59be9d7e22a0382a279d470501e1ecf74bdd5347e169951c9be191c',
'openspec-apply-change': '38ad2cb645827eda555f20e1ac9d483e1d75bae4c817c0669474aaa8c12c0421',
'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937',
'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde',
'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f',
'openspec-bulk-archive-change': '10477399bb07c7ba67f78e315bd68fb1901af8866720545baf4c62a6a679493b',
'openspec-verify-change': '30d07c6f7051965f624f5964db51844ec17c7dfd05f0da95281fe0ca73616326',
'openspec-verify-change': 'b6dc1b87940be9d6125b834831c8619019aec9a9748995f72bf981b6f08b67f8',
'openspec-onboard': 'c1444e026028210efd699110f7e9079bcb486d85ccf27f743213a81cb1084303',
'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7',
};
+1
View File
@@ -250,6 +250,7 @@ Old instructions content
expect(exists).toBe(false);
}
});
});
describe('multi-tool support', () => {
+105
View File
@@ -247,6 +247,111 @@ Then authenticated`;
expect(report.summary.errors).toBeGreaterThan(0);
expect(report.issues.some(i => i.message.includes('Purpose'))).toBe(true);
});
it('should error on delta headers inside a main spec', async () => {
const specContent = `# Test Specification
## Purpose
This specification validates that stray delta headers are rejected in main specs.
## Requirements
### Requirement: A
The system SHALL do A.
#### Scenario: A works
- **WHEN** foo
- **THEN** bar
## MODIFIED Requirements
### Requirement: B
The system SHALL do B.
#### Scenario: B works
- **WHEN** baz
- **THEN** qux`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const report = await new Validator().validateSpec(specPath);
expect(report.valid).toBe(false);
expect(
report.issues.some(i => i.level === 'ERROR' && i.message.includes('Main spec contains delta header'))
).toBe(true);
expect(
report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside'))
).toBe(true);
});
it('should error on requirement headers that appear after the Requirements section ends', async () => {
const specContent = `# Test Specification
## Purpose
This specification validates that hidden requirements are rejected even without delta headers.
## Requirements
### Requirement: A
The system SHALL do A.
#### Scenario: A works
- **WHEN** foo
- **THEN** bar
## Edge Cases
### Requirement: B
The system SHALL do B.
#### Scenario: B works
- **WHEN** baz
- **THEN** qux`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const report = await new Validator().validateSpec(specPath);
expect(report.valid).toBe(false);
expect(
report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside'))
).toBe(true);
});
it('should ignore delta header examples inside fenced code blocks', async () => {
const specContent = `# Test Specification
## Purpose
This specification documents delta syntax without being flagged for quoted examples.
## Requirements
### Requirement: Explain delta syntax
The system SHALL allow documentation specs to quote delta headers inside fenced code blocks.
\`\`\`markdown
## ADDED Requirements
### Requirement: Example
The system SHALL ...
\`\`\`
#### Scenario: reader follows the example
- **WHEN** a reader reviews the documentation
- **THEN** the quoted delta header remains an example only`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const report = await new Validator().validateSpec(specPath);
expect(report.valid).toBe(true);
expect(report.issues.some(i => i.message.includes('Main spec contains delta header'))).toBe(false);
expect(report.issues.some(i => i.message.includes('appears outside the main ## Requirements section'))).toBe(false);
});
});
describe('validateChange', () => {
+17 -5
View File
@@ -2,14 +2,19 @@ import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { describe, it, expect } from 'vitest';
import { MarkdownParser } from '../../src/core/parsers/markdown-parser.js';
import {
findMainSpecStructureIssues,
stripFencedCodeBlocksPreservingLines,
} from '../../src/core/parsers/spec-structure.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..', '..');
const specsRoot = path.join(projectRoot, 'openspec', 'specs');
const DELTA_HEADER_PATTERN = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/m;
const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created by archiving change .*?\. Update Purpose after archive\./;
const REQUIREMENT_HEADER_PATTERN = /^###\s+Requirement:/gm;
async function getSpecFiles(): Promise<string[]> {
const entries = await fs.readdir(specsRoot, { withFileTypes: true });
@@ -30,22 +35,29 @@ async function getSpecFiles(): Promise<string[]> {
}
describe('source-of-truth specs normalization', () => {
it('enforces required sections and bans archive placeholders/delta headers', async () => {
it('enforces required sections and bans hidden requirements, placeholders, and delta headers', async () => {
const files = await getSpecFiles();
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
const content = await fs.readFile(file, 'utf8');
const relativeFile = path.relative(projectRoot, file);
const structureIssues = findMainSpecStructureIssues(content);
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(path.basename(path.dirname(file)));
const rawRequirementCount =
stripFencedCodeBlocksPreservingLines(content).match(REQUIREMENT_HEADER_PATTERN)?.length ?? 0;
expect(content, `${relativeFile} must include ## Purpose`).toMatch(/^## Purpose$/m);
expect(content, `${relativeFile} must include ## Requirements`).toMatch(/^## Requirements$/m);
expect(content, `${relativeFile} must not include archive placeholder purpose text`).not.toMatch(
PURPOSE_PLACEHOLDER_PATTERN
);
expect(content, `${relativeFile} must not include delta headers in source-of-truth specs`).not.toMatch(
DELTA_HEADER_PATTERN
);
expect(structureIssues, `${relativeFile} must not contain hidden requirements or delta headers`).toHaveLength(0);
expect(
spec.requirements.length,
`${relativeFile} parsed requirement count must match visible requirement headers`
).toBe(rawRequirementCount);
}
});
});
+85 -1
View File
@@ -22,6 +22,7 @@ describe('telemetry/index', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let fetchSpy: ReturnType<typeof vi.spyOn<typeof globalThis, 'fetch'>>;
beforeEach(() => {
// Create unique temp directory for each test using UUID
@@ -39,9 +40,10 @@ describe('telemetry/index', () => {
// Spy on console.log for notice tests
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
afterEach(async () => {
// Restore original env
process.env = originalEnv;
@@ -52,6 +54,8 @@ describe('telemetry/index', () => {
// Ignore cleanup errors
}
await shutdown();
// Restore all mocks
vi.restoreAllMocks();
});
@@ -115,6 +119,86 @@ describe('telemetry/index', () => {
expect(PostHog).toHaveBeenCalled();
});
it('should construct PostHog with bounded silent-failure settings', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
expect(PostHog).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
host: 'https://edge.openspec.dev',
flushAt: 1,
flushInterval: 0,
fetchRetryCount: 0,
requestTimeout: 1000,
preloadFeatureFlags: false,
disableRemoteConfig: true,
disableSurveys: true,
fetch: expect.any(Function),
})
);
});
it('should return a synthetic success response when fetch throws a network error', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
fetchSpy.mockRejectedValueOnce(new Error('network down'));
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
expect(response.status).toBe(204);
});
it('should return a synthetic success response when fetch aborts', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
fetchSpy.mockRejectedValueOnce(new DOMException('This operation was aborted', 'AbortError'));
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
expect(response.status).toBe(204);
});
it('should return a synthetic success response for non-2xx responses', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
fetchSpy.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
expect(response.status).toBe(204);
});
it('should pass through successful responses from fetch', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch;
const expectedResponse = new Response(null, { status: 200 });
fetchSpy.mockResolvedValueOnce(expectedResponse);
const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' });
expect(response).toBe(expectedResponse);
});
});
describe('shutdown', () => {
+18 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as nodeFs from 'fs';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
@@ -92,6 +93,22 @@ describe('FileSystemUtils', () => {
});
});
describe('canonicalizeExistingPath', () => {
it('should prefer the native realpath resolver when available', async () => {
const filePath = path.join(testDir, 'canonical.txt');
await fs.writeFile(filePath, 'content');
const nativeSpy = vi.spyOn(nodeFs.realpathSync, 'native');
const resolved = FileSystemUtils.canonicalizeExistingPath(filePath);
expect(nativeSpy).toHaveBeenCalledWith(filePath);
expect(resolved).toBe(nodeFs.realpathSync.native(filePath));
nativeSpy.mockRestore();
});
});
describe('writeFile', () => {
it('should write content to file', async () => {
const filePath = path.join(testDir, 'output.txt');