Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/archive-carries-delta-purpose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@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 placeholder still appears when the delta has no Purpose (or an empty one), and the Purpose of an existing main spec is never touched.
12 changes: 12 additions & 0 deletions openspec/specs/cli-archive/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
- **THEN** abort with error message showing the conflict
- **AND** suggest manual resolution

#### Scenario: New main spec inherits the delta's Purpose

- **WHEN** a delta creates a main spec that does not exist yet
- **AND** the delta spec has a non-empty `## Purpose` section outside fenced code blocks
- **THEN** write that Purpose into the new main spec

#### Scenario: New main spec without an authored Purpose

- **WHEN** a delta creates a main spec that does not exist yet
- **AND** the delta spec has no `## Purpose` section, or an empty one
- **THEN** write the TBD placeholder Purpose naming the change to update after archive

### Requirement: Confirmation Behavior

The spec update confirmation SHALL provide clear visibility into changes before they are applied.
Expand Down
7 changes: 7 additions & 0 deletions schemas/spec-driven/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ artifacts:
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
- Every requirement MUST have at least one scenario.

New capabilities only: start the delta spec with a `## Purpose` section -
one or two sentences describing what the capability is for. Archive copies
it into the main spec it creates; without it the new main spec is left with
a `TBD ... Update Purpose after archive` placeholder to fill in by hand.
Do NOT add `## Purpose` to a delta for an existing capability - that spec
already has one and the delta's is ignored.

MODIFIED requirements workflow:
1. Locate the existing requirement in openspec/specs/<capability>/spec.md
2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)
Expand Down
36 changes: 32 additions & 4 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type RequirementBlock,
} from './parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
import { buildCodeFenceMask } from './parsers/code-fence.js';
import { Validator } from './validation/validator.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

Expand Down Expand Up @@ -221,7 +222,7 @@ export async function buildUpdatedSpec(
);
}
isNewSpec = true;
targetContent = buildSpecSkeleton(specName, changeName);
targetContent = buildSpecSkeleton(specName, changeName, extractPurposeSection(changeContent));
}

const structureIssues = findMainSpecStructureIssues(targetContent);
Expand Down Expand Up @@ -400,11 +401,38 @@ export async function writeUpdatedSpec(
}

/**
* Build a skeleton spec for new capabilities.
* Read the body of a `## Purpose` section, ignoring fenced code blocks.
* Returns undefined when the section is absent or empty.
*/
export function buildSpecSkeleton(specFolderName: string, changeName: string): string {
function extractPurposeSection(content: string): string | undefined {
const lines = content.replace(/\r\n?/g, '\n').split('\n');
const mask = buildCodeFenceMask(lines);
const start = lines.findIndex((line, i) => !mask[i] && /^##\s+Purpose\s*$/i.test(line));
if (start === -1) return undefined;

let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (!mask[i] && /^##\s+/.test(lines[i])) {
end = i;
break;
}
}

const body = lines.slice(start + 1, end).join('\n').trim();
return body || undefined;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Build a skeleton spec for new capabilities. When the delta spec authored a
* `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
* invents the Purpose for a brand-new main spec either way, and the author's
* own wording beats a placeholder they then have to hand-edit.
*/
export function buildSpecSkeleton(specFolderName: string, changeName: string, purpose?: string): string {
const titleBase = specFolderName;
return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
const purposeBody =
purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
}

function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[] {
Expand Down
107 changes: 105 additions & 2 deletions test/core/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ describe('ArchiveCommand', () => {

beforeEach(async () => {
// Create temp directory
tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-test-'));

// Change to temp directory
process.chdir(tempDir);
Expand Down Expand Up @@ -489,6 +488,110 @@ The system SHALL support logo and backgroundColor fields for gift cards.
expect(archives.some(a => a.includes(changeName))).toBe(true);
});

it('should carry the delta Purpose into a new main spec (issue #1413)', async () => {
const changeName = 'new-spec-with-purpose';
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'loyalty');
await fs.mkdir(changeSpecDir, { recursive: true });

const specContent = `## Purpose

Tracks loyalty points earned and redeemed across the storefront.

## ADDED Requirements

### Requirement: Earn Points
The system SHALL award loyalty points on each completed order.

#### Scenario: Order completes
- **WHEN** an order completes
- **THEN** points are credited to the customer
`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);

await archiveCommand.execute(changeName, { yes: true, noValidate: true });

const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'loyalty', 'spec.md');
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain('Tracks loyalty points earned and redeemed across the storefront.');
expect(updatedContent).not.toContain('TBD - created by archiving change');
expect(updatedContent).toContain('### Requirement: Earn Points');
});

it('should keep the TBD Purpose placeholder when the delta has no Purpose (issue #1413)', async () => {
const changeName = 'new-spec-without-purpose';
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'referrals');
await fs.mkdir(changeSpecDir, { recursive: true });

const specContent = `## ADDED Requirements

### Requirement: Send Invite
The system SHALL send a referral invite.

#### Scenario: Invite sent
- **WHEN** a customer refers a friend
- **THEN** an invite email is sent
`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

await archiveCommand.execute(changeName, { yes: true, noValidate: true });

const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'referrals', 'spec.md');
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain(
`TBD - created by archiving change ${changeName}. Update Purpose after archive.`
);
});

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');
await fs.mkdir(changeSpecDir, { recursive: true });

const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'billing');
await fs.mkdir(mainSpecDir, { recursive: true });
await fs.writeFile(
path.join(mainSpecDir, 'spec.md'),
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
`# billing Specification

## Purpose
The established purpose that must survive archiving.

## Requirements

### Requirement: Charge Card
The system SHALL charge the card on file.

#### Scenario: Card charged
- **WHEN** an invoice is due
- **THEN** the card is charged
`
);

await fs.writeFile(
path.join(changeSpecDir, 'spec.md'),
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
`## Purpose

A purpose written in the delta that must be ignored for an existing spec.

## ADDED Requirements

### Requirement: Refund Card
The system SHALL refund the card on file.

#### Scenario: Refund issued
- **WHEN** a refund is approved
- **THEN** the card is refunded
`
);

await archiveCommand.execute(changeName, { yes: true, noValidate: true });

const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updatedContent).toContain('The established purpose that must survive archiving.');
expect(updatedContent).not.toContain('A purpose written in the delta that must be ignored');
expect(updatedContent).toContain('### Requirement: Refund Card');
});

it('should still error on MODIFIED when creating new spec file', async () => {
const changeName = 'new-spec-with-modified';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
Expand Down
Loading