Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/legacy-cleanup-live-command-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@fission-ai/openspec': patch
---

`openspec init` and `openspec update` no longer delete the CoStrict and Junie command files they just generated. Legacy cleanup removes artifacts older OpenSpec versions left behind, and two of its patterns named paths the current adapters still write to. CoStrict's was a whole-directory removal of `.cospec/openspec/commands/`, the folder the adapter writes `opsx-<id>.md` into, so every run wiped the directory — including any file the user kept there — while the banner above it read `No user content to preserve`. Junie's `.junie/commands/opsx-*.md` listed its own current output. Cleanup runs before the config migration, so on a config that has no `profile` key yet the missing command files make delivery detection read the project as skills-only and persist that to the global config: the files are not regenerated, and the preference changes for every other project too.

CoStrict is now a file pattern, `.cospec/openspec/commands/openspec-*.md`, matching the three commands the pre-`opsx` CoStrict integration wrote there (`openspec-proposal.md`, `openspec-apply.md`, `openspec-archive.md`) and the same shape every other file-based tool already uses. Junie's entry is removed outright: Junie support arrived after the slash configurators that wrote `openspec-*` files were deleted, so no OpenSpec version ever created those files there. Genuinely legacy files are still detected and removed, and no other tool's patterns change — they never overlapped their adapter's current output.
6 changes: 4 additions & 2 deletions src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
'crush': { type: 'directory', path: '.crush/commands/openspec' },
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },

// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
Expand All @@ -59,9 +58,12 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
// Keep this file-scoped: the CoStrict adapter writes `opsx-*.md` into the
// same folder, so a directory entry removes the live command files — and
// anything else the user keeps there — on every run.
'costrict': { type: 'files', pattern: '.cospec/openspec/commands/openspec-*.md' },
};

/**
Expand Down
61 changes: 61 additions & 0 deletions test/core/legacy-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { OPENSPEC_MARKERS } from '../../src/core/config.js';
import { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js';
import { resolveCommandSurfaceCapability } from '../../src/core/command-surface.js';
import { ALL_WORKFLOWS } from '../../src/core/profiles.js';

describe('legacy-cleanup', () => {
let testDir: string;
Expand Down Expand Up @@ -389,6 +390,44 @@ ${OPENSPEC_MARKERS.end}`);
expect(result.files).toContain('.opencode/command/openspec-new.md');
});

it('should detect legacy CoStrict command files without claiming their directory', async () => {
const dirPath = path.join(testDir, '.cospec', 'openspec', 'commands');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'openspec-proposal.md'), 'content');
await fs.writeFile(path.join(dirPath, 'openspec-apply.md'), 'content');
await fs.writeFile(path.join(dirPath, 'openspec-archive.md'), 'content');

const result = await detectLegacySlashCommands(testDir);
expect(result.files).toContain('.cospec/openspec/commands/openspec-proposal.md');
expect(result.files).toContain('.cospec/openspec/commands/openspec-apply.md');
expect(result.files).toContain('.cospec/openspec/commands/openspec-archive.md');
expect(result.directories).not.toContain('.cospec/openspec/commands');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('should not report any file a current command adapter writes as a legacy artifact', async () => {
// Codex is the only legacy tool id with no adapter, so it is the only
// entry with no current output for this invariant to compare against.
const withoutAdapter = Object.keys(LEGACY_SLASH_COMMAND_PATHS).filter(
(toolId) => !CommandAdapterRegistry.has(toolId)
);
expect(withoutAdapter).toEqual(['codex']);

const currentFiles = CommandAdapterRegistry.getAll().flatMap((adapter) =>
ALL_WORKFLOWS.map((workflowId) => adapter.getFilePath(workflowId))
);
expect(currentFiles.every((filePath) => !path.isAbsolute(filePath))).toBe(true);

for (const relativePath of currentFiles) {
const filePath = path.join(testDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, 'current command output');
}

const result = await detectLegacySlashCommands(testDir);
expect(result.files).toEqual([]);
expect(result.directories).toEqual([]);
});

it('should not include managed global Codex prompt files in repo-local slash command detection', async () => {
const promptDir = getCodexPromptDir();
await fs.mkdir(promptDir, { recursive: true });
Expand Down Expand Up @@ -596,6 +635,26 @@ ${OPENSPEC_MARKERS.end}`);
await expect(fs.access(filePath)).rejects.toThrow();
});

it('should delete legacy CoStrict command files without emptying their directory', async () => {
const dirPath = path.join(testDir, '.cospec', 'openspec', 'commands');
await fs.mkdir(dirPath, { recursive: true });
const legacyFile = path.join(dirPath, 'openspec-proposal.md');
const currentFile = path.join(dirPath, 'opsx-propose.md');
const userFile = path.join(dirPath, 'my-team-command.md');
await fs.writeFile(legacyFile, 'content');
await fs.writeFile(currentFile, 'content');
await fs.writeFile(userFile, 'content');

const detection = await detectLegacyArtifacts(testDir);
const result = await cleanupLegacyArtifacts(testDir, detection);

expect(result.deletedFiles).toContain('.cospec/openspec/commands/openspec-proposal.md');
expect(result.deletedDirs).not.toContain('.cospec/openspec/commands');
await expect(fs.access(legacyFile)).rejects.toThrow();
await expect(fs.access(currentFile)).resolves.not.toThrow();
await expect(fs.access(userFile)).resolves.not.toThrow();
});

it('should delete openspec/AGENTS.md', async () => {
const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');
await fs.writeFile(agentsPath, 'content');
Expand Down Expand Up @@ -1124,6 +1183,8 @@ ${OPENSPEC_MARKERS.end}`);

// Pi was never a pre-1.0 legacy tool
expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('pi');
// Junie support landed after the opsx rename; it never had openspec-* files
expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('junie');
});

it('should use the repo-local compatibility glob pattern for Codex prompt detection', () => {
Expand Down
Loading