Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 13 additions & 0 deletions .changeset/archive-carries-delta-purpose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@fission-ai/openspec": patch
---

A delta spec that introduces a brand-new capability can now open with a `## Purpose`, and `openspec archive` uses it as the Purpose of the main spec it creates instead of writing the `TBD - created by archiving change <name>. Update Purpose after archive.` placeholder over it. The `specs` artifact instruction, its example, the delta template and the `openspec-sync-specs` skill all tell authors and agents to write one, so the CLI and agent-driven sync paths produce the same main spec.

Archive keeps the placeholder when the delta has no usable `## Purpose`:

- no `## Purpose` header outside a code fence or HTML comment, or a body that is only a code fence or only a comment
- a body that would leave a spec its own parser cannot read — a heading or requirement header that truncates a section, an unterminated fence, or any HTML comment
- in the second case archive also says why, and still completes rather than aborting

A carried Purpose under 50 characters is kept but warned about, since `openspec validate --strict` reports it as too brief. The Purpose of an existing main spec is never touched; archive warns when it ignores a delta's Purpose there.
1 change: 1 addition & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ The system MUST expire sessions after 15 minutes of inactivity.
| `## ADDED Requirements` | New behavior | Appended to main spec |
| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement |
| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec |
| `## Purpose` | What a brand-new capability is for | Seeds the Purpose of the main spec being created; ignored when the spec already exists |

### Why Deltas Instead of Full Specs

Expand Down
2 changes: 2 additions & 0 deletions docs/writing-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ A change describes its edits to the specs with three section types. Using the ri

On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is deleted. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.

One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs/<capability>/spec.md` directly to change one.

## Right-size the change

The single most common authoring mistake isn't a badly worded requirement — it's a change that's trying to be three changes.
Expand Down
32 changes: 32 additions & 0 deletions openspec/specs/cli-archive/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,38 @@ 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 line-initial `## Purpose` header that is not inside a fenced code block or an HTML comment
- **AND** the section body, ignoring fenced blocks and HTML comments, is not empty
- **THEN** write the section body into the new main spec, trimmed but otherwise verbatim, fenced code blocks included
- **AND** the section body runs to the next `## ` heading outside a fenced block

#### 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 such `## Purpose` header, or that section's body is empty once fenced blocks and HTML comments are ignored
- **THEN** write the TBD placeholder Purpose naming the change to update after archive

#### Scenario: Delta Purpose that would leave the new main spec unreadable

- **WHEN** a delta creates a main spec that does not exist yet
- **AND** carrying its `## Purpose` body over would leave a spec that reads differently to different readers - a heading or requirement header that truncates a section, an unterminated code fence that swallows one, or any HTML comment, which the section scan skips but the file keeps
- **THEN** write the TBD placeholder Purpose instead and warn that the delta Purpose was ignored
- **AND** complete the archive rather than aborting it

#### Scenario: Carried Purpose shorter than the strict-mode minimum

- **WHEN** the Purpose parsed back out of the new main spec is shorter than the minimum Purpose length strict validation enforces
- **THEN** carry it over unchanged and warn that `openspec validate --strict` reports it as too brief

#### Scenario: Delta Purpose for a capability that already has a main spec

- **WHEN** a delta carries a `## Purpose` and the target main spec already exists
- **THEN** leave the existing Purpose untouched
- **AND** warn that the delta Purpose was ignored, naming the spec file to edit directly, but only when that spec has a Purpose of its own and it differs from the delta's

### Requirement: Confirmation Behavior

The spec update confirmation SHALL provide clear visibility into changes before they are applied.
Expand Down
8 changes: 8 additions & 0 deletions openspec/specs/openspec-conventions/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,18 @@ Change proposals SHALL store only the additions, modifications, and removals to
The `changes/[name]/specs/` directory SHALL contain:
- Delta files showing only what changes
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
- An optional `## Purpose` section on deltas that introduce a new capability
- Normalized header matching for requirement identification
- Complete requirements using the structured format
- Clear indication of change type for each requirement

#### Scenario: Introducing a new capability

- **WHEN** a delta introduces a capability that has no main spec yet
- **THEN** the delta MAY open with a `## Purpose` section describing the capability
- **AND** that Purpose SHALL seed the main spec created for it
- **AND** a delta for a capability that already has a main spec SHOULD NOT carry a `## Purpose`, because the existing Purpose is authoritative and the delta's is ignored

#### Scenario: Using standard output symbols

- **WHEN** displaying delta operations in CLI output
Expand Down
2 changes: 2 additions & 0 deletions openspec/specs/specs-sync-skill/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ The agent SHALL reconcile main specs with delta specs using the delta operation
#### Scenario: New capability spec
- **WHEN** delta spec exists for a capability not in main specs
- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`
- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does
- **AND** write a brief TBD placeholder Purpose only when the delta has none

#### Scenario: Merged main spec keeps canonical structure
- **WHEN** the agent writes a main spec during sync
Expand Down
16 changes: 15 additions & 1 deletion schemas/spec-driven/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ 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 (50+ characters, or `openspec validate --strict`
reports it as too brief) 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. To change an
existing capability's Purpose - including a leftover `TBD` placeholder -
edit `openspec/specs/<capability>/spec.md` directly.

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 All @@ -90,8 +100,12 @@ artifacts:
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
If adding new concerns without changing existing behavior, use ADDED instead.

Example:
Example (a new capability, so it opens with `## Purpose`):
```
## Purpose

Lets users take their data out of the product in a portable format.

## ADDED Requirements

### Requirement: User can export data
Expand Down
3 changes: 3 additions & 0 deletions schemas/spec-driven/templates/spec.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## Purpose
<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->

## ADDED Requirements

### Requirement: <!-- requirement name -->
Expand Down
13 changes: 12 additions & 1 deletion skills/openspec-sync-specs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO

**`## Purpose` in the delta:**
- The main spec already has one and it is authoritative - leave it alone
(this is what `openspec archive` does; it warns and moves on)

d. **Create new main spec** if capability doesn't exist yet:
- Create `<planningHome.root>/openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
- Add Requirements section with the ADDED requirements
- Follow the **Main Spec Format Reference** below

Expand All @@ -87,10 +92,16 @@ This is an **agent-driven** operation - you will read delta specs and directly e
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
- Any new main spec left with a TBD Purpose placeholder, so it gets written
now rather than lingering

**Delta Spec Format Reference**

```markdown
## Purpose

Only on a delta that introduces a brand-new capability. Seeds the new main spec.

## ADDED Requirements

### Requirement: New Feature
Expand Down
142 changes: 138 additions & 4 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import {
type RequirementBlock,
} from './parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
import { buildCodeFenceMask } from './parsers/code-fence.js';
import { MarkdownParser } from './parsers/markdown-parser.js';
import { Validator } from './validation/validator.js';
import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -200,10 +203,28 @@ export async function buildUpdatedSpec(
}

// Load or create base target content
const deltaPurpose = extractPurposeSection(changeContent);
let targetContent: string;
let isNewSpec = false;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
// A delta Purpose only seeds a spec that does not exist yet. Say so rather
// than dropping it silently - the specs instruction tells authors to write
// one for new capabilities, and the delta file looks identical either way.
// Only when the spec really does have a different Purpose: claiming it
// "already has one" would be false when it has none, and saying anything at
// all is noise when the two bodies match.
if (deltaPurpose && !options.silent) {
const existingPurpose = extractPurposeSection(targetContent);
if (existingPurpose && existingPurpose !== deltaPurpose) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - delta Purpose ignored; ${specName} already has one. ` +
`Edit ${update.target} directly to change it.`
)
);
}
}
} catch {
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
Expand All @@ -221,7 +242,30 @@ export async function buildUpdatedSpec(
);
}
isNewSpec = true;
targetContent = buildSpecSkeleton(specName, changeName);
targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
const overview = deltaPurpose ? readableOverview(targetContent, specName) : null;
if (deltaPurpose && !overview) {
// Keep the placeholder rather than turning this into a failure: these
// deltas archived cleanly before the Purpose carry-over existed.
targetContent = buildSpecSkeleton(specName, changeName);
if (!options.silent) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`
)
);
}
} else if (overview && overview.length < MIN_PURPOSE_LENGTH && !options.silent) {
// The placeholder always cleared this threshold, so a carried Purpose is
// the first way archive can leave a spec that `validate --strict` fails.
// Measured on the parsed overview, which is what the validator reads.
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` +
`openspec validate --strict reports it as too brief.`
)
);
}
}

const structureIssues = findMainSpecStructureIssues(targetContent);
Expand Down Expand Up @@ -399,12 +443,102 @@ export async function writeUpdatedSpec(
if (counts.renamed) console.log(` → ${counts.renamed} renamed`);
}

/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
function maskHtmlComments(content: string): string {
const blank = (text: string) => text.replace(/[^\n]/g, ' ');
// `--!>` is a comment terminator as well as `-->`.
const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
// A comment that is never closed runs to end of file, so everything after it
// is commented out too. Without this an unterminated `<!--` above a
// `## Purpose` left the commented-out header looking real (#1413).
const unterminated = masked.indexOf('<!--');
if (unterminated === -1) return masked;
return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
}

/**
* Read the body of a `## Purpose` section, ignoring markdown that only appears
* inside fenced code blocks or HTML comments. Returns undefined when the
* section is absent or its body is empty.
*/
function extractPurposeSection(content: string): string | undefined {
const normalized = content.replace(/\r\n?/g, '\n');
const lines = normalized.split('\n');
// Structure is read from the masked copy so a commented-out or fenced
// `## Purpose` is not mistaken for the real one; the body is returned from
// the original lines so an author's own comments and fences survive intact.
const masked = maskHtmlComments(normalized).split('\n');
const fenceMask = buildCodeFenceMask(masked);
const isStructural = (i: number) => !fenceMask[i];

const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
if (start === -1) return undefined;

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

// Emptiness is judged with fenced blocks and HTML comments blanked out, so a
// Purpose that is only a code sample or only an unfilled template comment
// counts as absent and falls back to the TBD placeholder.
const hasProse = masked
.slice(start + 1, end)
.filter((_, offset) => isStructural(start + 1 + offset))
.join('\n')
.trim();
if (!hasProse) return undefined;

const body = lines.slice(start + 1, end).join('\n').trim();
return body || undefined;
}

/**
* The Purpose a new main spec would end up with, or null when carrying the
* delta's body over would leave a spec the readers downstream cannot handle.
*
* Returns the parsed overview rather than a boolean so callers measure the same
* string `validate` measures, not the raw slice out of the delta.
*/
function readableOverview(skeleton: string, specName: string): string | null {
// HTML comments are invisible to the spec parsers but not to the file itself:
// markdown hidden in one is skipped by the boundary scan yet still lands in
// the spec, where it can hide the headers those parsers depend on and blank
// the document out in any markdown renderer. Refuse rather than write a spec
// that reads differently depending on who is reading it (#1413).
//
// Only the opener is disqualifying, and only because `maskHtmlComments`
// covers unterminated comments too: a comment starting above the section
// header therefore always masks the header, leaving no body to carry, so a
// body can only hide content behind a `<!--` of its own. A bare `-->` hides
// nothing and renders as text - rejecting it would throw away a Purpose over
// prose like "ingest --> transform".
if (skeleton.includes('<!--')) return null;
if (findMainSpecStructureIssues(skeleton).length > 0) return null;
try {
// A heading or unterminated fence in the body truncates or swallows the
// sections around it, so archive would abort or write a spec its own
// validator rejects.
return new MarkdownParser(skeleton).parseSpec(specName).overview.trim() || null;
} catch {
return null;
}
}

/**
* Build a skeleton spec for new capabilities.
* 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): string {
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
Loading
Loading