Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale e5cd35ae29 docs: expand OPSX workflow section with detailed examples
Significantly expanded the versioning/archiving guide to better
address user concerns about parallel development conflicts:

- Added TL;DR with jump link to solution
- Expanded OPSX section from 8 lines to ~120 lines
- Added step-by-step authentication conflict example
- Added three-team concrete example showing all merge scenarios
- Added two workflow options (Friday batch vs sync-as-you-go)
- Added "What cases does this handle" section
- Updated quick reference card to lead with OPSX
- Restructured flow: Problem → OPSX Solution → Manual fallback
- Moved manual practices to "If You Can't Use OPSX Yet" section

The guide now clearly positions OPSX as the solution with
actionable examples instead of being buried as a light mention.
2026-01-13 13:12:11 -08:00
Tabish Bidiwale 33d8b9d96f docs: add versioning and archiving best practices guide
Add comprehensive guide covering:
- Silent data loss issue when multiple branches modify same requirements
- Best practices for batch archiving workflow
- Archive ordering strategies to prevent conflicts
- CodeRabbit configuration recommendations
- OPSX workflow as safer alternative
- Roadmap for upcoming conflict resolution features

Addresses common pain points teams encounter with parallel development
and weekly batch archiving workflows.
2026-01-13 10:21:18 -08:00
Tabish Bidiwale 36078b1947 chore: improve release notes pipeline (#481)
* chore: improve changelog generation with GitHub integration

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

* ci: add AI-powered release notes polishing

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

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

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

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

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

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

Requires APP_ID variable and APP_PRIVATE_KEY secret to be configured.

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

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

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

Requires APP_ID variable and APP_PRIVATE_KEY secret to be configured.

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

* ci: auto-merge version PR to streamline releases

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

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

* add update

* Update spec.md

* fix: correct Continue frontmatter format and README ordering

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

---------

Co-authored-by: ajuanli <ajuanli@tencent.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2026-01-10 15:54:09 -08:00
Tabish Bidiwale 4971cda812 fix: use actionCommand for telemetry command tracking (#472)
The preAction hook receives (thisCommand, actionCommand) where thisCommand
is the root program and actionCommand is the actual subcommand being run.
Using thisCommand was incorrectly tracking the root command instead of
the actual subcommand executed by the user.
2026-01-10 15:10:12 -08:00
Tabish Bidiwale 4715138927 fix: set USERPROFILE for Windows compatibility in telemetry tests (#469)
On Windows, os.homedir() uses the USERPROFILE environment variable
instead of HOME. The telemetry config tests were only mocking HOME,
causing them to fail on Windows CI.
2026-01-09 23:36:54 -08:00
Tabish Bidiwale 940898c1c5 Add optional anonymous usage statistics (#468)
* chore: add proposal for PostHog analytics integration

Introduces the proposal artifact for adding opt-in telemetry to OpenSpec
using PostHog. Covers command tracking, feature adoption metrics, and
privacy-respecting consent management.

* feat: add optional anonymous usage statistics

Introduces privacy-first usage analytics to help understand how OpenSpec
is being used. Key privacy protections:

- Only tracks command names and version (no arguments, paths, or content)
- Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
- Auto-disabled in CI environments
- No IP address collection (explicitly disabled)
- Anonymous ID is a random UUID with no PII

Uses PostHog with a reverse proxy to avoid ad blockers. First-run shows
a one-line notice informing users about the collection.

* docs: make telemetry section collapsible and concise
2026-01-09 23:10:31 -08:00
29 changed files with 2033 additions and 10 deletions
+93 -4
View File
@@ -1,6 +1,95 @@
This directory is managed by Changesets.
# Changesets
- Add a changeset locally with `pnpm changeset`.
- The CI "Release (prepare)" workflow opens/updates a Version Packages PR.
- Publishing happens from a GitHub Release via the "Publish to npm" workflow.
This directory is managed by [Changesets](https://github.com/changesets/changesets).
## Quick Start
```bash
pnpm changeset
```
Follow the prompts to select version bump type and describe your changes.
## Workflow
1. **Add a changeset** — Run `pnpm changeset` locally before or after your PR
2. **Version PR** — CI opens/updates a "Version Packages" PR when changesets merge to main
3. **Release** — Merging the Version PR triggers npm publish and GitHub Release
> **Note:** Contributors only need to run `pnpm changeset`. Versioning (`changeset version`) and publishing happen automatically in CI.
## Template
Use this structure for your changeset content:
```markdown
---
"@fission-ai/openspec": patch
---
### New Features
- **Feature name** — What users can now do
### Bug Fixes
- Fixed issue where X happened when Y
### Breaking Changes
- `oldMethod()` has been removed, use `newMethod()` instead
### Deprecations
- `legacyOption` is deprecated and will be removed in v2.0
### Other
- Internal refactoring of X for better performance
```
Include only the sections relevant to your change.
## Version Bump Guide
| Type | When to use | Example |
|------|-------------|---------|
| `patch` | Bug fixes, small improvements | Fixed crash when config missing |
| `minor` | New features, non-breaking additions | Added `--verbose` flag |
| `major` | Breaking changes, removed features | Renamed `init` to `setup` |
## When to Create a Changeset
**Create one for:**
- New features or commands
- Bug fixes that affect users
- Breaking changes or deprecations
- Performance improvements users would notice
**Skip for:**
- Documentation-only changes
- Test additions/fixes
- Internal refactoring with no user impact
- CI/tooling changes
## Writing Good Descriptions
**Do:** Write for users, not developers
```markdown
- **Shell completions** — Tab completion now available for Bash, Fish, and PowerShell
```
**Don't:** Write implementation details
```markdown
- Added ShellCompletionGenerator class with Bash/Fish/PowerShell subclasses
```
**Do:** Explain the impact
```markdown
- Fixed config loading to respect `XDG_CONFIG_HOME` on Linux
```
**Don't:** Just reference the fix
```markdown
- Fixed #123
```
+4 -1
View File
@@ -1,6 +1,9 @@
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"changelog": [
"@changesets/changelog-github",
{ "repo": "Fission-AI/OpenSpec" }
],
"commit": false,
"fixed": [],
"linked": [],
+137
View File
@@ -0,0 +1,137 @@
name: Polish Release Notes
# Triggers when changesets creates a release
on:
release:
types: [published]
permissions:
contents: write
jobs:
polish:
# Only run on the main repo, not forks
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get current release body
id: get-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "${{ github.event.release.tag_name }}" --json body -q '.body' > current-notes.md
echo "Fetched release notes for ${{ github.event.release.tag_name }}"
- name: Transform release notes with Claude
uses: anthropics/claude-code-action@v1
id: claude
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
direct_prompt: |
Transform the changelog in `current-notes.md` into release notes for OpenSpec ${{ github.event.release.tag_name }}.
## Voice
OpenSpec is a developer tool. Write like you're talking to a peer:
- Direct and practical, not marketing copy
- Focus on what changed and why it matters
- Skip the hype, keep it real
## Output
Create two files:
### 1. `release-title.txt`
A short title in this format:
```
${{ github.event.release.tag_name }} - [1-4 words describing the release]
```
Examples:
- `v0.18.0 - OPSX Experimental Workflow`
- `v0.16.0 - Antigravity, iFlow Support`
- `v0.15.0 - Gemini CLI, RooCode`
Rules for title:
- Lead with the most notable addition
- 1-4 words after the dash, no fluff
- If multiple features, comma-separate the top 2
- For bugfix-only releases, use something like `v0.17.2 - Pre-commit Hook Fix`
### 2. `polished-notes.md`
```markdown
## What's New in ${{ github.event.release.tag_name }}
[One sentence: what's the theme of this release?]
### New
- **Feature name** - What it does and why you'd use it
### Improved
- **Area** - What got better
### Fixed
- What was broken, now works
```
Omit empty sections.
## Rules
1. Write for developers using OpenSpec with AI coding assistants
2. Remove commit hashes (like `eb152eb:`), PR numbers, and changesets wrappers (`### Minor Changes`)
3. Lead with what users can do, not implementation details
4. One to two sentences per item, max
5. Use **bold** for feature/area names
6. Skip internal changes (CI, refactors, tests) unless they affect users
7. If the input is already well-formatted, just clean up structure and remove noise
## Example
Before:
```
### Minor Changes
- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system
**New Commands:**
- `/opsx:ff` - Fast-forward through artifact creation
```
After (polished-notes.md):
```
### New
- **Fast-forward mode** - Generate all planning artifacts at once with `/opsx:ff`. Useful when you already know what you're building.
```
After (release-title.txt):
```
v0.18.0 - OPSX Experimental Workflow
```
Write both files. No other output.
- name: Update release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
if [ -f "polished-notes.md" ] && [ -f "release-title.txt" ]; then
TITLE=$(cat release-title.txt)
gh release edit "$TAG" --title "$TITLE" --notes-file polished-notes.md
echo "Updated: $TITLE"
elif [ -f "polished-notes.md" ]; then
gh release edit "$TAG" --notes-file polished-notes.md
echo "Updated notes (title unchanged)"
else
echo "No changes generated, keeping original"
fi
+12 -1
View File
@@ -18,9 +18,20 @@ jobs:
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
# Generate GitHub App token first - used for checkout and changesets
# This allows git operations to trigger CI workflows on the version PR
# (GITHUB_TOKEN cannot trigger workflows by design)
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v4
with:
@@ -44,5 +55,5 @@ jobs:
# so package.json already contains the bumped version.
publish: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# npm authentication handled via OIDC trusted publishing (no token needed)
+22
View File
@@ -1,5 +1,25 @@
# @fission-ai/openspec
## 0.19.0
### Minor Changes
- eb152eb: ### New Features
- **Continue IDE support** – OpenSpec now generates slash commands for [Continue](https://continue.dev/), expanding editor integration options alongside Cursor, Windsurf, Claude Code, and others
- **Shell completions for Bash, Fish, and PowerShell** – Run `openspec completion install` to set up tab completion in your preferred shell
- **`/opsx:explore` command** – A new thinking partner mode for exploring ideas and investigating problems before committing to changes
- **Codebuddy slash command improvements** – Updated frontmatter format for better compatibility
### Bug Fixes
- Shell completions now correctly offer parent-level flags (like `--help`) when a command has subcommands
- Fixed Windows compatibility issues in tests
### Other
- Added optional anonymous usage statistics to help understand how OpenSpec is used. This is **opt-out** by default – set `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` to disable. Only command names and version are collected; no arguments, file paths, or content. Automatically disabled in CI environments.
## 0.18.0
### Minor Changes
@@ -86,6 +106,8 @@
### Minor Changes
- Add Continue slash command support so `openspec init` can generate `.continue/prompts/openspec-*.prompt` files with MARKDOWN frontmatter and `$ARGUMENTS` placeholder, and refresh them on `openspec update`.
- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.
## 0.15.0
+10
View File
@@ -103,6 +103,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
@@ -410,6 +411,15 @@ You can always go back:
</details>
<details>
<summary><strong>Telemetry</strong> – OpenSpec collects anonymous usage stats (opt-out: <code>OPENSPEC_TELEMETRY=0</code>)</summary>
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
</details>
## Contributing
- Install dependencies: `pnpm install`
+329
View File
@@ -0,0 +1,329 @@
# OpenSpec Versioning & Archiving: Quick Guide
> **TL;DR:** When multiple branches modify the same spec, the last archive wins (data loss). **Solution:** Use the OPSX workflow (`/opsx:sync`) for AI-driven intelligent merging. Setup: `openspec artifact-experimental-setup`. [Jump to solution](#the-solution-opsx-workflow)
## The Core Problem
**OpenSpec can silently lose changes when multiple branches modify the same requirement:**
```
Week 1:
┌─────────────┐
│ Branch A │ Adds: "Support SSO login"
│ (Feature) │ To requirement: "Authentication Methods"
└──────┬──────┘
│ Archives Monday ✓
▼
Main Spec now has: [Password, OAuth, SSO]
Week 1:
┌─────────────┐
│ Branch B │ Adds: "Support biometric login"
│ (Feature) │ To requirement: "Authentication Methods"
└──────┬──────┘ (Started from pre-SSO spec)
│ Archives Friday
▼
Main Spec now has: [Password, OAuth, Biometric] ← SSO disappeared! 😱
```
**Why?** OpenSpec replaces entire requirement blocks. No Git-like merging. No conflict detection.
## Workflow Risk
```
Multiple feature branches
│
├─── Change X: updates "Auth" requirement
├─── Change Y: updates "Auth" requirement
├─── Change Z: updates "Logging" requirement
│
Weekly batch archive (Friday)
│
└─── Last archive wins, earlier scenarios may vanish
```
## The Solution: OPSX Workflow
> **Requirements:** Claude Code, experimental workflow (stable and actively used)
The **OPSX workflow** solves the conflict problem with **AI-driven intelligent merging**. Instead of mechanical replacement, Claude reads your delta specs and understands intent.
**Available today.** Setup takes 30 seconds.
### Quick Comparison
```
Standard Archive: OPSX Sync:
┌──────────────────┐ ┌──────────────────┐
│ Replace entire │ │ AI-driven merge │
│ requirement │ │ Preserves │
│ block │ │ unmentioned │
│ │ │ scenarios │
│ Last write wins │ │ Intelligent │
│ ⚠️ Data loss │ │ ✓ Safe │
└──────────────────┘ └──────────────────┘
```
### How It Works: Your Authentication Example
Let's revisit the SSO/Biometric conflict with OPSX:
```
Week 1:
┌─────────────┐
│ Branch A │ Delta: "ADDED Requirements: Authentication Methods with SSO"
│ (Feature) │
└──────┬──────┘
│ Friday: /opsx:sync feature-a
▼
Main Spec: [Password, OAuth, SSO] ✓
┌─────────────┐
│ Branch B │ Delta: "ADDED Requirements: Authentication Methods with Biometric"
│ (Feature) │ (Delta created from pre-SSO spec)
└──────┬──────┘
│ Friday: /opsx:sync feature-b
│
│ What Claude does:
│ 1. Reads delta B: "Add Authentication Methods with Biometric"
│ 2. Reads main: "Already has Authentication Methods with SSO"
│ 3. Applies rule: "Preserve existing content not mentioned in delta"
│ 4. Merges: Keep SSO + OAuth, add Biometric
▼
Main Spec: [Password, OAuth, SSO, Biometric] ✓✓ ← Both changes preserved!
```
**Key principle:** Claude treats deltas as **instructions** ("add this scenario"), not **replacements** ("make it look exactly like this").
### Your Workflow Options
**Option 1: Friday Batch (Keeps Your Current Rhythm)**
```
Throughout week:
├─ Features complete on branches
└─ Code merges to main
Friday:
┌─────────────────────────────────────────────────┐
│ /opsx:sync feature-a │
│ ↓ Main updated with A's changes │
│ │
│ /opsx:sync feature-b │
│ ↓ Claude preserves A's changes, adds B │
│ │
│ /opsx:sync feature-c │
│ ↓ Claude preserves A+B, adds C │
│ │
│ git diff openspec/specs/ (review all) │
│ │
│ /opsx:archive feature-a │
│ /opsx:archive feature-b │
│ /opsx:archive feature-c │
└─────────────────────────────────────────────────┘
```
**Option 2: Sync-as-you-go (Even Safer)**
```
Monday: Feature A merges → /opsx:sync feature-a
Wednesday: Feature B merges → /opsx:sync feature-b (auto-merges with A)
Friday: Just /opsx:archive (specs already synced)
```
### Concrete Example: Three Teams, One Requirement
**Setup:**
- Change A: Adds SSO login
- Change B: Adds Biometric login
- Change C: Updates OAuth token expiry 1h → 2h
**Main spec starts with:**
```markdown
### Requirement: Authentication Methods
#### Scenario: Password login
...
#### Scenario: OAuth login (expires in 1h)
...
```
**Friday batch sync:**
```
/opsx:sync feature-a
├─ Delta A: ADDED "SSO login" scenario
└─ Main spec: [Password, OAuth, SSO]
/opsx:sync feature-b
├─ Delta B: ADDED "Biometric login" scenario
├─ Claude sees: Main already has SSO (not in delta B)
├─ Claude preserves: SSO
└─ Main spec: [Password, OAuth, SSO, Biometric]
/opsx:sync feature-c
├─ Delta C: MODIFIED "OAuth login" scenario (2h expiry)
├─ Claude sees: Main has SSO + Biometric (not in delta C)
├─ Claude preserves: SSO + Biometric
├─ Claude updates: OAuth expiry
└─ Main spec: [Password, OAuth (2h), SSO, Biometric]
```
**Result:** All three changes merged successfully! 🎉
### Getting Started
**1. One-time setup:**
```bash
openspec artifact-experimental-setup
# Creates /opsx:* skills in .claude/skills/
```
**2. Try it this Friday:**
```
# In Claude Code:
/opsx:sync
→ Select a change
→ Claude merges intelligently
git diff openspec/specs/
→ Review what changed
/opsx:archive
→ Move to archive when satisfied
```
**3. For new features:**
```
/opsx:new feature-name # Start new change
/opsx:ff # Create planning artifacts
/opsx:apply # Implement
/opsx:sync # Merge specs to main
/opsx:archive # Archive when done
```
### What Cases Does This Handle?
```
✓ Multiple changes adding different scenarios to same requirement
✓ One change adds, another modifies same requirement
✓ Changes affecting different parts of same requirement
✓ Mix of ADDED/MODIFIED/REMOVED operations
✓ Most real-world parallel development
⚠️ Two changes modifying exact same scenario in conflicting ways
⚠️ One removes what another modifies
Best practice: Always review git diff after each sync
```
### Why This Works
The `/opsx:sync` skill instructs Claude:
> "Preserve scenarios/content not mentioned in the delta. The delta represents *intent*, not a wholesale replacement. Apply changes intelligently - add new scenarios without removing existing ones, update only what's explicitly mentioned."
This is like having a human do the merge instead of a mechanical diff tool.
## If You Can't Use OPSX Yet
If you're not using Claude Code or can't adopt OPSX yet, here are manual workarounds:
### Before Creating Changes
```bash
# Check for conflicts
openspec list
ls openspec/changes/*/specs/your-capability/
# If overlap exists:
→ Coordinate with that team
→ Consider combining changes
→ Or sequence them (one after another)
```
### Archive Order Strategy
```
Friday Archive Session:
Step 1: Group by overlap
┌─────────────────────────────────────┐
│ No Overlap: [Z, W, V] │ ← Archive these first
│ Low Overlap: [A touches auth only] │ ← Then these
│ High Overlap:[X, Y both touch auth] │ ← Coordinate these last
└─────────────────────────────────────┘
Step 2: Archive in order
Z → W → V → A → (manually check X vs Y) → X → fix Y's delta → Y
↑
Key step: update Y's delta spec
to include X's changes before
archiving Y
```
### Safety Checklist
```bash
□ git diff main -- openspec/specs/your-capability/
□ openspec validate --strict
□ Manual review: did another change land on this spec?
□ If yes: update your delta spec to include their scenarios
```
## Your Action Plan
```
This Week:
├─ Run: openspec artifact-experimental-setup
└─ Try /opsx:sync on one change
Next Sprint:
├─ Adopt /opsx:sync for all Friday archives
└─ Switch to sync-as-you-go if it works well
Ongoing:
└─ Always run git diff after /opsx:sync to review
```
## Quick Reference Card
```
┌─────────────────────────────────────────────────────────────┐
│ OPENSPEC CONFLICT PREVENTION CHEAT SHEET │
├─────────────────────────────────────────────────────────────┤
│ │
│ RECOMMENDED: Use OPSX Workflow │
│ ✓ openspec artifact-experimental-setup (one-time) │
│ ✓ /opsx:sync after each feature │
│ ✓ git diff openspec/specs/ (review) │
│ ✓ /opsx:archive when done │
│ │
│ FRIDAY BATCH WORKFLOW: │
│ 1. /opsx:sync feature-a │
│ 2. /opsx:sync feature-b (AI merges with A) │
│ 3. /opsx:sync feature-c (AI merges with A+B) │
│ 4. git diff openspec/specs/ (review all) │
│ 5. /opsx:archive each │
│ │
│ IF NOT USING OPSX: │
│ □ openspec list (check overlaps) │
│ □ Coordinate with other teams │
│ □ Archive non-overlapping first │
│ □ Manually merge conflicts in delta specs │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Key Insight
> **OpenSpec delta specs are instructions, not files.** Standard archiving does mechanical replacement (last write wins). OPSX workflow uses AI to understand intent and merge intelligently (all writes preserved). Use OPSX for parallel development.
## Additional Resources
- **Detailed Technical Analysis:** [openspec-parallel-merge-plan.md](../openspec-parallel-merge-plan.md)
- **OPSX Workflow Guide:** [experimental-workflow.md](./experimental-workflow.md)
- **Join Discussion:** [OpenSpec Discord](https://discord.gg/BYjPaKbqMt)
- **Report Issues:** [GitHub Issues](https://github.com/Fission-AI/openspec/issues)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-10
@@ -0,0 +1,175 @@
## Context
OpenSpec needs usage analytics to understand adoption and inform product decisions. PostHog provides a privacy-conscious analytics platform suitable for open source projects.
## Goals / Non-Goals
**Goals:**
- Track daily/weekly/monthly active usage
- Understand command usage patterns
- Keep implementation minimal and privacy-respecting
- Enable opt-out with minimal friction
**Non-Goals:**
- Detailed error tracking or diagnostics
- User identification or profiling
- Complex event hierarchies
- Full CLI command for telemetry management (env var sufficient for now)
## Decisions
### Opt-Out Model
**Decision:** Telemetry enabled by default, opt-out via environment variable.
```bash
OPENSPEC_TELEMETRY=0 # Disable telemetry
DO_NOT_TRACK=1 # Industry standard, also respected
```
Auto-disabled when `CI=true` is detected.
**Rationale:**
- Opt-in typically yields ~3% participation—not enough for meaningful data
- Understanding usage patterns requires statistically significant sample sizes
- Environment variable opt-out is simple and immediate
- Respecting `DO_NOT_TRACK` follows industry convention
**Alternatives considered:**
- Opt-in only - Insufficient data for product decisions
- Config file setting - More complex, env var sufficient for MVP
- Full `openspec telemetry` command - Can add later if users request
### Event Design
**Decision:** Single event type with minimal properties.
```typescript
{
event: 'command_executed',
properties: {
command: 'init', // Command name only
version: '1.2.3' // OpenSpec version
}
}
```
**Rationale:**
- Answers the core questions: how much usage, which commands are popular
- PostHog derives DAU/WAU/MAU from anonymous user counts over time
- No arguments, paths, or content—clean privacy story
- Easy to explain in disclosure notice
**Not tracked:**
- Command arguments
- File paths or contents
- Error messages or stack traces
- Project names or spec content
- IP addresses (`$ip: null` explicitly set)
### Anonymous ID
**Decision:** Random UUID, lazily generated on first telemetry send, stored in global config.
```typescript
// ~/.config/openspec/config.json
{
"telemetry": {
"anonymousId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
```
**Rationale:**
- Random UUID has no relation to the person—can't be reversed
- Stored in config so same user = same ID across sessions (needed for DAU/WAU/MAU)
- Lazy generation means no ID created if user opts out before first command
- User can delete config to reset identity
**Alternatives considered:**
- Machine-derived hash (hostname, MAC) - Feels invasive, fingerprint-like
- Per-session UUID - Breaks user counting metrics entirely
### SDK Configuration
**Decision:** PostHog Node SDK with immediate flush, shutdown on exit.
```typescript
const posthog = new PostHog(API_KEY, {
flushAt: 1, // Send immediately, don't batch
flushInterval: 0 // No timer-based flushing
});
// Before CLI exits
await posthog.shutdown();
```
**Rationale:**
- CLI processes are short-lived; batching would lose events
- `flushAt: 1` ensures each event sends immediately
- `shutdown()` guarantees flush before process exit
- Adds ~100-300ms to exit—negligible for typical CLI workflows
**Error handling:**
- Network failures silently ignored (telemetry shouldn't break CLI)
- `shutdown()` wrapped in try/catch
### Hook Location
**Decision:** Commander.js `preAction` and `postAction` hooks.
```typescript
program
.hook('preAction', (thisCommand) => {
maybeShowTelemetryNotice();
trackCommand(thisCommand.name(), VERSION);
})
.hook('postAction', async () => {
await shutdown();
});
```
**Rationale:**
- Centralized—one place for all telemetry logic
- Automatic—new commands get tracked without code changes
- Clean separation—command handlers don't know about telemetry
**Subcommand handling:**
- Track full command path for nested commands (e.g., `change:apply`)
### First-Run Notice
**Decision:** One-liner on first command ever, stored "seen" flag in config.
```
Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0
```
**Rationale:**
- First command (not just `init`) ensures notice is always seen
- Non-blocking—no prompt, just informational
- One-liner is visible but not intrusive
- Storing "seen" in config prevents repeated display
**Config after first run:**
```json
{
"telemetry": {
"anonymousId": "...",
"noticeSeen": true
}
}
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Users prefer opt-in | Clear disclosure, trivial opt-out, transparent about what's collected |
| GDPR concerns | No personal data, no IP, user can delete config |
| Slows CLI exit by ~200ms | Negligible for most workflows; can optimize if needed |
| PostHog outage affects CLI | Fire-and-forget with timeout; failures are silent |
## Open Questions
None—design is intentionally minimal. Future enhancements (dedicated command, workflow tracking) can be added based on user feedback.
@@ -0,0 +1,37 @@
## Why
OpenSpec currently has no visibility into how the tool is being used. Without analytics, we cannot:
- Understand which commands and features are most valuable to users
- Measure adoption and usage patterns
- Make data-driven decisions about product development
Adding PostHog analytics enables product insights while respecting user privacy through transparent, opt-out telemetry.
## What Changes
- Add PostHog Node.js SDK as a dependency
- Implement telemetry system with environment variable opt-out
- Track command usage (command name and version only)
- Show first-run notice informing users about telemetry
- Store anonymous ID in global config (`~/.config/openspec/config.json`)
- Respect `DO_NOT_TRACK` and `OPENSPEC_TELEMETRY=0` environment variables
- Auto-disable in CI environments
## Capabilities
### New Capabilities
- `telemetry`: Anonymous usage analytics using PostHog. Covers command tracking, opt-out controls, and first-run disclosure notice.
### Modified Capabilities
- `global-config`: Add telemetry state storage (anonymous ID, notice seen flag)
## Impact
- **Dependencies**: Add `posthog-node` package
- **Privacy**: Opt-out via env var, no personal data collected, clear disclosure
- **Configuration**: New global config fields for telemetry state
- **Network**: Async event sending with flush on exit (~100-300ms added)
- **CI/CD**: Telemetry auto-disabled when `CI=true`
- **Documentation**: Update README with telemetry disclosure
@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: Global configuration storage
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
#### Scenario: Initial config creation
- **WHEN** no global config file exists
- **AND** the first telemetry event is about to be sent
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
#### Scenario: Telemetry config structure
- **WHEN** reading or writing telemetry configuration
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
#### Scenario: Config file format
- **WHEN** storing configuration
- **THEN** the system writes valid JSON that can be read and modified by users
#### Scenario: Existing config preservation
- **WHEN** adding telemetry fields to an existing config file
- **THEN** the system preserves all existing configuration fields
@@ -0,0 +1,116 @@
## ADDED Requirements
### Requirement: Command execution tracking
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
#### Scenario: Standard command execution
- **WHEN** a user runs any openspec command
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
#### Scenario: Subcommand execution
- **WHEN** a user runs a nested command like `openspec change apply`
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
### Requirement: Privacy-preserving event design
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
#### Scenario: Command with arguments
- **WHEN** a user runs `openspec init my-project --force`
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
#### Scenario: IP address exclusion
- **WHEN** the system sends a telemetry event
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
### Requirement: Environment variable opt-out
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
#### Scenario: OPENSPEC_TELEMETRY opt-out
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: DO_NOT_TRACK opt-out
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: Environment variable takes precedence
- **WHEN** the user has previously used the CLI (config exists)
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
- **THEN** telemetry is disabled regardless of config state
### Requirement: CI environment auto-disable
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
#### Scenario: CI environment detection
- **WHEN** `CI=true` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: CI with explicit enable
- **WHEN** `CI=true` is set
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
### Requirement: Anonymous user identification
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
#### Scenario: First telemetry event
- **WHEN** the first telemetry event is sent
- **AND** no anonymousId exists in config
- **THEN** the system generates a random UUID v4 and stores it in config
#### Scenario: Persistent identity
- **WHEN** a user runs multiple commands across sessions
- **THEN** the same anonymousId is used for all events
#### Scenario: Lazy generation with opt-out
- **WHEN** a user opts out before running any command
- **THEN** no anonymousId is ever generated or stored
### Requirement: Immediate event sending
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
#### Scenario: Event transmission timing
- **WHEN** a command executes
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
### Requirement: Graceful shutdown
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
#### Scenario: Normal exit
- **WHEN** a command completes successfully
- **THEN** the system awaits `shutdown()` before exiting
#### Scenario: Error exit
- **WHEN** a command fails with an error
- **THEN** the system still awaits `shutdown()` before exiting
### Requirement: Silent failure handling
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
#### Scenario: Network failure
- **WHEN** the telemetry request fails due to network error
- **THEN** the CLI command completes normally without error message
#### Scenario: PostHog outage
- **WHEN** PostHog service is unavailable
- **THEN** the CLI command completes normally without error message
#### Scenario: Shutdown failure
- **WHEN** `shutdown()` fails or times out
- **THEN** the CLI exits normally without error message
@@ -0,0 +1,47 @@
## 1. Setup
- [x] 1.1 Add `posthog-node` package as a dependency
- [x] 1.2 Create `src/telemetry/` module directory
- [x] 1.3 Add PostHog API key configuration (environment variable or embedded)
## 2. Global Config
- [x] 2.1 Create or extend global config module for `~/.config/openspec/config.json`
- [x] 2.2 Implement read/write functions that preserve existing config fields
- [x] 2.3 Define telemetry config structure (`anonymousId`, `noticeSeen`)
## 3. Core Telemetry Module
- [x] 3.1 Implement `isTelemetryEnabled()` checking `OPENSPEC_TELEMETRY`, `DO_NOT_TRACK`, and `CI` env vars
- [x] 3.2 Implement `getOrCreateAnonymousId()` with lazy UUID generation
- [x] 3.3 Initialize PostHog client with `flushAt: 1` and `flushInterval: 0`
- [x] 3.4 Implement `trackCommand(commandName, version)` with `$ip: null`
- [x] 3.5 Implement `shutdown()` with try/catch for silent failure handling
## 4. First-Run Notice
- [x] 4.1 Implement `maybeShowTelemetryNotice()` function
- [x] 4.2 Check `noticeSeen` flag before displaying notice
- [x] 4.3 Display notice text: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
- [x] 4.4 Update `noticeSeen` in config after first display
## 5. CLI Integration
- [x] 5.1 Add Commander.js `preAction` hook to show notice and track command
- [x] 5.2 Add Commander.js `postAction` hook to call shutdown
- [x] 5.3 Handle subcommand path extraction (e.g., `change:apply`)
## 6. Testing
- [x] 6.1 Test opt-out via `OPENSPEC_TELEMETRY=0`
- [x] 6.2 Test opt-out via `DO_NOT_TRACK=1`
- [x] 6.3 Test auto-disable in CI environment
- [x] 6.4 Test first-run notice display and noticeSeen persistence
- [x] 6.5 Test anonymous ID generation and persistence
- [x] 6.6 Test silent failure on network error (mock PostHog)
## 7. Documentation
- [x] 7.1 Add telemetry disclosure section to README
- [x] 7.2 Document opt-out methods (`OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`)
- [x] 7.3 Document what data is collected and not collected
+6
View File
@@ -211,6 +211,12 @@ The init command SHALL generate slash command files for supported editors using
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Continue
- **WHEN** the user selects Continue during initialization
- **THEN** create `.continue/prompts/openspec-proposal.prompt`, `.continue/prompts/openspec-apply.prompt`, and `.continue/prompts/openspec-archive.prompt`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
+5
View File
@@ -75,6 +75,11 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Continue
- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
+20
View File
@@ -4,6 +4,26 @@
This spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules.
## Requirements
### Requirement: Global configuration storage
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
#### Scenario: Initial config creation
- **WHEN** no global config file exists
- **AND** the first telemetry event is about to be sent
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
#### Scenario: Telemetry config structure
- **WHEN** reading or writing telemetry configuration
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
#### Scenario: Config file format
- **WHEN** storing configuration
- **THEN** the system writes valid JSON that can be read and modified by users
#### Scenario: Existing config preservation
- **WHEN** adding telemetry fields to an existing config file
- **THEN** the system preserves all existing configuration fields
### Requirement: Global Config Directory Path
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
+122
View File
@@ -0,0 +1,122 @@
# telemetry Specification
## Purpose
This spec defines how OpenSpec collects anonymous usage telemetry to help improve the tool. It governs the `src/telemetry/` module, which handles PostHog integration, privacy-preserving event design, user opt-out mechanisms, and first-run notice display. The spec ensures telemetry is minimal, transparent, and respects user privacy.
## Requirements
### Requirement: Command execution tracking
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
#### Scenario: Standard command execution
- **WHEN** a user runs any openspec command
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
#### Scenario: Subcommand execution
- **WHEN** a user runs a nested command like `openspec change apply`
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
### Requirement: Privacy-preserving event design
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
#### Scenario: Command with arguments
- **WHEN** a user runs `openspec init my-project --force`
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
#### Scenario: IP address exclusion
- **WHEN** the system sends a telemetry event
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
### Requirement: Environment variable opt-out
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
#### Scenario: OPENSPEC_TELEMETRY opt-out
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: DO_NOT_TRACK opt-out
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: Environment variable takes precedence
- **WHEN** the user has previously used the CLI (config exists)
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
- **THEN** telemetry is disabled regardless of config state
### Requirement: CI environment auto-disable
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
#### Scenario: CI environment detection
- **WHEN** `CI=true` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: CI with explicit enable
- **WHEN** `CI=true` is set
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
### Requirement: Anonymous user identification
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
#### Scenario: First telemetry event
- **WHEN** the first telemetry event is sent
- **AND** no anonymousId exists in config
- **THEN** the system generates a random UUID v4 and stores it in config
#### Scenario: Persistent identity
- **WHEN** a user runs multiple commands across sessions
- **THEN** the same anonymousId is used for all events
#### Scenario: Lazy generation with opt-out
- **WHEN** a user opts out before running any command
- **THEN** no anonymousId is ever generated or stored
### Requirement: Immediate event sending
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
#### Scenario: Event transmission timing
- **WHEN** a command executes
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
### Requirement: Graceful shutdown
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
#### Scenario: Normal exit
- **WHEN** a command completes successfully
- **THEN** the system awaits `shutdown()` before exiting
#### Scenario: Error exit
- **WHEN** a command fails with an error
- **THEN** the system still awaits `shutdown()` before exiting
### Requirement: Silent failure handling
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
#### Scenario: Network failure
- **WHEN** the telemetry request fails due to network error
- **THEN** the CLI command completes normally without error message
#### Scenario: PostHog outage
- **WHEN** PostHog service is unavailable
- **THEN** the CLI command completes normally without error message
#### Scenario: Shutdown failure
- **WHEN** `shutdown()` fails or times out
- **THEN** the CLI exits normally without error message
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.18.0",
"version": "0.19.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -54,13 +54,13 @@
"check:pack-version": "node scripts/pack-version-check.mjs",
"release": "pnpm run release:ci",
"release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
"release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
"changeset": "changeset"
},
"engines": {
"node": ">=20.19.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.2",
"@changesets/cli": "^2.27.7",
"@types/node": "^24.2.0",
"@vitest/ui": "^3.2.4",
@@ -76,6 +76,7 @@
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"posthog-node": "^5.20.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
+84
View File
@@ -26,6 +26,9 @@ importers:
ora:
specifier: ^8.2.0
version: 8.2.0
posthog-node:
specifier: ^5.20.0
version: 5.20.0
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -33,6 +36,9 @@ importers:
specifier: ^4.0.17
version: 4.0.17
devDependencies:
'@changesets/changelog-github':
specifier: ^0.5.2
version: 0.5.2
'@changesets/cli':
specifier: ^2.27.7
version: 2.29.6(@types/node@24.2.0)
@@ -70,6 +76,9 @@ packages:
'@changesets/changelog-git@0.2.1':
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
'@changesets/changelog-github@0.5.2':
resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==}
'@changesets/cli@2.29.6':
resolution: {integrity: sha512-6qCcVsIG1KQLhpQ5zE8N0PckIx4+9QlHK3z6/lwKnw7Tir71Bjw8BeOZaxA/4Jt00pcgCnCSWZnyuZf5Il05QQ==}
hasBin: true
@@ -83,6 +92,9 @@ packages:
'@changesets/get-dependents-graph@2.1.3':
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
'@changesets/get-github-info@0.7.0':
resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==}
'@changesets/get-release-plan@4.0.13':
resolution: {integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==}
@@ -484,6 +496,9 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/core@1.9.1':
resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==}
'@rollup/rollup-android-arm-eabi@4.46.2':
resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==}
cpu: [arm]
@@ -823,6 +838,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
dataloader@1.4.0:
resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -847,6 +865,10 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dotenv@8.6.0:
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
engines: {node: '>=10'}
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -1192,6 +1214,15 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
@@ -1284,6 +1315,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
posthog-node@5.20.0:
resolution: {integrity: sha512-LkR5KfrvEQTnUtNKN97VxFB00KcYG1Iz8iKg8r0e/i7f1eQhg1WSZO+Jp1B4bvtHCmdpIE4HwYbvCCzFoCyjVg==}
engines: {node: '>=20'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1455,6 +1490,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -1564,6 +1602,12 @@ packages:
jsdom:
optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1631,6 +1675,14 @@ snapshots:
dependencies:
'@changesets/types': 6.1.0
'@changesets/changelog-github@0.5.2':
dependencies:
'@changesets/get-github-info': 0.7.0
'@changesets/types': 6.1.0
dotenv: 8.6.0
transitivePeerDependencies:
- encoding
'@changesets/cli@2.29.6(@types/node@24.2.0)':
dependencies:
'@changesets/apply-release-plan': 7.0.12
@@ -1685,6 +1737,13 @@ snapshots:
picocolors: 1.1.1
semver: 7.7.2
'@changesets/get-github-info@0.7.0':
dependencies:
dataloader: 1.4.0
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
'@changesets/get-release-plan@4.0.13':
dependencies:
'@changesets/assemble-release-plan': 6.0.9
@@ -2038,6 +2097,10 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@posthog/core@1.9.1':
dependencies:
cross-spawn: 7.0.6
'@rollup/rollup-android-arm-eabi@4.46.2':
optional: true
@@ -2365,6 +2428,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
dataloader@1.4.0: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -2379,6 +2444,8 @@ snapshots:
dependencies:
path-type: 4.0.0
dotenv@8.6.0: {}
emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {}
@@ -2723,6 +2790,10 @@ snapshots:
natural-compare@1.4.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
@@ -2808,6 +2879,10 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-node@5.20.0:
dependencies:
'@posthog/core': 1.9.1
prelude-ls@1.2.1: {}
prettier@2.8.8: {}
@@ -2967,6 +3042,8 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
ts-api-utils@2.1.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -3074,6 +3151,13 @@ snapshots:
- tsx
- yaml
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0
+38 -2
View File
@@ -15,11 +15,32 @@ import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
const program = new Command();
const require = createRequire(import.meta.url);
const { version } = require('../../package.json');
/**
* Get the full command path for nested commands.
* For example: 'change show' -> 'change:show'
*/
function getCommandPath(command: Command): string {
const names: string[] = [];
let current: Command | null = command;
while (current) {
const name = current.name();
// Skip the root 'openspec' command
if (name && name !== 'openspec') {
names.unshift(name);
}
current = current.parent;
}
return names.join(':') || 'openspec';
}
program
.name('openspec')
.description('AI-native system for spec-driven development')
@@ -28,12 +49,27 @@ program
// Global options
program.option('--no-color', 'Disable color output');
// Apply global flags before any command runs
program.hook('preAction', (thisCommand) => {
// Apply global flags and telemetry before any command runs
// Note: preAction receives (thisCommand, actionCommand) where:
// - thisCommand: the command where hook was added (root program)
// - actionCommand: the command actually being executed (subcommand)
program.hook('preAction', async (thisCommand, actionCommand) => {
const opts = thisCommand.opts();
if (opts.color === false) {
process.env.NO_COLOR = '1';
}
// Show first-run telemetry notice (if not seen)
await maybeShowTelemetryNotice();
// Track command execution (use actionCommand to get the actual subcommand)
const commandPath = getCommandPath(actionCommand);
await trackCommand(commandPath, version);
});
// Shutdown telemetry after command completes
program.hook('postAction', async () => {
await shutdown();
});
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
+1
View File
@@ -24,6 +24,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
+51
View File
@@ -0,0 +1,51 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.continue/prompts/openspec-proposal.prompt',
apply: '.continue/prompts/openspec-apply.prompt',
archive: '.continue/prompts/openspec-archive.prompt'
};
/*
* Continue .prompt format requires YAML frontmatter:
* ---
* name: commandName
* description: description
* invokable: true
* ---
* Body...
*
* The 'invokable: true' field is required to make the prompt available as a slash command.
* We use 'openspec-proposal' as the name so the command becomes /openspec-proposal.
*/
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: openspec-proposal
description: Scaffold a new OpenSpec change and validate strictly.
invokable: true
---`,
apply: `---
name: openspec-apply
description: Implement an approved OpenSpec change and keep tasks in sync.
invokable: true
---`,
archive: `---
name: openspec-archive
description: Archive a deployed OpenSpec change and update specs.
invokable: true
---`
};
export class ContinueSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'continue';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
+3
View File
@@ -19,6 +19,7 @@ import { QwenSlashCommandConfigurator } from './qwen.js';
import { RooCodeSlashCommandConfigurator } from './roocode.js';
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
import { IflowSlashCommandConfigurator } from './iflow.js';
import { ContinueSlashCommandConfigurator } from './continue.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -44,6 +45,7 @@ export class SlashCommandRegistry {
const roocode = new RooCodeSlashCommandConfigurator();
const antigravity = new AntigravitySlashCommandConfigurator();
const iflow = new IflowSlashCommandConfigurator();
const continueTool = new ContinueSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(codeBuddy.toolId, codeBuddy);
@@ -65,6 +67,7 @@ export class SlashCommandRegistry {
this.configurators.set(roocode.toolId, roocode);
this.configurators.set(antigravity.toolId, antigravity);
this.configurators.set(iflow.toolId, iflow);
this.configurators.set(continueTool.toolId, continueTool);
}
static register(configurator: SlashCommandConfigurator): void {
+85
View File
@@ -0,0 +1,85 @@
/**
* Global configuration for telemetry state.
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
export interface TelemetryConfig {
anonymousId?: string;
noticeSeen?: boolean;
}
export interface GlobalConfig {
telemetry?: TelemetryConfig;
[key: string]: unknown; // Preserve other fields
}
/**
* Get the path to the global config file.
* Uses ~/.config/openspec/config.json on all platforms.
*/
export function getConfigPath(): string {
const configDir = path.join(os.homedir(), '.config', 'openspec');
return path.join(configDir, 'config.json');
}
/**
* Read the global config file.
* Returns an empty object if the file doesn't exist.
*/
export async function readConfig(): Promise<GlobalConfig> {
const configPath = getConfigPath();
try {
const content = await fs.readFile(configPath, 'utf-8');
return JSON.parse(content) as GlobalConfig;
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
// If parse fails or other error, return empty config
return {};
}
}
/**
* Write to the global config file.
* Preserves existing fields and merges in new values.
*/
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
const configPath = getConfigPath();
const configDir = path.dirname(configPath);
// Ensure directory exists
await fs.mkdir(configDir, { recursive: true });
// Read existing config and merge
const existing = await readConfig();
const merged = { ...existing, ...updates };
// Deep merge for telemetry object
if (updates.telemetry && existing.telemetry) {
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
}
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
}
/**
* Get the telemetry config section.
*/
export async function getTelemetryConfig(): Promise<TelemetryConfig> {
const config = await readConfig();
return config.telemetry ?? {};
}
/**
* Update the telemetry config section.
*/
export async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {
const existing = await getTelemetryConfig();
await writeConfig({
telemetry: { ...existing, ...updates },
});
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Telemetry module for anonymous usage analytics.
*
* Privacy-first design:
* - Only tracks command name and version
* - No arguments, file paths, or content
* - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
* - Auto-disabled in CI environments
* - Anonymous ID is a random UUID with no relation to the user
*/
import { PostHog } from 'posthog-node';
import { randomUUID } from 'crypto';
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
// PostHog API key - public key for client-side analytics
// This is safe to embed as it only allows sending events, not reading data
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';
let posthogClient: PostHog | null = null;
let anonymousId: string | null = null;
/**
* Check if telemetry is enabled.
*
* Disabled when:
* - OPENSPEC_TELEMETRY=0
* - DO_NOT_TRACK=1
* - CI=true (any CI environment)
*/
export function isTelemetryEnabled(): boolean {
// Check explicit opt-out
if (process.env.OPENSPEC_TELEMETRY === '0') {
return false;
}
// Respect DO_NOT_TRACK standard
if (process.env.DO_NOT_TRACK === '1') {
return false;
}
// Auto-disable in CI environments
if (process.env.CI === 'true') {
return false;
}
return true;
}
/**
* Get or create the anonymous user ID.
* Lazily generates a UUID on first call and persists it.
*/
export async function getOrCreateAnonymousId(): Promise<string> {
// Return cached value if available
if (anonymousId) {
return anonymousId;
}
// Try to load from config
const config = await getTelemetryConfig();
if (config.anonymousId) {
anonymousId = config.anonymousId;
return anonymousId;
}
// Generate new UUID and persist
anonymousId = randomUUID();
await updateTelemetryConfig({ anonymousId });
return anonymousId;
}
/**
* Get the PostHog client instance.
* Creates it on first call with CLI-optimized settings.
*/
function getClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
flushAt: 1, // Send immediately, don't batch
flushInterval: 0, // No timer-based flushing
});
}
return posthogClient;
}
/**
* Track a command execution.
*
* @param commandName - The command name (e.g., 'init', 'change:apply')
* @param version - The OpenSpec version
*/
export async function trackCommand(commandName: string, version: string): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
try {
const userId = await getOrCreateAnonymousId();
const client = getClient();
client.capture({
distinctId: userId,
event: 'command_executed',
properties: {
command: commandName,
version: version,
surface: 'cli',
$ip: null, // Explicitly disable IP tracking
},
});
} catch {
// Silent failure - telemetry should never break CLI
}
}
/**
* Show first-run telemetry notice if not already seen.
*/
export async function maybeShowTelemetryNotice(): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
try {
const config = await getTelemetryConfig();
if (config.noticeSeen) {
return;
}
// Display notice
console.log(
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
);
// Mark as seen
await updateTelemetryConfig({ noticeSeen: true });
} catch {
// Silent failure - telemetry should never break CLI
}
}
/**
* Shutdown the PostHog client and flush pending events.
* Call this before CLI exit.
*/
export async function shutdown(): Promise<void> {
if (!posthogClient) {
return;
}
try {
await posthogClient.shutdown();
} catch {
// Silent failure - telemetry should never break CLI exit
} finally {
posthogClient = null;
}
}
+55
View File
@@ -1151,6 +1151,61 @@ describe('InitCommand', () => {
expect(codeBuddyChoice.configured).toBe(true);
});
it('should create Continue slash command files with templates', async () => {
queueSelections('continue', DONE);
await initCommand.execute(testDir);
const continueProposal = path.join(
testDir,
'.continue/prompts/openspec-proposal.prompt'
);
const continueApply = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
const continueArchive = path.join(
testDir,
'.continue/prompts/openspec-archive.prompt'
);
expect(await fileExists(continueProposal)).toBe(true);
expect(await fileExists(continueApply)).toBe(true);
expect(await fileExists(continueArchive)).toBe(true);
const proposalContent = await fs.readFile(continueProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('name: openspec-proposal');
expect(proposalContent).toContain('invokable: true');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
const applyContent = await fs.readFile(continueApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('name: openspec-apply');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('invokable: true');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(continueArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('name: openspec-archive');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('invokable: true');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
it('should mark Continue as already configured during extend mode', async () => {
queueSelections('continue', DONE, 'continue', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const continueChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'continue'
);
expect(continueChoice.configured).toBe(true);
});
it('should create CODEBUDDY.md when CodeBuddy is selected', async () => {
queueSelections('codebuddy', DONE);
+74
View File
@@ -368,6 +368,80 @@ Old body
consoleSpy.mockRestore();
});
it('should refresh existing Continue prompt files', async () => {
const continuePath = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
await fs.mkdir(path.dirname(continuePath), { recursive: true });
const initialContent = `---
name: openspec-apply
description: Old description
invokable: true
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(continuePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(continuePath, 'utf-8');
expect(updated).toContain('name: openspec-apply');
expect(updated).toContain('invokable: true');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .continue/prompts/openspec-apply.prompt'
);
consoleSpy.mockRestore();
});
it('should not create missing Continue prompt files on update', async () => {
const continueApply = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(continueApply), { recursive: true });
await fs.writeFile(
continueApply,
`---
name: openspec-apply
description: Old description
invokable: true
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const continueProposal = path.join(
testDir,
'.continue/prompts/openspec-proposal.prompt'
);
const continueArchive = path.join(
testDir,
'.continue/prompts/openspec-archive.prompt'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(continueProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(continueArchive)).resolves.toBe(false);
});
it('should refresh existing OpenCode slash command files', async () => {
const openCodePath = path.join(
testDir,
+185
View File
@@ -0,0 +1,185 @@
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 {
getConfigPath,
readConfig,
writeConfig,
getTelemetryConfig,
updateTelemetryConfig,
} from '../../src/telemetry/config.js';
describe('telemetry/config', () => {
let tempDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
// Create temp directory for tests
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Mock HOME/USERPROFILE to point to temp dir
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;
});
afterEach(() => {
// Restore HOME/USERPROFILE
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('should return path to config.json in .config/openspec', () => {
const result = getConfigPath();
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
});
});
describe('readConfig', () => {
it('should return empty object when config file does not exist', async () => {
const config = await readConfig();
expect(config).toEqual({});
});
it('should load valid config from file', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'test-id', noticeSeen: true }
}));
const config = await readConfig();
expect(config.telemetry).toEqual({ anonymousId: 'test-id', noticeSeen: true });
});
it('should return empty object for invalid JSON', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{ invalid json }');
const config = await readConfig();
expect(config).toEqual({});
});
});
describe('writeConfig', () => {
it('should create directory if it does not exist', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
await writeConfig({ telemetry: { noticeSeen: true } });
expect(fs.existsSync(configDir)).toBe(true);
});
it('should write config to file', async () => {
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('test-123');
});
it('should preserve existing fields when updating', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config with other fields
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
existingField: 'preserved',
telemetry: { anonymousId: 'old-id' }
}));
// Update telemetry
await writeConfig({ telemetry: { noticeSeen: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.existingField).toBe('preserved');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
it('should deep merge telemetry fields', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'existing-id' }
}));
// Update with noticeSeen only
await writeConfig({ telemetry: { noticeSeen: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('existing-id');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
});
describe('getTelemetryConfig', () => {
it('should return empty object when no config exists', async () => {
const config = await getTelemetryConfig();
expect(config).toEqual({});
});
it('should return telemetry section from config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'my-id', noticeSeen: false }
}));
const config = await getTelemetryConfig();
expect(config).toEqual({ anonymousId: 'my-id', noticeSeen: false });
});
});
describe('updateTelemetryConfig', () => {
it('should create telemetry config when none exists', async () => {
await updateTelemetryConfig({ anonymousId: 'new-id' });
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('new-id');
});
it('should merge with existing telemetry config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'existing-id' }
}));
await updateTelemetryConfig({ noticeSeen: true });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('existing-id');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { randomUUID } from 'node:crypto';
// Mock posthog-node before importing the module
vi.mock('posthog-node', () => {
return {
PostHog: vi.fn().mockImplementation(() => ({
capture: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
})),
};
});
// Import after mocking
import { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js';
import { PostHog } from 'posthog-node';
describe('telemetry/index', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Create unique temp directory for each test using UUID
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Save original env
originalEnv = { ...process.env };
// Mock HOME to point to temp dir
process.env.HOME = tempDir;
// Clear all mocks
vi.clearAllMocks();
// Spy on console.log for notice tests
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore original env
process.env = originalEnv;
// Clean up temp directory
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
// Restore all mocks
vi.restoreAllMocks();
});
describe('isTelemetryEnabled', () => {
it('should return false when OPENSPEC_TELEMETRY=0', () => {
process.env.OPENSPEC_TELEMETRY = '0';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return false when DO_NOT_TRACK=1', () => {
process.env.DO_NOT_TRACK = '1';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return false when CI=true', () => {
process.env.CI = 'true';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return true when no opt-out is set', () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
expect(isTelemetryEnabled()).toBe(true);
});
it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => {
process.env.OPENSPEC_TELEMETRY = '0';
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
expect(isTelemetryEnabled()).toBe(false);
});
});
describe('maybeShowTelemetryNotice', () => {
it('should not show notice when telemetry is disabled', async () => {
process.env.OPENSPEC_TELEMETRY = '0';
await maybeShowTelemetryNotice();
expect(consoleLogSpy).not.toHaveBeenCalled();
});
});
describe('trackCommand', () => {
it('should not track when telemetry is disabled', async () => {
process.env.OPENSPEC_TELEMETRY = '0';
await trackCommand('test', '1.0.0');
expect(PostHog).not.toHaveBeenCalled();
});
it('should track when telemetry is enabled', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
expect(PostHog).toHaveBeenCalled();
});
});
describe('shutdown', () => {
it('should not throw when no client exists', async () => {
await expect(shutdown()).resolves.not.toThrow();
});
it('should handle shutdown errors silently', async () => {
const mockPostHog = {
capture: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Network error')),
};
(PostHog as any).mockImplementation(() => mockPostHog);
await expect(shutdown()).resolves.not.toThrow();
});
});
});