diff --git a/openspec/changes/add-specs-apply-command/.openspec.yaml b/openspec/changes/add-specs-apply-command/.openspec.yaml new file mode 100644 index 00000000..5d67ddcd --- /dev/null +++ b/openspec/changes/add-specs-apply-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-06 diff --git a/openspec/changes/add-specs-apply-command/design.md b/openspec/changes/add-specs-apply-command/design.md new file mode 100644 index 00000000..a3b25593 --- /dev/null +++ b/openspec/changes/add-specs-apply-command/design.md @@ -0,0 +1,77 @@ +## Context + +Currently, delta specs are only applied to main specs when running `openspec archive`. This bundles two concerns: +1. Applying spec changes (delta → main) +2. Archiving the change (move to archive folder) + +Users want flexibility to sync specs earlier, especially when iterating. The archive command already contains the reconciliation logic in `buildUpdatedSpec()`. + +## Goals / Non-Goals + +**Goals:** +- Decouple spec syncing from archiving +- Provide `/opsx:sync` skill for agents to sync specs on demand +- Keep operation idempotent (safe to run multiple times) + +**Non-Goals:** +- Tracking whether specs have been synced (no state) +- Changing archive behavior (it will continue to apply specs) +- Supporting partial application (all deltas sync together) + +## Decisions + +### 1. Reuse existing reconciliation logic + +**Decision**: Extract `buildUpdatedSpec()` logic from `ArchiveCommand` into a shared module. + +**Rationale**: The archive command already implements delta parsing and application. Rather than duplicate, we extract and reuse. + +**Alternatives considered**: +- Duplicate logic in new command (rejected: maintenance burden) +- Have sync call archive with flags (rejected: coupling) + +### 2. No state tracking + +**Decision**: Don't track whether specs have been synced. Each invocation reads delta and main specs, reconciles. + +**Rationale**: +- Idempotent operations don't need state +- Avoids sync issues between flag and reality +- Simpler implementation and mental model + +**Alternatives considered**: +- Track `specsSynced: true` in `.openspec.yaml` (rejected: unnecessary complexity) +- Store snapshot of synced deltas (rejected: over-engineering) + +### 3. Agent-driven approach (no CLI command) + +**Decision**: The `/opsx:sync` skill is fully agent-driven - the agent reads delta specs and directly edits main specs. + +**Rationale**: +- Allows intelligent merging (add scenarios without copying entire requirements) +- Delta represents *intent*, not wholesale replacement +- More flexible and natural editing workflow +- Archive still uses programmatic merge (for finalized changes) + +### 4. Archive behavior unchanged + +**Decision**: Archive continues to apply specs as part of its flow. If specs are already reconciled, the operation is a no-op. + +**Rationale**: Backward compatibility. Users who don't use `/opsx:sync` get the same experience. + +## Risks / Trade-offs + +**[Risk] Multiple changes modify same spec** +→ Last to sync wins. Same as today with archive. Users should coordinate or use sequential archives. + +**[Risk] User syncs specs then continues editing deltas** +→ Running `/opsx:sync` again reconciles. Idempotent design handles this. + +**[Trade-off] No undo mechanism** +→ Users can `git checkout` main specs if needed. Explicit undo command is out of scope. + +## Implementation Approach + +1. Extract spec application logic from `ArchiveCommand.buildUpdatedSpec()` into `src/core/specs-apply.ts` +2. Add skill template for `/opsx:sync` in `skill-templates.ts` +3. Register skill in managed skills diff --git a/openspec/changes/add-specs-apply-command/proposal.md b/openspec/changes/add-specs-apply-command/proposal.md new file mode 100644 index 00000000..22fccb65 --- /dev/null +++ b/openspec/changes/add-specs-apply-command/proposal.md @@ -0,0 +1,32 @@ +## Why + +Spec application is currently bundled with archive - users must run `openspec archive` to apply delta specs to main specs. This couples two distinct concerns (applying specs vs. archiving the change) and forces users to wait until they're "done" to see main specs updated. Users want the flexibility to sync specs earlier in the workflow while iterating. + +## What Changes + +- Add `/opsx:sync` skill that syncs delta specs to main specs as a standalone action +- The operation is idempotent - safe to run multiple times, agent reconciles main specs to match deltas +- Archive continues to work as today (applies specs if not already reconciled, then moves to archive) +- No new state tracking - the agent reads delta and main specs, reconciles on each run +- Agent-driven approach allows intelligent merging (partial updates, adding scenarios) + +**Workflow becomes:** +``` +/opsx:new → /opsx:continue → /opsx:apply → archive + │ + └── /opsx:sync (optional, anytime) +``` + +## Capabilities + +### New Capabilities +- `specs-sync-skill`: Skill template for `/opsx:sync` command that reconciles main specs with delta specs + +### Modified Capabilities +- None (agent-driven, no CLI command needed) + +## Impact + +- **Skills**: New `openspec-sync-specs` skill in `skill-templates.ts` +- **Archive**: No changes needed - already does reconciliation, will continue to work +- **Agent workflow**: Users gain flexibility to sync specs before archive diff --git a/openspec/changes/add-specs-apply-command/specs/specs-sync-skill/spec.md b/openspec/changes/add-specs-apply-command/specs/specs-sync-skill/spec.md new file mode 100644 index 00000000..084b3f1c --- /dev/null +++ b/openspec/changes/add-specs-apply-command/specs/specs-sync-skill/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Specs Sync Skill +The system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs. + +#### Scenario: Sync delta specs to main specs +- **WHEN** agent executes `/opsx:sync` with a change name +- **THEN** the agent reads delta specs from `openspec/changes//specs/` +- **AND** reads corresponding main specs from `openspec/specs/` +- **AND** reconciles main specs to match what the deltas describe + +#### Scenario: Idempotent operation +- **WHEN** agent executes `/opsx:sync` multiple times on the same change +- **THEN** the result is the same as running it once +- **AND** no duplicate requirements are created + +#### Scenario: Change selection prompt +- **WHEN** agent executes `/opsx:sync` without specifying a change +- **THEN** the agent prompts user to select from available changes +- **AND** shows changes that have delta specs + +### Requirement: Delta Reconciliation Logic +The agent SHALL reconcile main specs with delta specs using the delta operation headers. + +#### Scenario: ADDED requirements +- **WHEN** delta contains `## ADDED Requirements` with a requirement +- **AND** the requirement does not exist in main spec +- **THEN** add the requirement to main spec + +#### Scenario: ADDED requirement already exists +- **WHEN** delta contains `## ADDED Requirements` with a requirement +- **AND** a requirement with the same name already exists in main spec +- **THEN** update the existing requirement to match the delta version + +#### Scenario: MODIFIED requirements +- **WHEN** delta contains `## MODIFIED Requirements` with a requirement +- **AND** the requirement exists in main spec +- **THEN** replace the requirement in main spec with the delta version + +#### Scenario: REMOVED requirements +- **WHEN** delta contains `## REMOVED Requirements` with a requirement name +- **AND** the requirement exists in main spec +- **THEN** remove the requirement from main spec + +#### Scenario: RENAMED requirements +- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format +- **AND** the FROM requirement exists in main spec +- **THEN** rename the requirement to the TO name + +#### Scenario: New capability spec +- **WHEN** delta spec exists for a capability not in main specs +- **THEN** create new main spec file at `openspec/specs//spec.md` + +### Requirement: Skill Output +The skill SHALL provide clear feedback on what was synced. + +#### Scenario: Show synced changes +- **WHEN** reconciliation completes successfully +- **THEN** display summary of changes per capability: + - Number of requirements added + - Number of requirements modified + - Number of requirements removed + - Number of requirements renamed + +#### Scenario: No changes needed +- **WHEN** main specs already match delta specs +- **THEN** display "Specs already in sync - no changes needed" diff --git a/openspec/changes/add-specs-apply-command/tasks.md b/openspec/changes/add-specs-apply-command/tasks.md new file mode 100644 index 00000000..7af3bb04 --- /dev/null +++ b/openspec/changes/add-specs-apply-command/tasks.md @@ -0,0 +1,40 @@ +## Tasks + +### Core Implementation + +- [x] Extract spec application logic from `ArchiveCommand` into `src/core/specs-apply.ts` + - Move `buildUpdatedSpec()`, `findSpecUpdates()`, `writeUpdatedSpec()` to shared module + - Keep `ArchiveCommand` importing from the new module + - Ensure all validation logic is preserved + +### Skill Template + +- [x] Add `getSyncSpecsSkillTemplate()` function in `src/core/templates/skill-templates.ts` + - Skill name: `openspec-sync-specs` + - Description: Sync delta specs to main specs + - **Agent-driven**: Instructions for agent to read deltas and edit main specs directly + +- [x] Add `/opsx:sync` slash command template in `skill-templates.ts` + - Mirror the skill template for slash command format + - **Agent-driven**: No CLI command, agent does the merge + +### Registration + +- [x] Register skill in managed skills (via `artifact-experimental-setup`) + - Add to skill list with appropriate metadata + - Ensure it appears in setup output + +### Design Decision + +**Why agent-driven instead of CLI-driven?** + +The programmatic merge operates at requirement-level granularity: +- MODIFIED requires copying ALL scenarios, not just the changed ones +- If agent forgets a scenario, it gets deleted +- Delta specs become bloated with copied content + +Agent-driven approach: +- Agent can apply partial updates (add a scenario without copying others) +- Delta represents *intent*, not wholesale replacement +- More flexible and natural editing workflow +- Archive still uses programmatic merge (for finalized changes) diff --git a/openspec/specs/specs-sync-skill/spec.md b/openspec/specs/specs-sync-skill/spec.md new file mode 100644 index 00000000..780c6e15 --- /dev/null +++ b/openspec/specs/specs-sync-skill/spec.md @@ -0,0 +1,72 @@ +# specs-sync-skill Specification + +## Purpose +Defines the agent skill for syncing delta specs from changes to main specs. + +## Requirements + +### Requirement: Specs Sync Skill +The system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs. + +#### Scenario: Sync delta specs to main specs +- **WHEN** agent executes `/opsx:sync` with a change name +- **THEN** the agent reads delta specs from `openspec/changes//specs/` +- **AND** reads corresponding main specs from `openspec/specs/` +- **AND** reconciles main specs to match what the deltas describe + +#### Scenario: Idempotent operation +- **WHEN** agent executes `/opsx:sync` multiple times on the same change +- **THEN** the result is the same as running it once +- **AND** no duplicate requirements are created + +#### Scenario: Change selection prompt +- **WHEN** agent executes `/opsx:sync` without specifying a change +- **THEN** the agent prompts user to select from available changes +- **AND** shows changes that have delta specs + +### Requirement: Delta Reconciliation Logic +The agent SHALL reconcile main specs with delta specs using the delta operation headers. + +#### Scenario: ADDED requirements +- **WHEN** delta contains `## ADDED Requirements` with a requirement +- **AND** the requirement does not exist in main spec +- **THEN** add the requirement to main spec + +#### Scenario: ADDED requirement already exists +- **WHEN** delta contains `## ADDED Requirements` with a requirement +- **AND** a requirement with the same name already exists in main spec +- **THEN** update the existing requirement to match the delta version + +#### Scenario: MODIFIED requirements +- **WHEN** delta contains `## MODIFIED Requirements` with a requirement +- **AND** the requirement exists in main spec +- **THEN** replace the requirement in main spec with the delta version + +#### Scenario: REMOVED requirements +- **WHEN** delta contains `## REMOVED Requirements` with a requirement name +- **AND** the requirement exists in main spec +- **THEN** remove the requirement from main spec + +#### Scenario: RENAMED requirements +- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format +- **AND** the FROM requirement exists in main spec +- **THEN** rename the requirement to the TO name + +#### Scenario: New capability spec +- **WHEN** delta spec exists for a capability not in main specs +- **THEN** create new main spec file at `openspec/specs//spec.md` + +### Requirement: Skill Output +The skill SHALL provide clear feedback on what was applied. + +#### Scenario: Show applied changes +- **WHEN** reconciliation completes successfully +- **THEN** display summary of changes per capability: + - Number of requirements added + - Number of requirements modified + - Number of requirements removed + - Number of requirements renamed + +#### Scenario: No changes needed +- **WHEN** main specs already match delta specs +- **THEN** display "Specs already in sync - no changes needed" diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index 550752f4..f9f046fb 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -28,7 +28,7 @@ import { type SchemaInfo, } from '../core/artifact-graph/index.js'; import { createChange, validateChangeName } from '../utils/change-utils.js'; -import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate } from '../core/templates/skill-templates.js'; +import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate } from '../core/templates/skill-templates.js'; import { FileSystemUtils } from '../utils/file-system.js'; // ----------------------------------------------------------------------------- @@ -797,12 +797,14 @@ async function artifactExperimentalSetupCommand(): Promise { const continueChangeSkill = getContinueChangeSkillTemplate(); const applyChangeSkill = getApplyChangeSkillTemplate(); const ffChangeSkill = getFfChangeSkillTemplate(); + const syncSpecsSkill = getSyncSpecsSkillTemplate(); // Get command templates const newCommand = getOpsxNewCommandTemplate(); const continueCommand = getOpsxContinueCommandTemplate(); const applyCommand = getOpsxApplyCommandTemplate(); const ffCommand = getOpsxFfCommandTemplate(); + const syncCommand = getOpsxSyncCommandTemplate(); // Create skill directories and SKILL.md files const skills = [ @@ -810,6 +812,7 @@ async function artifactExperimentalSetupCommand(): Promise { { template: continueChangeSkill, dirName: 'openspec-continue-change' }, { template: applyChangeSkill, dirName: 'openspec-apply-change' }, { template: ffChangeSkill, dirName: 'openspec-ff-change' }, + { template: syncSpecsSkill, dirName: 'openspec-sync-specs' }, ]; const createdSkillFiles: string[] = []; @@ -838,6 +841,7 @@ ${template.instructions} { template: continueCommand, fileName: 'continue.md' }, { template: applyCommand, fileName: 'apply.md' }, { template: ffCommand, fileName: 'ff.md' }, + { template: syncCommand, fileName: 'sync.md' }, ]; const createdCommandFiles: string[] = []; @@ -894,6 +898,7 @@ ${template.content} console.log(' • /opsx:continue - Create the next artifact'); console.log(' • /opsx:apply - Implement tasks'); console.log(' • /opsx:ff - Fast-forward: create all artifacts at once'); + console.log(' • /opsx:sync - Sync delta specs to main specs'); console.log(); console.log(chalk.yellow('💡 This is an experimental feature.')); console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues'); diff --git a/src/core/archive.ts b/src/core/archive.ts index 7263bb1c..1121ec25 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -4,17 +4,11 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre import { Validator } from './validation/validator.js'; import chalk from 'chalk'; import { - extractRequirementsSection, - parseDeltaSpec, - normalizeRequirementName, - type RequirementBlock, -} from './parsers/requirement-blocks.js'; - -interface SpecUpdate { - source: string; - target: string; - exists: boolean; -} + findSpecUpdates, + buildUpdatedSpec, + writeUpdatedSpec, + type SpecUpdate, +} from './specs-apply.js'; export class ArchiveCommand { async execute( @@ -167,7 +161,7 @@ export class ArchiveCommand { console.log('Skipping spec updates (--skip-specs flag provided).'); } else { // Find specs to update - const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir); + const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); if (specUpdates.length > 0) { console.log('\nSpecs to update:'); @@ -194,7 +188,7 @@ export class ArchiveCommand { const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = []; try { for (const update of specUpdates) { - const built = await this.buildUpdatedSpec(update, changeName!); + const built = await buildUpdatedSpec(update, changeName!); prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); } } catch (err: any) { @@ -219,7 +213,7 @@ export class ArchiveCommand { return; } } - await this.writeUpdatedSpec(p.update, p.rebuilt, p.counts); + await writeUpdatedSpec(p.update, p.rebuilt, p.counts); totals.added += p.counts.added; totals.modified += p.counts.modified; totals.removed += p.counts.removed; @@ -301,323 +295,6 @@ export class ArchiveCommand { } } - // Deprecated: replaced by shared task-progress utilities - private async checkIncompleteTasks(_tasksPath: string): Promise { - return 0; - } - - private async findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise { - const updates: SpecUpdate[] = []; - const changeSpecsDir = path.join(changeDir, 'specs'); - - try { - const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); - const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); - - try { - await fs.access(specFile); - - // Check if target exists - let exists = false; - try { - await fs.access(targetFile); - exists = true; - } catch { - exists = false; - } - - updates.push({ - source: specFile, - target: targetFile, - exists - }); - } catch { - // Source spec doesn't exist, skip - } - } - } - } catch { - // No specs directory in change - } - - return updates; - } - - private async buildUpdatedSpec(update: SpecUpdate, changeName: string): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> { - // Read change spec content (delta-format expected) - const changeContent = await fs.readFile(update.source, 'utf-8'); - - // Parse deltas from the change spec file - const plan = parseDeltaSpec(changeContent); - const specName = path.basename(path.dirname(update.target)); - - // Pre-validate duplicates within sections - const addedNames = new Set(); - for (const add of plan.added) { - const name = normalizeRequirementName(add.name); - if (addedNames.has(name)) { - throw new Error( - `${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"` - ); - } - addedNames.add(name); - } - const modifiedNames = new Set(); - for (const mod of plan.modified) { - const name = normalizeRequirementName(mod.name); - if (modifiedNames.has(name)) { - throw new Error( - `${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"` - ); - } - modifiedNames.add(name); - } - const removedNamesSet = new Set(); - for (const rem of plan.removed) { - const name = normalizeRequirementName(rem); - if (removedNamesSet.has(name)) { - throw new Error( - `${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"` - ); - } - removedNamesSet.add(name); - } - const renamedFromSet = new Set(); - const renamedToSet = new Set(); - for (const { from, to } of plan.renamed) { - const fromNorm = normalizeRequirementName(from); - const toNorm = normalizeRequirementName(to); - if (renamedFromSet.has(fromNorm)) { - throw new Error( - `${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"` - ); - } - if (renamedToSet.has(toNorm)) { - throw new Error( - `${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"` - ); - } - renamedFromSet.add(fromNorm); - renamedToSet.add(toNorm); - } - - // Pre-validate cross-section conflicts - const conflicts: Array<{ name: string; a: string; b: string }> = []; - for (const n of modifiedNames) { - if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' }); - if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' }); - } - for (const n of addedNames) { - if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' }); - } - // Renamed interplay: MODIFIED must reference the NEW header, not FROM - for (const { from, to } of plan.renamed) { - const fromNorm = normalizeRequirementName(from); - const toNorm = normalizeRequirementName(to); - if (modifiedNames.has(fromNorm)) { - throw new Error( - `${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"` - ); - } - // Detect ADDED colliding with a RENAMED TO - if (addedNames.has(toNorm)) { - throw new Error( - `${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"` - ); - } - } - if (conflicts.length > 0) { - const c = conflicts[0]; - throw new Error( - `${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"` - ); - } - const hasAnyDelta = (plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length) > 0; - if (!hasAnyDelta) { - throw new Error( - `Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` + - `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.` - ); - } - - // Load or create base target content - let targetContent: string; - let isNewSpec = false; - try { - targetContent = await fs.readFile(update.target, 'utf-8'); - } 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 - if (plan.modified.length > 0 || plan.renamed.length > 0) { - throw new Error( - `${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.` - ); - } - // Warn about REMOVED requirements being ignored for new specs - if (plan.removed.length > 0) { - console.log( - chalk.yellow( - `⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).` - ) - ); - } - isNewSpec = true; - targetContent = this.buildSpecSkeleton(specName, changeName); - } - - // Extract requirements section and build name->block map - const parts = extractRequirementsSection(targetContent); - const nameToBlock = new Map(); - for (const block of parts.bodyBlocks) { - nameToBlock.set(normalizeRequirementName(block.name), block); - } - - // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED - // RENAMED - for (const r of plan.renamed) { - const from = normalizeRequirementName(r.from); - const to = normalizeRequirementName(r.to); - if (!nameToBlock.has(from)) { - throw new Error( - `${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found` - ); - } - if (nameToBlock.has(to)) { - throw new Error( - `${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists` - ); - } - const block = nameToBlock.get(from)!; - const newHeader = `### Requirement: ${to}`; - const rawLines = block.raw.split('\n'); - rawLines[0] = newHeader; - const renamedBlock: RequirementBlock = { - headerLine: newHeader, - name: to, - raw: rawLines.join('\n'), - }; - nameToBlock.delete(from); - nameToBlock.set(to, renamedBlock); - } - - // REMOVED - for (const name of plan.removed) { - const key = normalizeRequirementName(name); - if (!nameToBlock.has(key)) { - // For new specs, REMOVED requirements are already warned about and ignored - // For existing specs, missing requirements are an error - if (!isNewSpec) { - throw new Error( - `${specName} REMOVED failed for header "### Requirement: ${name}" - not found` - ); - } - // Skip removal for new specs (already warned above) - continue; - } - nameToBlock.delete(key); - } - - // MODIFIED - for (const mod of plan.modified) { - const key = normalizeRequirementName(mod.name); - if (!nameToBlock.has(key)) { - throw new Error( - `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found` - ); - } - // Replace block with provided raw (ensure header line matches key) - const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/); - if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) { - throw new Error( - `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content` - ); - } - nameToBlock.set(key, mod); - } - - // ADDED - for (const add of plan.added) { - const key = normalizeRequirementName(add.name); - if (nameToBlock.has(key)) { - throw new Error( - `${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists` - ); - } - nameToBlock.set(key, add); - } - - // Duplicates within resulting map are implicitly prevented by key uniqueness. - - // Recompose requirements section preserving original ordering where possible - const keptOrder: RequirementBlock[] = []; - const seen = new Set(); - for (const block of parts.bodyBlocks) { - const key = normalizeRequirementName(block.name); - const replacement = nameToBlock.get(key); - if (replacement) { - keptOrder.push(replacement); - seen.add(key); - } - } - // Append any newly added that were not in original order - for (const [key, block] of nameToBlock.entries()) { - if (!seen.has(key)) { - keptOrder.push(block); - } - } - - const reqBody = [ - parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : '' - ] - .filter(Boolean) - .concat(keptOrder.map(b => b.raw)) - .join('\n\n') - .trimEnd(); - - const rebuilt = [ - parts.before.trimEnd(), - parts.headerLine, - reqBody, - parts.after - ] - .filter((s, idx) => !(idx === 0 && s === '')) - .join('\n') - .replace(/\n{3,}/g, '\n\n'); - - return { - rebuilt, - counts: { - added: plan.added.length, - modified: plan.modified.length, - removed: plan.removed.length, - renamed: plan.renamed.length, - } - }; - } - - private async writeUpdatedSpec(update: SpecUpdate, rebuilt: string, counts: { added: number; modified: number; removed: number; renamed: number }): Promise { - // Create target directory if needed - const targetDir = path.dirname(update.target); - await fs.mkdir(targetDir, { recursive: true }); - await fs.writeFile(update.target, rebuilt); - - const specName = path.basename(path.dirname(update.target)); - console.log(`Applying changes to openspec/specs/${specName}/spec.md:`); - if (counts.added) console.log(` + ${counts.added} added`); - if (counts.modified) console.log(` ~ ${counts.modified} modified`); - if (counts.removed) console.log(` - ${counts.removed} removed`); - if (counts.renamed) console.log(` → ${counts.renamed} renamed`); - } - - private buildSpecSkeleton(specFolderName: string, changeName: 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`; - } - private getArchiveDate(): string { // Returns date in YYYY-MM-DD format return new Date().toISOString().split('T')[0]; diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts new file mode 100644 index 00000000..9ce0f12f --- /dev/null +++ b/src/core/specs-apply.ts @@ -0,0 +1,483 @@ +/** + * Spec Application Logic + * + * Extracted from ArchiveCommand to enable standalone spec application. + * Applies delta specs from a change to main specs without archiving. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { + extractRequirementsSection, + parseDeltaSpec, + normalizeRequirementName, + type RequirementBlock, +} from './parsers/requirement-blocks.js'; +import { Validator } from './validation/validator.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface SpecUpdate { + source: string; + target: string; + exists: boolean; +} + +export interface ApplyResult { + capability: string; + added: number; + modified: number; + removed: number; + renamed: number; +} + +export interface SpecsApplyOutput { + changeName: string; + capabilities: ApplyResult[]; + totals: { + added: number; + modified: number; + removed: number; + renamed: number; + }; + noChanges: boolean; +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Find all delta spec files that need to be applied from a change. + */ +export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise { + const updates: SpecUpdate[] = []; + const changeSpecsDir = path.join(changeDir, 'specs'); + + try { + const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); + const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); + + try { + await fs.access(specFile); + + // Check if target exists + let exists = false; + try { + await fs.access(targetFile); + exists = true; + } catch { + exists = false; + } + + updates.push({ + source: specFile, + target: targetFile, + exists, + }); + } catch { + // Source spec doesn't exist, skip + } + } + } + } catch { + // No specs directory in change + } + + return updates; +} + +/** + * Build an updated spec by applying delta operations. + * Returns the rebuilt content and counts of operations. + */ +export async function buildUpdatedSpec( + update: SpecUpdate, + changeName: string +): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> { + // Read change spec content (delta-format expected) + const changeContent = await fs.readFile(update.source, 'utf-8'); + + // Parse deltas from the change spec file + const plan = parseDeltaSpec(changeContent); + const specName = path.basename(path.dirname(update.target)); + + // Pre-validate duplicates within sections + const addedNames = new Set(); + for (const add of plan.added) { + const name = normalizeRequirementName(add.name); + if (addedNames.has(name)) { + throw new Error( + `${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"` + ); + } + addedNames.add(name); + } + const modifiedNames = new Set(); + for (const mod of plan.modified) { + const name = normalizeRequirementName(mod.name); + if (modifiedNames.has(name)) { + throw new Error( + `${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"` + ); + } + modifiedNames.add(name); + } + const removedNamesSet = new Set(); + for (const rem of plan.removed) { + const name = normalizeRequirementName(rem); + if (removedNamesSet.has(name)) { + throw new Error( + `${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"` + ); + } + removedNamesSet.add(name); + } + const renamedFromSet = new Set(); + const renamedToSet = new Set(); + for (const { from, to } of plan.renamed) { + const fromNorm = normalizeRequirementName(from); + const toNorm = normalizeRequirementName(to); + if (renamedFromSet.has(fromNorm)) { + throw new Error( + `${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"` + ); + } + if (renamedToSet.has(toNorm)) { + throw new Error( + `${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"` + ); + } + renamedFromSet.add(fromNorm); + renamedToSet.add(toNorm); + } + + // Pre-validate cross-section conflicts + const conflicts: Array<{ name: string; a: string; b: string }> = []; + for (const n of modifiedNames) { + if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' }); + if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' }); + } + for (const n of addedNames) { + if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' }); + } + // Renamed interplay: MODIFIED must reference the NEW header, not FROM + for (const { from, to } of plan.renamed) { + const fromNorm = normalizeRequirementName(from); + const toNorm = normalizeRequirementName(to); + if (modifiedNames.has(fromNorm)) { + throw new Error( + `${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"` + ); + } + // Detect ADDED colliding with a RENAMED TO + if (addedNames.has(toNorm)) { + throw new Error( + `${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"` + ); + } + } + if (conflicts.length > 0) { + const c = conflicts[0]; + throw new Error( + `${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"` + ); + } + const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0; + if (!hasAnyDelta) { + throw new Error( + `Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` + + `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.` + ); + } + + // Load or create base target content + let targetContent: string; + let isNewSpec = false; + try { + targetContent = await fs.readFile(update.target, 'utf-8'); + } 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 + if (plan.modified.length > 0 || plan.renamed.length > 0) { + throw new Error( + `${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.` + ); + } + // Warn about REMOVED requirements being ignored for new specs + if (plan.removed.length > 0) { + console.log( + chalk.yellow( + `⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).` + ) + ); + } + isNewSpec = true; + targetContent = buildSpecSkeleton(specName, changeName); + } + + // Extract requirements section and build name->block map + const parts = extractRequirementsSection(targetContent); + const nameToBlock = new Map(); + for (const block of parts.bodyBlocks) { + nameToBlock.set(normalizeRequirementName(block.name), block); + } + + // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED + // RENAMED + for (const r of plan.renamed) { + const from = normalizeRequirementName(r.from); + const to = normalizeRequirementName(r.to); + if (!nameToBlock.has(from)) { + throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`); + } + if (nameToBlock.has(to)) { + throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`); + } + const block = nameToBlock.get(from)!; + const newHeader = `### Requirement: ${to}`; + const rawLines = block.raw.split('\n'); + rawLines[0] = newHeader; + const renamedBlock: RequirementBlock = { + headerLine: newHeader, + name: to, + raw: rawLines.join('\n'), + }; + nameToBlock.delete(from); + nameToBlock.set(to, renamedBlock); + } + + // REMOVED + for (const name of plan.removed) { + const key = normalizeRequirementName(name); + if (!nameToBlock.has(key)) { + // For new specs, REMOVED requirements are already warned about and ignored + // For existing specs, missing requirements are an error + if (!isNewSpec) { + throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`); + } + // Skip removal for new specs (already warned above) + continue; + } + nameToBlock.delete(key); + } + + // MODIFIED + for (const mod of plan.modified) { + const key = normalizeRequirementName(mod.name); + if (!nameToBlock.has(key)) { + throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`); + } + // Replace block with provided raw (ensure header line matches key) + const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/); + if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) { + throw new Error( + `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content` + ); + } + nameToBlock.set(key, mod); + } + + // ADDED + for (const add of plan.added) { + const key = normalizeRequirementName(add.name); + if (nameToBlock.has(key)) { + throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`); + } + nameToBlock.set(key, add); + } + + // Duplicates within resulting map are implicitly prevented by key uniqueness. + + // Recompose requirements section preserving original ordering where possible + const keptOrder: RequirementBlock[] = []; + const seen = new Set(); + for (const block of parts.bodyBlocks) { + const key = normalizeRequirementName(block.name); + const replacement = nameToBlock.get(key); + if (replacement) { + keptOrder.push(replacement); + seen.add(key); + } + } + // Append any newly added that were not in original order + for (const [key, block] of nameToBlock.entries()) { + if (!seen.has(key)) { + keptOrder.push(block); + } + } + + const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : ''] + .filter(Boolean) + .concat(keptOrder.map((b) => b.raw)) + .join('\n\n') + .trimEnd(); + + const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after] + .filter((s, idx) => !(idx === 0 && s === '')) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); + + return { + rebuilt, + counts: { + added: plan.added.length, + modified: plan.modified.length, + removed: plan.removed.length, + renamed: plan.renamed.length, + }, + }; +} + +/** + * Write an updated spec to disk. + */ +export async function writeUpdatedSpec( + update: SpecUpdate, + rebuilt: string, + counts: { added: number; modified: number; removed: number; renamed: number } +): Promise { + // Create target directory if needed + const targetDir = path.dirname(update.target); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(update.target, rebuilt); + + const specName = path.basename(path.dirname(update.target)); + console.log(`Applying changes to openspec/specs/${specName}/spec.md:`); + if (counts.added) console.log(` + ${counts.added} added`); + if (counts.modified) console.log(` ~ ${counts.modified} modified`); + if (counts.removed) console.log(` - ${counts.removed} removed`); + if (counts.renamed) console.log(` → ${counts.renamed} renamed`); +} + +/** + * Build a skeleton spec for new capabilities. + */ +export function buildSpecSkeleton(specFolderName: string, changeName: 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`; +} + +/** + * Apply all delta specs from a change to main specs. + * + * @param projectRoot - The project root directory + * @param changeName - The name of the change to apply + * @param options - Options for the operation + * @returns Result of the operation with counts + */ +export async function applySpecs( + projectRoot: string, + changeName: string, + options: { + dryRun?: boolean; + skipValidation?: boolean; + silent?: boolean; + } = {} +): Promise { + const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); + const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs'); + + // Verify change exists + try { + const stat = await fs.stat(changeDir); + if (!stat.isDirectory()) { + throw new Error(`Change '${changeName}' not found.`); + } + } catch { + throw new Error(`Change '${changeName}' not found.`); + } + + // Find specs to update + const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); + + if (specUpdates.length === 0) { + return { + changeName, + capabilities: [], + totals: { added: 0, modified: 0, removed: 0, renamed: 0 }, + noChanges: true, + }; + } + + // Prepare all updates first (validation pass, no writes) + const prepared: Array<{ + update: SpecUpdate; + rebuilt: string; + counts: { added: number; modified: number; removed: number; renamed: number }; + }> = []; + + for (const update of specUpdates) { + const built = await buildUpdatedSpec(update, changeName); + prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); + } + + // Validate rebuilt specs unless validation is skipped + if (!options.skipValidation) { + const validator = new Validator(); + for (const p of prepared) { + const specName = path.basename(path.dirname(p.update.target)); + const report = await validator.validateSpecContent(specName, p.rebuilt); + if (!report.valid) { + const errors = report.issues + .filter((i) => i.level === 'ERROR') + .map((i) => ` ✗ ${i.message}`) + .join('\n'); + throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`); + } + } + } + + // Build results + const capabilities: ApplyResult[] = []; + const totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; + + for (const p of prepared) { + const capability = path.basename(path.dirname(p.update.target)); + + if (!options.dryRun) { + // Write the updated spec + const targetDir = path.dirname(p.update.target); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(p.update.target, p.rebuilt); + + if (!options.silent) { + console.log(`Applying changes to openspec/specs/${capability}/spec.md:`); + if (p.counts.added) console.log(` + ${p.counts.added} added`); + if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); + if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); + if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); + } + } else if (!options.silent) { + console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`); + if (p.counts.added) console.log(` + ${p.counts.added} added`); + if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); + if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); + if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); + } + + capabilities.push({ + capability, + ...p.counts, + }); + + totals.added += p.counts.added; + totals.modified += p.counts.modified; + totals.removed += p.counts.removed; + totals.renamed += p.counts.renamed; + } + + return { + changeName, + capabilities, + totals, + noChanges: false, + }; +} diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index d5a0a9a8..4050532c 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -455,6 +455,144 @@ After completing all artifacts, summarize: }; } +/** + * Template for openspec-sync-specs skill + * For syncing delta specs from a change to main specs (agent-driven) + */ +export function getSyncSpecsSkillTemplate(): SkillTemplate { + return { + name: 'openspec-sync-specs', + description: 'Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.', + instructions: `Sync delta specs from a change to main specs. + +This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). + +**Input**: Optionally specify a change name. If omitted, MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. + + Show changes that have delta specs (under \`specs/\` directory). + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Find delta specs** + + Look for delta spec files in \`openspec/changes//specs/*/spec.md\`. + + Each delta spec file contains sections like: + - \`## ADDED Requirements\` - New requirements to add + - \`## MODIFIED Requirements\` - Changes to existing requirements + - \`## REMOVED Requirements\` - Requirements to remove + - \`## RENAMED Requirements\` - Requirements to rename (FROM:/TO: format) + + If no delta specs found, inform user and stop. + +3. **For each delta spec, apply changes to main specs** + + For each capability with a delta spec at \`openspec/changes//specs//spec.md\`: + + a. **Read the delta spec** to understand the intended changes + + b. **Read the main spec** at \`openspec/specs//spec.md\` (may not exist yet) + + c. **Apply changes intelligently**: + + **ADDED Requirements:** + - If requirement doesn't exist in main spec → add it + - If requirement already exists → update it to match (treat as implicit MODIFIED) + + **MODIFIED Requirements:** + - Find the requirement in main spec + - Apply the changes - this can be: + - Adding new scenarios (don't need to copy existing ones) + - Modifying existing scenarios + - Changing the requirement description + - Preserve scenarios/content not mentioned in the delta + + **REMOVED Requirements:** + - Remove the entire requirement block from main spec + + **RENAMED Requirements:** + - Find the FROM requirement, rename to TO + + d. **Create new main spec** if capability doesn't exist yet: + - Create \`openspec/specs//spec.md\` + - Add Purpose section (can be brief, mark as TBD) + - Add Requirements section with the ADDED requirements + +4. **Show summary** + + After applying all changes, summarize: + - Which capabilities were updated + - What changes were made (requirements added/modified/removed/renamed) + +**Delta Spec Format Reference** + +\`\`\`markdown +## ADDED Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y + +## MODIFIED Requirements + +### Requirement: Existing Feature +#### Scenario: New scenario to add +- **WHEN** user does A +- **THEN** system does B + +## REMOVED Requirements + +### Requirement: Deprecated Feature + +## RENAMED Requirements + +- FROM: \`### Requirement: Old Name\` +- TO: \`### Requirement: New Name\` +\`\`\` + +**Key Principle: Intelligent Merging** + +Unlike programmatic merging, you can apply **partial updates**: +- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios +- The delta represents *intent*, not a wholesale replacement +- Use your judgment to merge changes sensibly + +**Output On Success** + +\`\`\` +## Specs Synced: + +Updated main specs: + +****: +- Added requirement: "New Feature" +- Modified requirement: "Existing Feature" (added 1 scenario) + +****: +- Created new spec file +- Added requirement: "Another Feature" + +Main specs are now updated. The change remains active - archive when implementation is complete. +\`\`\` + +**Guardrails** +- Read both delta and main specs before making changes +- Preserve existing content not mentioned in delta +- If something is unclear, ask for clarification +- Show what you're changing as you go +- The operation should be idempotent - running twice should give same result` + }; +} + // ----------------------------------------------------------------------------- // Slash Command Templates // ----------------------------------------------------------------------------- @@ -911,3 +1049,142 @@ After completing all artifacts, summarize: - Verify each artifact file exists after writing before proceeding to next` }; } + +/** + * Template for /opsx:sync slash command + */ +export function getOpsxSyncCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Sync', + description: 'Sync delta specs from a change to main specs', + category: 'Workflow', + tags: ['workflow', 'specs', 'experimental'], + content: `Sync delta specs from a change to main specs. + +This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). + +**Input**: Optionally specify \`--change \` after \`/opsx:sync\`. If omitted, MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. + + Show changes that have delta specs (under \`specs/\` directory). + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Find delta specs** + + Look for delta spec files in \`openspec/changes//specs/*/spec.md\`. + + Each delta spec file contains sections like: + - \`## ADDED Requirements\` - New requirements to add + - \`## MODIFIED Requirements\` - Changes to existing requirements + - \`## REMOVED Requirements\` - Requirements to remove + - \`## RENAMED Requirements\` - Requirements to rename (FROM:/TO: format) + + If no delta specs found, inform user and stop. + +3. **For each delta spec, apply changes to main specs** + + For each capability with a delta spec at \`openspec/changes//specs//spec.md\`: + + a. **Read the delta spec** to understand the intended changes + + b. **Read the main spec** at \`openspec/specs//spec.md\` (may not exist yet) + + c. **Apply changes intelligently**: + + **ADDED Requirements:** + - If requirement doesn't exist in main spec → add it + - If requirement already exists → update it to match (treat as implicit MODIFIED) + + **MODIFIED Requirements:** + - Find the requirement in main spec + - Apply the changes - this can be: + - Adding new scenarios (don't need to copy existing ones) + - Modifying existing scenarios + - Changing the requirement description + - Preserve scenarios/content not mentioned in the delta + + **REMOVED Requirements:** + - Remove the entire requirement block from main spec + + **RENAMED Requirements:** + - Find the FROM requirement, rename to TO + + d. **Create new main spec** if capability doesn't exist yet: + - Create \`openspec/specs//spec.md\` + - Add Purpose section (can be brief, mark as TBD) + - Add Requirements section with the ADDED requirements + +4. **Show summary** + + After applying all changes, summarize: + - Which capabilities were updated + - What changes were made (requirements added/modified/removed/renamed) + +**Delta Spec Format Reference** + +\`\`\`markdown +## ADDED Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y + +## MODIFIED Requirements + +### Requirement: Existing Feature +#### Scenario: New scenario to add +- **WHEN** user does A +- **THEN** system does B + +## REMOVED Requirements + +### Requirement: Deprecated Feature + +## RENAMED Requirements + +- FROM: \`### Requirement: Old Name\` +- TO: \`### Requirement: New Name\` +\`\`\` + +**Key Principle: Intelligent Merging** + +Unlike programmatic merging, you can apply **partial updates**: +- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios +- The delta represents *intent*, not a wholesale replacement +- Use your judgment to merge changes sensibly + +**Output On Success** + +\`\`\` +## Specs Synced: + +Updated main specs: + +****: +- Added requirement: "New Feature" +- Modified requirement: "Existing Feature" (added 1 scenario) + +****: +- Created new spec file +- Added requirement: "Another Feature" + +Main specs are now updated. The change remains active - archive when implementation is complete. +\`\`\` + +**Guardrails** +- Read both delta and main specs before making changes +- Preserve existing content not mentioned in delta +- If something is unclear, ask for clarification +- Show what you're changing as you go +- The operation should be idempotent - running twice should give same result` + }; +}