mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-16 05:40:40 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b192a02c1 | ||
|
|
d0f268cd0d |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
CoStrict now loads OpenSpec skills. CoStrict reads project skills from `.agents/skills/`, `.claude/skills/` and `.roo/skills/`, but OpenSpec wrote them to `.cospec/skills/`, which it never reads, so CoStrict users were relying on commands alone. Skills now install to the shared `.agents/skills/` folder, and `openspec update` moves an existing install's skills there. CoStrict's commands stay in `.cospec/openspec/commands/`, the only place it reads them, and CoStrict's own files in `.cospec/` are never touched. If another tool already owns `.agents/skills/`, its copy is kept and the old `.cospec/skills/` copy is left for you to review.
|
||||
|
||||
`openspec update` no longer re-syncs a tool on every run when it shares a skills folder that another tool owns and its old folder holds a customized skill. This affected Antigravity alongside Codex, and would have affected CoStrict too.
|
||||
|
||||
The Hermes setup note is current: Hermes v2026.6.5 and later load a project's `.hermes/skills/` once you run `hermes skills trust` in it. The `skills.external_dirs` step is now given only for older versions.
|
||||
@@ -27,7 +27,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| Code Studio | `codestudio` | `.codestudio/skills/` | `/openspec-apply-change` | `.codestudio/prompts/` | `/opsx-apply` |
|
||||
| Codex | `codex` | `.agents/skills/` | `$openspec-apply-change` | none | none |
|
||||
| Continue | `continue` | `.continue/skills/` | `/openspec-apply-change` | `.continue/prompts/` | `/opsx-apply` |
|
||||
| CoStrict | `costrict` | `.cospec/skills/` | `/openspec-apply-change` | `.cospec/openspec/commands/` | `/opsx-apply` |
|
||||
| CoStrict | `costrict` | `.agents/skills/` | `/openspec-apply-change` | `.cospec/openspec/commands/` | `/opsx-apply` |
|
||||
| Crush | `crush` | `.crush/skills/` | `/openspec-apply-change` | `.crush/commands/opsx/` | `/opsx:apply` |
|
||||
| Cursor | `cursor` | `.cursor/skills/` | `/openspec-apply-change` | `.cursor/commands/` | `/opsx-apply` |
|
||||
| DeepSeek Harness | `dsh` | `.dsh/skills/` | `/openspec-apply-change` | none | none |
|
||||
@@ -76,7 +76,7 @@ A tool not listed here behaves exactly as its row reads.
|
||||
|
||||
- **Project skills**: Amp reads OpenSpec skills from `.agents/skills/`.
|
||||
- **No command files**: Amp runs skills directly, so init skips command generation.
|
||||
- **Shared folder**: Amp shares `.agents/skills/` with Antigravity, Codex, Zed Agent,
|
||||
- **Shared folder**: Amp shares `.agents/skills/` with Antigravity, Codex, CoStrict, Zed Agent,
|
||||
and the `agents` target. OpenSpec writes the skill tree once.
|
||||
|
||||
### Antigravity
|
||||
@@ -86,7 +86,7 @@ A tool not listed here behaves exactly as its row reads.
|
||||
- **Legacy folder**: after OpenSpec writes replacements, it removes equivalent
|
||||
generated files from `.agent/`. Custom files and changed generated files stay in
|
||||
`.agent/` for you to review.
|
||||
- **Shared skills**: Antigravity shares `.agents/skills/` with Amp, Codex, Zed Agent,
|
||||
- **Shared skills**: Antigravity shares `.agents/skills/` with Amp, Codex, CoStrict, Zed Agent,
|
||||
and the `agents` target. OpenSpec writes that skill tree once while still writing
|
||||
Antigravity commands to `.agents/workflows/`.
|
||||
|
||||
@@ -106,12 +106,25 @@ Skills stay in `.cline/skills/`.
|
||||
- **No command files**: Codex runs skills directly, so init skips commands even when
|
||||
delivery includes them and prints `Commands skipped for: codex (uses skills)`.
|
||||
- **Shared folder**: Codex skills land in `.agents/skills/`, the same tree Amp,
|
||||
Antigravity, Zed Agent, and the `agents` target use. Selecting more than one keeps a
|
||||
Antigravity, CoStrict, Zed Agent, and the `agents` target use. Selecting more than one keeps a
|
||||
single compatible tree, and its handoffs spell both `$openspec-*` and `/openspec-*`
|
||||
when Codex owns it.
|
||||
- **Legacy path**: skills installed under `.codex/skills/` by older versions are
|
||||
migrated on the next `openspec update`.
|
||||
|
||||
### CoStrict
|
||||
|
||||
- **Skills**: CoStrict reads project skills from `.agents/skills/`, `.claude/skills/`,
|
||||
and `.roo/skills/`, never from `.cospec/`, so OpenSpec installs them in the shared
|
||||
`.agents/skills/` folder.
|
||||
- **Commands**: CoStrict reads OpenSpec commands from `.cospec/openspec/commands/`, so
|
||||
they stay there. `.cospec/` also holds CoStrict's own coworkflow files, which OpenSpec
|
||||
never touches.
|
||||
- **Legacy folder**: earlier OpenSpec versions wrote skills to `.cospec/skills/`, which
|
||||
CoStrict never loaded. `openspec update` moves them to `.agents/skills/`. When another
|
||||
tool already owns `.agents/skills/`, its copy is kept and the `.cospec/skills/` copy is
|
||||
left for you to review and delete.
|
||||
|
||||
### DeepSeek Harness
|
||||
|
||||
- **Project root**: DSH uses the nearest `.git` ancestor, or the current directory
|
||||
@@ -155,9 +168,11 @@ Skills stay in `.cline/skills/`.
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
Hermes loads skills only from `~/.hermes/skills/` by default. Add the project's
|
||||
`.hermes/skills/` folder to `skills.external_dirs` in `~/.hermes/config.yaml`;
|
||||
init prints this reminder after install.
|
||||
Hermes v2026.6.5 and later load a project's `.hermes/skills/` once you trust the repo:
|
||||
run `hermes skills trust` inside the project. Until then, Hermes shows a notice that
|
||||
it found project skills but did not load them. On older versions, add the project's
|
||||
`.hermes/skills/` folder to `skills.external_dirs` in `~/.hermes/config.yaml` instead.
|
||||
Init prints this reminder after install.
|
||||
|
||||
### MiniMax Code
|
||||
|
||||
@@ -180,7 +195,7 @@ init prints this reminder after install.
|
||||
assistant is not listed. The init picker's search box finds it by `universal`,
|
||||
`other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`,
|
||||
`vendor-neutral`, or `agents.md`.
|
||||
- **Alongside other targets**: Amp, Antigravity, Codex, Zed Agent, and this target share
|
||||
- **Alongside other targets**: Amp, Antigravity, Codex, CoStrict, Zed Agent, and this target share
|
||||
one physical skill tree. OpenSpec records one writer in `.openspec-target` and
|
||||
writes the tree once per run. Each tool's separate command files are still
|
||||
generated.
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
|
||||
{ name: 'Code Studio', value: 'codestudio', available: true, successLabel: 'Code Studio', skillsDir: '.codestudio', requiresIdeRestart: true },
|
||||
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', requiresIdeRestart: true },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.agents', legacySkillsDirs: ['.cospec'], detectionPaths: ['.cospec'], requiresIdeRestart: true },
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
|
||||
{ name: 'EasyCode', value: 'easycode', available: true, successLabel: 'EasyCode', skillsDir: '.easycode' },
|
||||
@@ -71,7 +71,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'GigaCode', value: 'gigacode', available: true, successLabel: 'GigaCode', skillsDir: '.gigacode' },
|
||||
{ name: 'Grok Build', value: 'grok', available: true, successLabel: 'Grok Build', skillsDir: '.grok' },
|
||||
{ name: 'GSD', value: 'gsd', available: true, successLabel: 'GSD', skillsDir: '.agents', detectionPaths: ['.gsd'] },
|
||||
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
|
||||
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes loads this project's .hermes/skills only once you trust the repo: run `hermes skills trust` in this project (Hermes v2026.6.5 or later). On older versions, add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml instead." },
|
||||
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
|
||||
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode', requiresIdeRestart: true },
|
||||
|
||||
@@ -59,6 +59,13 @@ export const LEGACY_TOOL_ROOTS: Record<string, LegacyToolRoot[]> = {
|
||||
// Codex: the replacement is written first, so a divergent legacy file is
|
||||
// reported and kept rather than overwritten.
|
||||
antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
|
||||
// CoStrict reads project skills from .agents, .claude and .roo, never from
|
||||
// .cospec, which holds CoStrict's own coworkflow files and the OpenSpec
|
||||
// commands it does read (.cospec/openspec/commands). Only skills move:
|
||||
// legacyCommandPath remaps a command only when its path starts with the
|
||||
// current root, and CoStrict's starts with .cospec. Same after-generation
|
||||
// timing as Codex, so a divergent legacy skill is kept and reported.
|
||||
costrict: [{ root: '.cospec', needsConsent: false, timing: 'after-generation' }],
|
||||
};
|
||||
|
||||
export interface LegacyToolMigration {
|
||||
|
||||
@@ -88,7 +88,13 @@ export function hasToolProfileOrDeliveryDrift(
|
||||
if (!fs.existsSync(legacySkill)) continue;
|
||||
|
||||
const currentSkill = path.join(skillsDir, dirName, 'SKILL.md');
|
||||
if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) {
|
||||
// An update is due when the current skill is missing, or when no tool
|
||||
// has claimed the shared root yet, since generating writes the marker.
|
||||
// Once any tool owns the root, an update cannot change the current copy
|
||||
// for this one. Requiring this tool to be the owner looped forever for a
|
||||
// tool sharing a root another tool owns: its divergent legacy copy is
|
||||
// kept by design, so every run asked to sync again.
|
||||
if (!fs.existsSync(currentSkill) || sharedTarget === undefined) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -445,6 +445,24 @@ describe('available-tools', () => {
|
||||
expect(toolValues).toContain('github-copilot');
|
||||
});
|
||||
|
||||
it('detects CoStrict from its .cospec folder', async () => {
|
||||
// Detection falls back to skillsDir, which is now the shared .agents
|
||||
// root, so without its own detection path CoStrict would no longer be
|
||||
// found from the folder that actually marks a CoStrict project.
|
||||
await fs.mkdir(path.join(testDir, '.cospec'), { recursive: true });
|
||||
|
||||
expect(getAvailableTools(testDir).map((t) => t.value)).toContain('costrict');
|
||||
});
|
||||
|
||||
it('does not take a bare shared .agents folder as a sign of CoStrict', async () => {
|
||||
// CoStrict's skills now live in the shared .agents root, which Amp,
|
||||
// Codex, Zed and others also use. Shared-root reconciliation keeps a
|
||||
// bare .agents folder from naming CoStrict; this pins that invariant.
|
||||
await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true });
|
||||
|
||||
expect(getAvailableTools(testDir).map((t) => t.value)).not.toContain('costrict');
|
||||
});
|
||||
|
||||
it('should detect Hermes Agent when HERMES.md exists', async () => {
|
||||
await fs.writeFile(path.join(testDir, 'HERMES.md'), '');
|
||||
|
||||
|
||||
@@ -194,3 +194,52 @@ describe('profile sync drift detection', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy skills under a shared root another tool owns', () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-shared-legacy-'));
|
||||
const agentsSkills = path.join(projectDir, '.agents', 'skills');
|
||||
fs.mkdirSync(agentsSkills, { recursive: true });
|
||||
for (const workflow of CORE_WORKFLOWS) {
|
||||
const dir = WORKFLOW_TO_SKILL_DIR[workflow as keyof typeof WORKFLOW_TO_SKILL_DIR];
|
||||
fs.mkdirSync(path.join(agentsSkills, dir), { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsSkills, dir, 'SKILL.md'), `name: ${dir}\nrendered for codex\n`);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeLegacySkill(root: string, body: string): void {
|
||||
const file = path.join(projectDir, root, 'skills', 'openspec-propose', 'SKILL.md');
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body);
|
||||
}
|
||||
|
||||
it.each([
|
||||
['antigravity', '.agent'],
|
||||
['costrict', '.cospec'],
|
||||
])(
|
||||
'does not ask %s to sync on every run for a divergent legacy copy it cannot change',
|
||||
(toolId, legacyRoot) => {
|
||||
// Codex owns .agents, so updating this tool never rewrites the current
|
||||
// copy, and migration keeps a divergent legacy one by design. Reporting
|
||||
// drift here made every `openspec update` sync this tool again.
|
||||
fs.writeFileSync(path.join(projectDir, '.agents', 'skills', '.openspec-target'), 'codex\n');
|
||||
writeLegacySkill(legacyRoot, 'My customized propose skill.\n');
|
||||
|
||||
expect(hasToolProfileOrDeliveryDrift(projectDir, toolId, CORE_WORKFLOWS, 'skills')).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('still asks for a sync while no tool has claimed the shared root', () => {
|
||||
// Generating writes the ownership marker, so an update can change this.
|
||||
writeLegacySkill('.cospec', 'My customized propose skill.\n');
|
||||
|
||||
expect(hasToolProfileOrDeliveryDrift(projectDir, 'costrict', CORE_WORKFLOWS, 'skills')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2180,6 +2180,65 @@ metadata:
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoStrict skills root', () => {
|
||||
const cospec = () => path.join(testDir, '.cospec');
|
||||
const agentsSkills = () => path.join(testDir, '.agents', 'skills');
|
||||
|
||||
/** Recreates the layout earlier releases wrote: skills under .cospec. */
|
||||
async function moveSkillsToLegacyRoot(): Promise<void> {
|
||||
await fs.mkdir(path.join(cospec(), 'skills'), { recursive: true });
|
||||
for (const entry of await fs.readdir(agentsSkills())) {
|
||||
if (!entry.startsWith('openspec-')) continue;
|
||||
await fs.rename(path.join(agentsSkills(), entry), path.join(cospec(), 'skills', entry));
|
||||
}
|
||||
await fs.rm(path.join(testDir, '.agents'), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
it('installs CoStrict skills where CoStrict reads them, and leaves its commands in .cospec', async () => {
|
||||
await new InitCommand({ tools: 'costrict', force: true }).execute(testDir);
|
||||
|
||||
expect(
|
||||
await FileSystemUtils.fileExists(path.join(agentsSkills(), 'openspec-propose', 'SKILL.md'))
|
||||
).toBe(true);
|
||||
expect(await FileSystemUtils.fileExists(path.join(cospec(), 'skills'))).toBe(false);
|
||||
expect(
|
||||
await FileSystemUtils.fileExists(path.join(cospec(), 'openspec', 'commands', 'opsx-propose.md'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('moves an older install\'s skills out of .cospec and keeps everything else there', async () => {
|
||||
await new InitCommand({ tools: 'costrict', force: true }).execute(testDir);
|
||||
await moveSkillsToLegacyRoot();
|
||||
const ownFile = path.join(cospec(), 'requirements.md');
|
||||
await fs.writeFile(ownFile, '# CoStrict coworkflow spec\n');
|
||||
|
||||
await new UpdateCommand({ force: true }).execute(testDir);
|
||||
|
||||
expect(
|
||||
await FileSystemUtils.fileExists(path.join(agentsSkills(), 'openspec-propose', 'SKILL.md'))
|
||||
).toBe(true);
|
||||
expect(await FileSystemUtils.fileExists(path.join(cospec(), 'skills'))).toBe(false);
|
||||
// Commands live in .cospec on purpose, the only path CoStrict reads
|
||||
// them from, so the skills move must not take them along.
|
||||
expect(
|
||||
await FileSystemUtils.fileExists(path.join(cospec(), 'openspec', 'commands', 'opsx-propose.md'))
|
||||
).toBe(true);
|
||||
expect(await fs.readFile(ownFile, 'utf-8')).toBe('# CoStrict coworkflow spec\n');
|
||||
});
|
||||
|
||||
it('is up to date after the move, not asked to sync again', async () => {
|
||||
await new InitCommand({ tools: 'costrict', force: true }).execute(testDir);
|
||||
await moveSkillsToLegacyRoot();
|
||||
await new UpdateCommand({ force: true }).execute(testDir);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await new UpdateCommand().execute(testDir);
|
||||
const logs = consoleSpy.mock.calls.flat().map(String);
|
||||
|
||||
expect(logs.some((entry) => entry.includes('up to date'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill content validation', () => {
|
||||
it('should generate valid YAML frontmatter in skill files', async () => {
|
||||
// Set up a configured tool
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AI_TOOLS } from '../src/core/config.js';
|
||||
import { CommandAdapterRegistry } from '../src/core/command-generation/index.js';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
// A Windows checkout with core.autocrlf rewrites line endings, and the
|
||||
// section splits below key on "\n".
|
||||
const SUPPORTED = fs
|
||||
.readFileSync(path.join(REPO_ROOT, 'docs-lab', 'reference', 'supported-tools.md'), 'utf-8')
|
||||
.replace(/\r\n/g, '\n');
|
||||
const section = (heading: string) =>
|
||||
SUPPORTED.split(`### ${heading}\n`)[1].split('\n### ')[0];
|
||||
const tool = (id: string) => AI_TOOLS.find((candidate) => candidate.value === id)!;
|
||||
|
||||
// Where each tool loads skills from is checked against its upstream source;
|
||||
// these pin the docs to the config so the two cannot drift (#2022).
|
||||
describe('skill target documentation', () => {
|
||||
it('documents CoStrict skills in the folder the CLI writes them to', () => {
|
||||
expect(tool('costrict').skillsDir).toBe('.agents');
|
||||
expect(SUPPORTED).toContain('| CoStrict | `costrict` | `.agents/skills/` |');
|
||||
expect(section('CoStrict')).toContain('never from `.cospec/`');
|
||||
});
|
||||
|
||||
it('documents CoStrict commands where the adapter writes them', () => {
|
||||
const commandPath = CommandAdapterRegistry.get('costrict')!.getFilePath('propose');
|
||||
expect(commandPath.split(path.sep).join('/')).toBe('.cospec/openspec/commands/opsx-propose.md');
|
||||
expect(section('CoStrict')).toContain('`.cospec/openspec/commands/`');
|
||||
});
|
||||
|
||||
it('gives the same Hermes setup step in the CLI note and the docs', () => {
|
||||
const note = tool('hermes').setupNote!;
|
||||
for (const claim of ['hermes skills trust', 'v2026.6.5', 'skills.external_dirs']) {
|
||||
expect(note).toContain(claim);
|
||||
expect(section('Hermes Agent')).toContain(claim);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user