Skip to content
Merged
9 changes: 9 additions & 0 deletions .changeset/command-adapter-yaml-escaping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@fission-ai/openspec': patch
---

Generated tool command files now carry valid YAML frontmatter for every supported tool. Command names ship as `OPSX: Explore`, and the unquoted `name: OPSX: Explore` that adapters emitted is not parseable YAML — strict parsers rejected the whole file, so the command failed to load. Several adapters also re-implemented their own escaping, and a few interpolated descriptions in raw.

Escaping now lives in one place (`escapeYamlValue` / `formatTagsArray`) and every adapter uses it. String frontmatter values are always double-quoted, which also keeps values like `true`, `null` and `123` from round-tripping as booleans, nulls and numbers. Non-string fields such as `allowed-tools` and `invokable` are unchanged. Expect the first `openspec update` after upgrading to rewrite the frontmatter lines of your generated command files.

Archive workflow guidance also gets two corrections: bulk archive now carries its per-delta include/exclude decisions into execution, so a delta whose implementation was not found is reported as `sync skipped` instead of being synced anyway, and both archive workflows verify the main specs before moving the change directory.
49 changes: 36 additions & 13 deletions skills/openspec-bulk-archive-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
- If neither implemented -> skip spec sync, warn user

d. **Record resolution** for each conflict:
- Which change's specs to apply
- In what order (if both)
- An inclusion or exclusion decision for every delta spec, keyed by change and capability
- Which included delta specs to apply and in what order
- Which delta specs to exclude from sync because their implementation is missing
- Rationale (what was found in codebase)

6. **Show consolidated status table**
Expand Down Expand Up @@ -151,7 +152,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
so match what the user picked rather than the wording above:
- "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps.
- The archive-everything option — proceed with every selected change
- The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8c. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived.
- The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived.
- Anything else — ask again rather than archiving

Before step 8 writes the first main spec or moves any change, fetch every
Expand All @@ -166,19 +167,35 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig

8. **Execute archive for each confirmed change**

Before processing, carry the recorded decisions from step 5 (after any step 7 re-derivation) into two per-delta sets:
- `includedDeltas`: all non-conflicting delta specs from confirmed changes plus conflict deltas selected for sync
- `excludedDeltas`: conflict deltas from confirmed changes excluded because their implementation is missing
- A single change can have both included and excluded delta specs. Keep the decision per delta; do not collapse it into a per-change sync flag.

Process changes in the determined order (respecting conflict resolution):

a. **Sync specs** if delta specs exist:
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
- For conflicts, apply in resolved order
a. **Sync included delta specs**:
- Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) only for changes with entries in `includedDeltas`, passing only the included delta paths and explicitly instructing it to ignore that change's `excludedDeltas`. Wait for it to finish.
- For conflicts, apply in resolved order.
- Pass that change's fetched specs-rule snapshot into inline sync; inline
sync must reuse it without fetching instructions again
- Apply artifact rules only to main specs produced by that change. They do
not change conflict resolution, archive behavior, or CLI contracts, and
their text is not copied into an output file
- Track if sync was done
- Do not delegate to a background task — step 8c would move `changeRoot` out from under a sync that is still reading it.
- If a change has no included delta specs, do not run the sync workflow for it.

b. **Verify included delta specs before moving changeRoot**:
- Re-run the comparison only for delta specs in `includedDeltas` against main spec at `<planningHome.root>/openspec/specs/<capability>/spec.md` (use the store-aware `planningHome.root` from step 3 status JSON, not a hardcoded repo path).
- Verify that main specs are updated:
- ADDED requirements present
- MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
- REMOVED requirements gone
- RENAMED requirements present under the new name and absent under the old one
- Do not verify delta specs in `excludedDeltas`; they are intentionally left unsynced.
- If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's `changeRoot` — do not archive that change. `changeRoot` remains intact.

b. **Perform the archive**:
c. **Perform the archive**:

Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<name>` (same rule as `openspec archive`).

Expand All @@ -187,10 +204,11 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>"
```

c. **Track outcome** for each change:
d. **Track outcome** for each change:
- Success: archived successfully
- Failed: error during archive (record error)
- Failed: error during archive or spec verification (record error)
- Skipped: user chose not to archive (if applicable)
- Sync skipped: for every delta in `excludedDeltas`, report `sync skipped` with the change, capability, and recorded reason. This is distinct from skipping the archive.

9. **Display summary**

Expand All @@ -209,7 +227,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig

Spec sync summary:
- 4 delta specs synced to main specs
- 1 conflict resolved (auth: applied both in chronological order)
- 1 delta spec sync skipped (add-jwt/auth: implementation not found)
- 1 conflict resolved (auth: synced add-oauth, skipped add-jwt)
```

If any failures:
Expand All @@ -222,7 +241,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig

Example 1: Only one implemented
```text
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
Conflict: <planningHome.root>/openspec/specs/auth/spec.md touched by [add-oauth, add-jwt]

Checking add-oauth:
- Delta adds "OAuth Provider Integration" requirement
Expand All @@ -237,7 +256,7 @@ Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.

Example 2: Both implemented
```text
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
Conflict: <planningHome.root>/openspec/specs/api/spec.md touched by [add-rest-api, add-graphql]

Checking add-rest-api (created 2026-01-10):
- Delta adds "REST Endpoints" requirement
Expand Down Expand Up @@ -301,6 +320,10 @@ No active changes found. Create a new change to get started.
- Preserve .openspec.yaml when moving to archive
- Archive directory target uses current date: YYYY-MM-DD-<name>; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date)
- If archive target exists, fail that change but continue with others
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs
- Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas
- Report every excluded delta as `sync skipped` without treating the archive itself as skipped
- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at `<planningHome.root>/openspec/specs/<capability>/spec.md` before moving `changeRoot`
- Fetch archive inputs once per selected root before spec inspection or moves
- Fetch all required specs-rule snapshots before the batch's first main-spec write or move
- A failed archive-inputs lookup never blocks the batch; it proceeds with no context or guidance
Expand Down
17 changes: 15 additions & 2 deletions skills/openspec-sync-specs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,23 @@ This is an **agent-driven** operation - you will read delta specs and directly e
3. **Find delta specs**

Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the
complete list of delta spec files. If the `specs` entry is missing or
only source of delta spec paths. If the `specs` entry is missing or
`existingOutputPaths` is empty, report that there are no delta specs to sync,
do not infer them from other artifacts, and stop without requesting artifact
instructions or writing a main spec.

Sync every path in `existingOutputPaths` unless the caller narrowed the set.
A caller narrows it by naming an explicit list of delta spec paths to sync —
archive does this inline, and a user can too ("only sync the billing delta").
Then sync only the named paths and leave the remaining delta specs untouched:
bulk archive excludes a delta whose implementation it could not find, and
syncing it anyway would write a main spec the caller deliberately withheld.
Carry that narrowed selection through step 4; never widen it back to the full
list. If a named path is not in `existingOutputPaths`, do not sync it —
report it and stop, rather than dropping it silently. If the named list is
empty, report that there is nothing to sync and stop without writing a main
spec.

Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
Expand All @@ -70,7 +82,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e
selected roots, delta paths, CLI checks, or workflow steps. Use their text as
constraints without copying it verbatim into a main spec or summary.

For each capability delta spec path returned by the CLI (these may belong to a selected store, not the repo):
For each capability delta spec path selected in step 3 — the full `existingOutputPaths` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo):

a. **Read the delta spec** to understand the intended changes

Expand Down Expand Up @@ -201,6 +213,7 @@ Main specs are now updated. The change remains active - archive when implementat
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
- Use only `artifactPaths.specs.existingOutputPaths`; never infer delta specs from unrelated artifacts
- Honor a caller-supplied subset of `existingOutputPaths`; never widen it back to the full list
- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline
- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response
- Artifact rules constrain only the specs being written and are never copied into output files
3 changes: 2 additions & 1 deletion src/core/command-generation/adapters/amazon-q.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* Amazon Q adapter for command generation.
Expand All @@ -21,7 +22,7 @@ export const amazonQAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
description: ${content.description}
description: ${escapeYamlValue(content.description)}
---

${content.body}
Expand Down
3 changes: 2 additions & 1 deletion src/core/command-generation/adapters/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* Antigravity adapter for command generation.
Expand All @@ -21,7 +22,7 @@ export const antigravityAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
description: ${content.description}
description: ${escapeYamlValue(content.description)}
---

${content.body}
Expand Down
3 changes: 2 additions & 1 deletion src/core/command-generation/adapters/auggie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* Auggie adapter for command generation.
Expand All @@ -21,7 +22,7 @@ export const auggieAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
description: ${content.description}
description: ${escapeYamlValue(content.description)}
argument-hint: command arguments
---

Expand Down
7 changes: 5 additions & 2 deletions src/core/command-generation/adapters/bob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import { escapeYamlValue } from '../yaml.js';
/**
* Bob Shell adapter for command generation.
* File path: .bob/commands/opsx-<id>.md
* Frontmatter: description, argument-hint
* Frontmatter: description
*
* Bob uses the filename (minus .md) as the slash command name, so
* opsx-propose.md → /opsx-propose. Command references in the body
* are transformed from /opsx: to /opsx- for consistency.
*/
export const bobAdapter: ToolCommandAdapter = {
toolId: 'bob',
Expand All @@ -23,7 +27,6 @@ export const bobAdapter: ToolCommandAdapter = {
},

formatFile(content: CommandContent): string {
// Transform command references from colon to hyphen format for Bob
const transformedBody = transformToHyphenCommands(content.body);

return `---
Expand Down
10 changes: 1 addition & 9 deletions src/core/command-generation/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,9 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';
import { escapeYamlValue, formatTagsArray } from '../yaml.js';
import { OPENSPEC_CLI_ALLOWED_TOOLS } from '../../shared/allowed-tools.js';

/**
* Formats a tags array as a YAML array with proper escaping.
*/
function formatTagsArray(tags: string[]): string {
const escapedTags = tags.map((tag) => escapeYamlValue(tag));
return `[${escapedTags.join(', ')}]`;
}

/**
* Claude Code adapter for command generation.
* File path: .claude/commands/opsx/<id>.md
Expand Down
5 changes: 3 additions & 2 deletions src/core/command-generation/adapters/codebuddy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* CodeBuddy adapter for command generation.
Expand All @@ -21,8 +22,8 @@ export const codebuddyAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
name: ${content.name}
description: "${content.description}"
name: ${escapeYamlValue(content.name)}
description: ${escapeYamlValue(content.description)}
argument-hint: "[command arguments]"
---

Expand Down
5 changes: 3 additions & 2 deletions src/core/command-generation/adapters/continue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* Continue adapter for command generation.
Expand All @@ -21,8 +22,8 @@ export const continueAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
name: opsx-${content.id}
description: ${content.description}
name: ${escapeYamlValue(`opsx-${content.id}`)}
description: ${escapeYamlValue(content.description)}
invokable: true
---

Expand Down
3 changes: 2 additions & 1 deletion src/core/command-generation/adapters/costrict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* CoStrict adapter for command generation.
Expand All @@ -21,7 +22,7 @@ export const costrictAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
description: "${content.description}"
description: ${escapeYamlValue(content.description)}
argument-hint: command arguments
---

Expand Down
10 changes: 5 additions & 5 deletions src/core/command-generation/adapters/crush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue, formatTagsArray } from '../yaml.js';

/**
* Crush adapter for command generation.
Expand All @@ -20,12 +21,11 @@ export const crushAdapter: ToolCommandAdapter = {
},

formatFile(content: CommandContent): string {
const tagsStr = content.tags.join(', ');
return `---
name: ${content.name}
description: ${content.description}
category: ${content.category}
tags: [${tagsStr}]
name: ${escapeYamlValue(content.name)}
description: ${escapeYamlValue(content.description)}
category: ${escapeYamlValue(content.category)}
tags: ${formatTagsArray(content.tags)}
---

${content.body}
Expand Down
4 changes: 2 additions & 2 deletions src/core/command-generation/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export const cursorAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
name: /opsx-${content.id}
id: opsx-${content.id}
name: ${escapeYamlValue(`/opsx-${content.id}`)}
id: ${escapeYamlValue(`opsx-${content.id}`)}
category: ${escapeYamlValue(content.category)}
description: ${escapeYamlValue(content.description)}
---
Expand Down
3 changes: 2 additions & 1 deletion src/core/command-generation/adapters/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* Factory adapter for command generation.
Expand All @@ -21,7 +22,7 @@ export const factoryAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
description: ${content.description}
description: ${escapeYamlValue(content.description)}
argument-hint: command arguments
---

Expand Down
3 changes: 2 additions & 1 deletion src/core/command-generation/adapters/github-copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* GitHub Copilot adapter for command generation.
Expand All @@ -21,7 +22,7 @@ export const githubCopilotAdapter: ToolCommandAdapter = {

formatFile(content: CommandContent): string {
return `---
description: ${content.description}
description: ${escapeYamlValue(content.description)}
---
${content.body}
Expand Down
Loading
Loading