fix(archive): stop sending the archive skill to an uninstalled sync skill (#1977)

* fix(archive): stop sending the archive skill to an uninstalled sync skill

The archive skill always told the agent to run the `openspec-sync-specs`
skill, even when that skill was not installed, so an agent following it
stalled at the sync step. The archive command already chose between the
sync workflow and an inline merge based on what was installed; the skill
now does the same.

Closes #1975

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): document archive sync fallback

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-09-29 20:23:49 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 405d8b51ed
commit 772819417a
6 changed files with 59 additions and 5 deletions
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---
The `openspec-archive-change` skill no longer tells the agent to run the `openspec-sync-specs` skill when that skill is not installed. It merges the delta specs into the main specs itself instead, as the `/opsx:archive` command already did (#1975).
+1 -1
View File
@@ -121,7 +121,7 @@ Move a finished change proposal to the archive.
| Contract | Description |
|---|---|
| **Arguments** | A change proposal name, optional. |
| **Creates** | Moves the change proposal folder to `openspec/changes/archive/YYYY-MM-DD-<name>/` (no date added if the name already starts with one). With your approval it first syncs outstanding delta specs via `openspec-sync-specs`. Never code. |
| **Creates** | Moves the change proposal folder to `openspec/changes/archive/YYYY-MM-DD-<name>/` (no date added if the name already starts with one). With your approval it first syncs outstanding delta specs. When `openspec-sync-specs` is installed, it runs that workflow. Otherwise, it merges the delta specs into the main specs itself. Never code. |
| **Response** | Warns and asks before archiving with incomplete artifacts or tasks, and asks whether to sync when delta specs exist. Ends with a summary: name, schema, archive location, spec sync status, and any warnings. |
## openspec-new-change
@@ -158,7 +158,7 @@ ${PROJECT_ROOT_GUARD}
form of main specs produced by this merge; do not use them as archive guidance,
change CLI behavior, or copy the rule text into any output file.
Then run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
Then ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
If the sync reports any stop or blocking condition, treat the sync as failed.
Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
@@ -224,7 +224,7 @@ ${PROJECT_ROOT_GUARD}
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven)
- If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)')}
- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\`
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
- Apply relevant runtime context and report conflicts; operation guidance remains advisory
@@ -94,4 +94,31 @@ describe('workflow list --json field usage', () => {
expect(withoutSync).toContain('If sync is requested, perform the delta-to-main-spec merge inline');
}
});
it('keeps archive sync available with and without the sync workflow', () => {
const variants = [
[
getSkillTemplates(['archive', 'sync']).find((entry) => entry.workflowId === 'archive')!.template.instructions,
getSkillTemplates(['archive'])[0].template.instructions,
'openspec-sync-specs',
],
[
getCommandTemplates(['archive', 'sync']).find((entry) => entry.id === 'archive')!.template.content,
getCommandTemplates(['archive'])[0].template.content,
'/opsx:sync',
],
] as const;
for (const [withSync, withoutSync, workflow] of variants) {
const syncStep = (text: string) => text.slice(
text.indexOf('4. **Assess delta spec sync state**'),
text.indexOf('5. **Perform the archive**')
);
expect(syncStep(withSync)).toContain(workflow);
expect(withoutSync).not.toContain(workflow);
expect(syncStep(withoutSync)).toContain('perform the delta-to-main-spec merge inline yourself');
expect(withoutSync).toContain('If sync is requested, perform the delta-to-main-spec merge inline');
}
});
});
@@ -88,7 +88,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getOpsxContinueCommandTemplate: '241c50f97d5d681412d456d6b982743c3a5babeb77017fc8099c418bcf0d92df',
getOpsxApplyCommandTemplate: '21ccc013710ffb9f3a93ede9f9fb7e3eecaac292fcb3f3be30e2562cae9c4127',
getOpsxFfCommandTemplate: '743a7304c7efc84aa87f556154c034e1e0e561c276c51870a30ada58f33eb9af',
getArchiveChangeSkillTemplate: '286a8e56580f35f179fe050efbddcae87ef4d6ac68c24b1fce91c6651d5295be',
getArchiveChangeSkillTemplate: '04a029782fc4137971fad6f54cfda3685d7e0b835b06565f9a509b4878093099',
getBulkArchiveChangeSkillTemplate: '44dbd3c7a347e5f8339b2141393f2ac36017527cce251483fe70f2059c1e286e',
getOpsxSyncCommandTemplate: '60550b7bb9829421656d6324a9e4c951bc912f48f88882d1a07ce7f78397a5e7',
getVerifyChangeSkillTemplate: 'eecb063792075191b613978dec45f9f2fee247d2ff3003f2ebf17d632e54352e',
@@ -619,7 +619,7 @@ describe('skill templates split parity', () => {
// never written. Assertions are scoped to the sync-assessment step so they
// cannot pass on unrelated text elsewhere in the body.
const archiveVariants: Array<[string, string]> = [
['archive skill', generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE')],
['archive skill', generateSkillContent(asDeployed(getArchiveChangeSkillTemplate()), 'PARITY-BASELINE')],
['archive opsx command', getOpsxArchiveCommandTemplate().content],
];
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SKILLS = fs.readFileSync(
path.join(REPO_ROOT, 'docs-lab', 'reference', 'skills.md'),
'utf-8'
);
const ARCHIVE_SECTION = SKILLS.split('## openspec-archive-change')[1].split(
'## openspec-new-change'
)[0];
describe('skills documentation', () => {
it('documents archive sync with and without the sync workflow (#1975)', () => {
expect(ARCHIVE_SECTION).toContain('When `openspec-sync-specs` is installed');
expect(ARCHIVE_SECTION).toContain(
'Otherwise, it merges the delta specs into the main specs itself.'
);
});
});