Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale e0be11dd85 Clarify release automation proposal 2025-10-02 00:54:14 +10:00
12 changed files with 89 additions and 360 deletions
+2 -15
View File
@@ -8,13 +8,8 @@ permissions:
contents: write
pull-requests: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
prepare:
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -29,22 +24,14 @@ jobs:
with:
node-version: '20'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
scope: '@fission-ai'
always-auth: true
- run: pnpm install --frozen-lockfile
# Opens/updates the Version Packages PR; publishes when the Version PR merges
# Opens/updates the Version Packages PR; no publishing here
- name: Create/Update Version PR
uses: changesets/action@v1
with:
title: 'chore(release): version packages'
createGithubReleases: true
# Use CI-specific release script: relies on version PR having been merged
# so package.json already contains the bumped version.
publish: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+72
View File
@@ -0,0 +1,72 @@
name: Publish to npm
on:
release:
types: [published]
workflow_dispatch: {}
permissions:
contents: read
id-token: write
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
scope: '@fission-ai'
always-auth: true
- run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm run build
- name: Ensure running from a tag
run: |
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
echo "This workflow must run from a tag (got: $GITHUB_REF)";
exit 1;
fi
- name: Verify release tag matches package.json
run: |
TAG="${GITHUB_REF_NAME#v}"
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG" != "$PKG_VERSION" ]; then
echo "Tag v$TAG does not match package.json $PKG_VERSION"; exit 1
fi
- name: Debug npm auth and context
run: |
test -n "$NODE_AUTH_TOKEN" || (echo "NODE_AUTH_TOKEN is missing" && exit 1)
echo "NODE_AUTH_TOKEN present"
npm --version
pnpm --version
node --version
npm config get registry
npm whoami
npm ping
- run: pnpm test
- name: Publish
run: pnpm publish --access public --provenance --no-git-checks
-12
View File
@@ -1,17 +1,5 @@
# @fission-ai/openspec
## 0.8.1
### Patch Changes
- d070d08: Fix CLI version mismatch and add a release guard that validates the packed tarball prints the same version as package.json via `openspec --version`.
## 0.8.0
### Minor Changes
- c29b06d: Add Windsurf support.
## 0.7.0
### Minor Changes
+2 -15
View File
@@ -15,7 +15,7 @@
<a href="https://nodejs.org/"><img alt="node version" src="https://img.shields.io/node/v/@fission-ai/openspec?style=flat-square" /></a>
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" /></a>
<a href="https://conventionalcommits.org"><img alt="Conventional Commits" src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square" /></a>
<a href="https://discord.gg/YctCnvvshC"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=flat-square" /></a>
<a href="https://discord.gg/saTQQGQZ"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=flat-square" /></a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/saTQQGQZ">OpenSpec Discord</a> for help and questions.
</p>
# OpenSpec
@@ -40,15 +40,6 @@ Key outcomes:
- Shared visibility into what's proposed, active, or archived.
- Works with the AI tools you already use: custom slash commands where supported, context rules everywhere else.
## How OpenSpec compares (at a glance)
- **Lightweight**: simple workflow, no API keys, minimal setup.
- **Brownfield-first**: works great beyond 0→1. OpenSpec separates the source of truth from proposals: `openspec/specs/` (current truth) and `openspec/changes/` (proposed updates). This keeps diffs explicit and manageable across features.
- **Change tracking**: proposals, tasks, and spec deltas live together; archiving merges the approved updates back into specs.
- **Compared to spec-kit & Kiro**: those shine for brand-new features (0→1). OpenSpec also excels when modifying existing behavior (1→n), especially when updates span multiple specs.
See the full comparison in [How OpenSpec Compares](#how-openspec-compares).
## How It Works
```
@@ -94,7 +85,6 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
@@ -305,9 +295,6 @@ Deltas are "patches" that show how specs change:
## How OpenSpec Compares
### vs. spec-kit
OpenSpec’s two-folder model (`openspec/specs/` for the current truth, `openspec/changes/` for proposed updates) keeps state and diffs separate. This scales when you modify existing features or touch multiple specs. spec-kit is strong for greenfield/0→1 but provides less structure for cross-spec updates and evolving features.
### vs. Kiro.dev
OpenSpec groups every change for a feature in one folder (`openspec/changes/feature-name/`), making it easy to track related specs, tasks, and designs together. Kiro spreads updates across multiple spec folders, which can make feature tracking harder.
@@ -1,49 +1,21 @@
## Why
Today’s process requires maintainers to merge the Changesets PR, cut a tag, and draft the GitHub release by hand. npm publish then runs from our existing workflow after the GitHub release is published. The human-in-the-loop steps (versioning, tagging, release notes) slow us down and risk drift between npm, tags, and changelog.
Today's flow requires maintainers to merge the changeset PR, cut a tag, and draft the GitHub release by hand. Once the release is published on GitHub our existing automation pushes the package to npm, so the manual work centers on shepherding the release PR, updating version metadata, and keeping GitHub releases in sync with the changelog. Those repeated human steps slow the cycle time and increase the risk that the release/tag documentation falls out of sync with what was actually published to npm.
## What Changes
- Use the single `changesets/action` on pushes to `main` to either open/update the version PR or, when the release PR is merged, run our publish command automatically using repository secrets.
- Add a `release` script that builds and runs `changeset publish` so the action handles version bumps, changelog commits, npm publish, and GitHub releases end-to-end.
- Enable `createGithubReleases: true` so GitHub releases are created from the changeset data right after publishing.
- Document the automated flow, required secrets, guardrails, and recovery steps (rollback, hotfixes).
## Two-Phase Rollout (Two PRs)
1) Phase 1 — Dry run (no publish)
- Update the existing `release-prepare.yml` to wire up `changesets/action` with `createGithubReleases: true` and a no-op `publish` command (e.g., `echo 'dry run'`).
- Keep `.github/workflows/release-publish.yml` intact. This avoids any publish path changes while we verify that the version PR behavior and permissions are correct.
- Add a repository guard (`if: github.repository == 'Fission-AI/OpenSpec'`) and a concurrency group for safety.
2) Phase 2 — Enable publish and consolidate
- Add `"release": "pnpm run build && pnpm exec changeset publish"` to `package.json`.
- Change `release-prepare.yml` to use `with: publish: pnpm run release` and `env: NPM_TOKEN: \\${{ secrets.NPM_TOKEN }}` plus the default `GITHUB_TOKEN`.
- Remove `.github/workflows/release-publish.yml` to avoid double-publish. Publishing now happens when the version PR is merged.
## Guardrails
- Concurrency: `concurrency: { group: release-\\${{ github.ref }}, cancel-in-progress: false }` on the workflow to serialize releases.
- Repository/branch guard: run publish logic only on upstream `main` (`if: github.repository == 'Fission-AI/OpenSpec' && github.ref == 'refs/heads/main'`).
- Permissions: ensure `contents: write` and `pull-requests: write` for opening/updating the version PR; `packages: read` optional.
## Rollback and Hotfixes
- Rollback: revert the release PR merge (which reverts version bumps/changelog); if a tag or GitHub release was created, delete the tag and release; deprecate the npm version if necessary (`npm deprecate @fission-ai/openspec@x.y.z 'reason'`).
- Hotfix (urgent, no pending changesets): create a changeset for the fix and merge the release PR; in emergencies, run a manual bump/publish but reconcile with Changesets by adding a follow-up changeset to align versions.
## Required Secrets
- `NPM_TOKEN` with publish rights for the `@fission-ai` scope.
- Default `GITHUB_TOKEN` (provided by GitHub) for opening/updating the version PR and creating GitHub releases.
- Adopt the [changesets/action](https://github.com/changesets/action) workflow so pushes to `main` either open/update a release PR or, when the release PR merge lands, run our publish script automatically with the provided `NPM_TOKEN` and `GITHUB_TOKEN`.【5e2b47†L1-L43】【b9dad5†L13-L37】
- Add a `release` npm script that builds the project and calls `changeset publish`, allowing the action to handle version bumps, changelog commits, and npm publishing in one command.【b9dad5†L13-L21】
- Enable `createGithubReleases` in the action configuration so the workflow drafts GitHub releases from the published packages immediately after publishing.【5e2b47†L21-L38】
- Document the automated release flow, required repository secrets, and how maintainers should handle failed publishes or hotfixes.
## How the Maintainer Flow Changes
| Step | Current process | Future process |
| --- | --- | --- |
| Prepare release | Merge changeset PR, then manually draft release notes and tags | Merge release PR; action updates versions and handles changelog automatically |
| Publish npm package | Happens automatically after GitHub release | Happens automatically via `changeset publish` invoked by the action |
| GitHub release | Draft manually and sync with changelog | Action creates GitHub releases from changeset data |
| Docs/process | Follow manual tagging/release steps | Docs describe automated flow + recovery and hotfix paths |
| Prepare release | Merge changeset PR, wait for GitHub release automation, then manually draft release notes and tags | Merge changeset PR, the action updates/merges the release PR and handles version bumps automatically |
| Publish npm package | Happens automatically after the GitHub release is published | Continues to happen automatically via `changeset publish`; no manual npm work |
| GitHub release | Maintainers draft releases manually and ensure notes match the changelog | Action creates GitHub releases immediately after publishing, based on changeset information |
| Clean up docs/process | Maintainers follow separate docs describing manual tagging and release steps | Update docs to remove manual tag/release steps and document the automated workflow |
## Impact
- Automation: reuse `.github/workflows/release-prepare.yml` (phase 1: dry-run, phase 2: publish) and remove `.github/workflows/release-publish.yml` in phase 2.
- Package metadata: add `release` script to `package.json`.
- Docs: update README or `/docs` to show the automated flow, secrets, guardrails, and recovery steps.
## Acceptance Criteria
- Phase 1: merges to `main` open/update a version PR; on merge, the action’s `publish` step is a no-op; no npm publish occurs; logs confirm intended behavior; GitHub releases creation is wired but inert due to no publish.
- Phase 2: merges to `main` run `pnpm run release` from the action; npm package publishes successfully; GitHub release is created automatically; `.github/workflows/release-publish.yml` is removed; no duplicate publishes occur.
- Affected automation: `.github/workflows/release.yml`
- Affected package metadata: `package.json` scripts
- Documentation: `README.md` or `/docs` release instructions
+1 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.8.1",
"version": "0.7.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -46,10 +46,6 @@
"test:coverage": "vitest --coverage",
"prepare": "pnpm run build",
"prepublishOnly": "pnpm run build",
"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": {
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env node
// Guard: Ensure the packed tarball's CLI `--version` matches package.json.
//
// Notes:
// - We intentionally use `npm pack` (not pnpm) because `npm pack --json` is
// consistently supported and returns the tarball metadata we need. The
// project uses pnpm for install/publish, but this guard only needs to pack
// locally and verify the installed CLI output.
// - `npm pack` triggers the package's `prepare` script (build), and
// `changeset publish` triggers `prepublishOnly` (also builds here). This
// means an explicit build is not strictly necessary for the guard.
import { execFileSync } from 'child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
function log(msg) {
if (process.env.CI) return; // keep CI logs quiet by default
console.log(msg);
}
function run(cmd, args, opts = {}) {
return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
}
function npmPack() {
try {
const jsonOut = run('npm', ['pack', '--json', '--silent']);
const arr = JSON.parse(jsonOut);
if (Array.isArray(arr) && arr.length > 0) {
const last = arr[arr.length - 1];
const file = (last && typeof last === 'object' && last.filename) || (typeof last === 'string' ? last : null);
if (file) return String(file).trim();
}
// Unexpected JSON shape or empty array; fallback to plain output
const out = run('npm', ['pack', '--silent']).trim();
const lines = out.split(/\r?\n/);
return lines[lines.length - 1].trim();
} catch (e) {
// Fallback for environments not supporting --json
const out = run('npm', ['pack', '--silent']).trim();
const lines = out.split(/\r?\n/);
return lines[lines.length - 1].trim();
}
}
function main() {
const pkg = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
const expected = pkg.version;
let work;
let tgzPath;
try {
log(`Packing @fission-ai/openspec@${expected}...`);
const filename = npmPack();
tgzPath = path.resolve(filename);
log(`Created: ${tgzPath}`);
work = mkdtempSync(path.join(tmpdir(), 'openspec-pack-check-'));
log(`Temp dir: ${work}`);
// Make a tiny project
writeFileSync(
path.join(work, 'package.json'),
JSON.stringify({ name: 'pack-check', private: true }, null, 2)
);
// Try to avoid noisy output and speed up
const env = {
...process.env,
npm_config_loglevel: 'silent',
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_progress: 'false',
};
// Install the tarball
run('npm', ['install', tgzPath, '--silent', '--no-audit', '--no-fund'], { cwd: work, env });
// Run the installed CLI via Node to avoid bin resolution/platform issues
const binRel = path.join('node_modules', '@fission-ai', 'openspec', 'bin', 'openspec.js');
const actual = run(process.execPath, [binRel, '--version'], { cwd: work }).trim();
if (actual !== expected) {
throw new Error(
`Packed CLI version mismatch: expected ${expected}, got ${actual}. ` +
'Ensure the dist is built and the CLI reads version from package.json.'
);
}
log('Version check passed.');
} finally {
// Always attempt cleanup
if (work) {
try { rmSync(work, { recursive: true, force: true }); } catch {}
}
if (tgzPath) {
try { rmSync(tgzPath, { force: true }); } catch {}
}
}
}
try {
main();
console.log('✅ pack-version-check: OK');
} catch (err) {
console.error(`❌ pack-version-check: ${err.message}`);
process.exit(1);
}
-1
View File
@@ -21,6 +21,5 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
{ name: 'AGENTS.md (works with Codex, Amp, VS Code, GitHub Copilot, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
-3
View File
@@ -1,7 +1,6 @@
import { SlashCommandConfigurator } from './base.js';
import { ClaudeSlashCommandConfigurator } from './claude.js';
import { CursorSlashCommandConfigurator } from './cursor.js';
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
@@ -11,13 +10,11 @@ export class SlashCommandRegistry {
static {
const claude = new ClaudeSlashCommandConfigurator();
const cursor = new CursorSlashCommandConfigurator();
const windsurf = new WindsurfSlashCommandConfigurator();
const kilocode = new KiloCodeSlashCommandConfigurator();
const opencode = new OpenCodeSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(cursor.toolId, cursor);
this.configurators.set(windsurf.toolId, windsurf);
this.configurators.set(kilocode.toolId, kilocode);
this.configurators.set(opencode.toolId, opencode);
}
-27
View File
@@ -1,27 +0,0 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.windsurf/workflows/openspec-proposal.md',
apply: '.windsurf/workflows/openspec-apply.md',
archive: '.windsurf/workflows/openspec-archive.md'
};
export class WindsurfSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'windsurf';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
const descriptions: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
const description = descriptions[id];
return `---\ndescription: ${description}\nauto_execution_mode: 3\n---`;
}
}
-56
View File
@@ -129,50 +129,6 @@ describe('InitCommand', () => {
expect(updatedContent).toContain('Custom instructions here');
});
it('should create Windsurf workflows when Windsurf is selected', async () => {
queueSelections('windsurf', DONE);
await initCommand.execute(testDir);
const wsProposal = path.join(
testDir,
'.windsurf/workflows/openspec-proposal.md'
);
const wsApply = path.join(
testDir,
'.windsurf/workflows/openspec-apply.md'
);
const wsArchive = path.join(
testDir,
'.windsurf/workflows/openspec-archive.md'
);
expect(await fileExists(wsProposal)).toBe(true);
expect(await fileExists(wsApply)).toBe(true);
expect(await fileExists(wsArchive)).toBe(true);
const proposalContent = await fs.readFile(wsProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('auto_execution_mode: 3');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(wsApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('auto_execution_mode: 3');
expect(applyContent).toContain('<!-- OPENSPEC:START -->');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(wsArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('auto_execution_mode: 3');
expect(archiveContent).toContain('<!-- OPENSPEC:START -->');
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
});
it('should always create AGENTS.md in project root', async () => {
queueSelections(DONE);
@@ -443,18 +399,6 @@ describe('InitCommand', () => {
const preselected = secondRunArgs.initialSelected ?? [];
expect(preselected).toContain('kilocode');
});
it('should mark Windsurf as already configured during extend mode', async () => {
queueSelections('windsurf', DONE, 'windsurf', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const wsChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'windsurf'
);
expect(wsChoice.configured).toBe(true);
});
});
describe('error handling', () => {
-75
View File
@@ -220,81 +220,6 @@ Old body
consoleSpy.mockRestore();
});
it('should refresh existing Windsurf workflows', async () => {
const wsPath = path.join(
testDir,
'.windsurf/workflows/openspec-apply.md'
);
await fs.mkdir(path.dirname(wsPath), { recursive: true });
const initialContent = `## OpenSpec: Apply (Windsurf)
Intro
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(wsPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(wsPath, 'utf-8');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
expect(updated).toContain('## OpenSpec: Apply (Windsurf)');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated slash commands: .windsurf/workflows/openspec-apply.md'
);
consoleSpy.mockRestore();
});
it('should preserve Windsurf content outside markers during update', async () => {
const wsPath = path.join(
testDir,
'.windsurf/workflows/openspec-proposal.md'
);
await fs.mkdir(path.dirname(wsPath), { recursive: true });
const initialContent = `## Custom Intro Title\nSome intro text\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->\n\nFooter stays`;
await fs.writeFile(wsPath, initialContent);
await updateCommand.execute(testDir);
const updated = await fs.readFile(wsPath, 'utf-8');
expect(updated).toContain('## Custom Intro Title');
expect(updated).toContain('Footer stays');
expect(updated).not.toContain('Old body');
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
});
it('should not create missing Windsurf workflows on update', async () => {
const wsApply = path.join(
testDir,
'.windsurf/workflows/openspec-apply.md'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(wsApply), { recursive: true });
await fs.writeFile(
wsApply,
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);
await updateCommand.execute(testDir);
const wsProposal = path.join(
testDir,
'.windsurf/workflows/openspec-proposal.md'
);
const wsArchive = path.join(
testDir,
'.windsurf/workflows/openspec-archive.md'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(wsProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(wsArchive)).resolves.toBe(false);
});
it('should handle no AI tool files present', async () => {
// Execute update command with no AI tool files
const consoleSpy = vi.spyOn(console, 'log');