Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5.5 0b192a02c1 test: read the supported-tools page with normalized line endings
The new docs-claims test split sections on "\n", which finds nothing in a
Windows checkout with core.autocrlf, so all three cases failed on
windows-pwsh. The page is now read with CRLF normalized; verified locally
against a CRLF copy of the file.

Part of #2022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:05:29 -05:00
Clay GoodandClaude Opus 5.5 d0f268cd0d fix(costrict): install skills where CoStrict reads them
CoStrict loads project skills from .agents/skills, .claude/skills and
.roo/skills (SkillsManager.ts upstream), never from .cospec, which holds
its own coworkflow files. OpenSpec wrote skills to .cospec/skills, so
CoStrict users depended on commands alone, and #2022's skills-only
default would have left them with no working workflow.

The skills root moves to the shared .agents folder, registered the same
way as the Codex and Antigravity moves: legacySkillsDirs, a
LEGACY_TOOL_ROOTS entry with after-generation timing, and detectionPaths
so a bare .agents folder never stands in for CoStrict. Only skills move;
legacyCommandPath remaps a command only when its path starts with the
current root, so .cospec/openspec/commands, the only path CoStrict reads
commands from, is untouched.

Sharing .agents exposed an existing loop: a legacy skill counted as drift
whenever the tool was not the shared root's owner, so a tool sharing a
root another tool owns, with a divergent legacy copy migration keeps by
design, re-synced on every run. Reproduced on main with Antigravity and
Codex. Drift is now forced only while the root is unclaimed, matching the
stated intent that divergent copies never force an update every run.

The Hermes setup note is updated: since v2026.6.5 Hermes loads project
skills once the repo is trusted with `hermes skills trust`.

Closes #2022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:43:57 -05:00
9 changed files with 214 additions and 11 deletions
@@ -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.
+23 -8
View File
@@ -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
View File
@@ -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 },
+7
View File
@@ -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 {
+7 -1
View File
@@ -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 {
+18
View File
@@ -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'), '');
+49
View File
@@ -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);
});
});
+59
View File
@@ -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
+40
View File
@@ -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);
}
});
});