fix(archive): never let a carried Purpose abort the archive

Self-review found a regression introduced by the carry-over: a delta
whose `## Purpose` body contains a `### Requirement:` header put that
header outside `## Requirements` in the new main spec, so the structure
guard rejected it and archive exited 1. The same delta archived fine
before this branch.

Fall back to the placeholder and warn when the carried Purpose would
make the new spec structurally invalid, so archive completes as it did
before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-07-23 09:07:51 -05:00
co-authored by Claude Opus 4.8
parent 2ccbf32a84
commit 651b42c6eb
4 changed files with 64 additions and 1 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
'@fission-ai/openspec': patch
---
`openspec archive` now carries a delta spec's `## Purpose` into the main spec it creates for a brand-new capability, instead of always writing the `TBD - created by archiving change <name>. Update Purpose after archive.` placeholder over it. The section body is copied verbatim, fenced code blocks included. The placeholder still appears when the delta has no `## Purpose` header outside a code fence, or when the section body is empty, and the Purpose of an existing main spec is never touched.
`openspec archive` now carries a delta spec's `## Purpose` into the main spec it creates for a brand-new capability, instead of always writing the `TBD - created by archiving change <name>. Update Purpose after archive.` placeholder over it. The section body is copied verbatim, fenced code blocks included. The placeholder still appears when the delta has no `## Purpose` header outside a code fence, when the section body is empty, or when carrying the body over would put a requirement header outside `## Requirements` (that last case warns and still completes the archive). The Purpose of an existing main spec is never touched.
+7
View File
@@ -103,6 +103,13 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
- **AND** the delta spec has no `## Purpose` header outside a fenced code block, or the section body is empty
- **THEN** write the TBD placeholder Purpose naming the change to update after archive
#### Scenario: Delta Purpose that would invalidate the new main spec
- **WHEN** a delta creates a main spec that does not exist yet
- **AND** carrying its `## Purpose` body over would place a requirement header outside the `## Requirements` section
- **THEN** write the TBD placeholder Purpose instead and warn that the delta Purpose was ignored
- **AND** complete the archive rather than aborting it
### Requirement: Confirmation Behavior
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
+13
View File
@@ -223,6 +223,19 @@ export async function buildUpdatedSpec(
}
isNewSpec = true;
targetContent = buildSpecSkeleton(specName, changeName, extractPurposeSection(changeContent));
// A carried Purpose that hides a requirement header would make the new main
// spec structurally invalid and abort an archive that succeeded before
// #1413. Keep the placeholder rather than turning a warning into a failure.
if (findMainSpecStructureIssues(targetContent).length > 0) {
targetContent = buildSpecSkeleton(specName, changeName);
if (!options.silent) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - delta Purpose ignored (it contains a requirement header); wrote the placeholder Purpose instead.`
)
);
}
}
}
const structureIssues = findMainSpecStructureIssues(targetContent);
+43
View File
@@ -635,6 +635,49 @@ The system SHALL send a notification.
);
});
it('should fall back to the placeholder when the delta Purpose hides a requirement header (issue #1413)', async () => {
const changeName = 'new-spec-with-stray-header-in-purpose';
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'widgets');
await fs.mkdir(changeSpecDir, { recursive: true });
// A delta an agent can plausibly emit. Carrying this Purpose verbatim
// would put a requirement header outside ## Requirements and abort the
// archive - which succeeded before the Purpose carry-over existed.
const specContent = `## Purpose
Handles widgets.
### Requirement: Stray header
## ADDED Requirements
### Requirement: Real Requirement
The system SHALL handle widgets.
#### Scenario: Widget handled
- **WHEN** a widget arrives
- **THEN** it is handled
`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'widgets', 'spec.md');
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain(
`TBD - created by archiving change ${changeName}. Update Purpose after archive.`
);
expect(updatedContent).not.toContain('### Requirement: Stray header');
expect(updatedContent).toContain('### Requirement: Real Requirement');
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining(`Warning: widgets - delta Purpose ignored (it contains a requirement header)`)
);
// The archive still completed rather than aborting.
const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'));
expect(archives.some(a => a.includes(changeName))).toBe(true);
});
it('should not overwrite the Purpose of an existing main spec (issue #1413)', async () => {
const changeName = 'existing-spec-with-purpose';
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'billing');