From 47d20142bcfabe0b469c73d1e9e8cec9b4921dd9 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Wed, 12 Aug 2026 12:02:26 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat(lifecycle):=20experimental=20lifecyc?= =?UTF-8?q?le:=20status=20mode=20=E2=80=94=20state=20as=20data,=20sync=20-?= =?UTF-8?q?-check=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in via `lifecycle: status` in openspec/config.yaml (default 'archive' unchanged). Under the mode: a change's lifecycle state lives in its .openspec.yaml `status` field (proposed | applied | shipped) and nothing ever moves to changes/archive/. New `openspec sync` folds shipped changes' deltas into specs/ as a standalone idempotent step through the existing specs-apply engine; `sync --check` verifies by regeneration (rebuilt output byte-identical to the current spec) and exits 1 on a shipped-but-unfolded change, making the gate a pure function of the tree — runnable at pre-commit, pre-push and CI. archive refuses under the mode and points at the status workflow; list gains a status column and --status filter; new changes are born `status: proposed`. Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 21 ++- src/core/archive.ts | 19 ++ src/core/change-metadata/schema.ts | 7 + src/core/completions/command-registry.ts | 23 +++ src/core/list.ts | 46 ++++- src/core/project-config.ts | 35 ++++ src/core/sync.ts | 224 +++++++++++++++++++++++ src/utils/change-utils.ts | 7 +- test/core/sync.test.ts | 155 ++++++++++++++++ 9 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 src/core/sync.ts create mode 100644 test/core/sync.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index c51e490a8d..2098c54c82 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,6 +19,7 @@ import { } from '../core/version-check.js'; import { ListCommand } from '../core/list.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; +import { SyncCommand } from '../core/sync.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; @@ -313,10 +314,11 @@ program .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') + .option('--status ', 'Filter changes by lifecycle status (proposed, applied, shipped)') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; status?: string; json?: boolean; store?: string; storePath?: string }) => { try { const root = await resolveRootForCommand(options ?? {}, { json: options?.json, @@ -334,6 +336,7 @@ program await listCommand.execute(root.path, mode, { sort, json: options?.json, + ...(options?.status ? { status: options.status } : {}), ...(options?.json ? { root: toRootOutput(root) } : {}), }); } catch (error) { @@ -449,6 +452,22 @@ program } }); +program + .command('sync [change-name]') + .description( + "Fold shipped changes' spec deltas into main specs (projects with `lifecycle: status`)" + ) + .option('--check', 'Verify only: exit 1 if a shipped change has unfolded deltas') + .option('--json', 'Output as JSON (non-interactive)') + .action(async (changeName?: string, options?: { check?: boolean; json?: boolean }) => { + try { + await new SyncCommand().execute(changeName, '.', options ?? {}); + } catch (error) { + failWithError(error); + process.exit(1); + } + }); + registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); diff --git a/src/core/archive.ts b/src/core/archive.ts index d476c036e5..e9dc2ce1ba 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -28,6 +28,7 @@ import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js'; import { FileSystemUtils } from '../utils/file-system.js'; import { folderStyleNameProblem } from './id.js'; +import { resolveLifecycle } from './project-config.js'; function isMissingPathError(error: unknown): boolean { return ( @@ -1039,6 +1040,24 @@ export class ArchiveCommand { throw error; } + // Under `lifecycle: status` nothing ever moves: shipping is a metadata + // edit and the spec fold belongs to `openspec sync`. Refusing here keeps + // one mode from half-running the other's workflow. + if (resolveLifecycle(root.path) === 'status') { + const diagnostic: ArchiveDiagnostic = { + severity: 'error', + code: 'lifecycle_status_mode', + message: + 'This project uses `lifecycle: status` — changes are never moved to archive/.', + fix: 'Set `status: shipped` in the change\'s .openspec.yaml, then run `openspec sync`.', + }; + if (json) { + this.printJsonFailure(root, diagnostic); + return; + } + throw new Error(`${diagnostic.message} ${diagnostic.fix}`); + } + if (json) { try { const result = await this.run(changeName, options, root, true); diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index 3644160052..e6a109544a 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -46,6 +46,13 @@ export const ChangeMetadataSchema = z.object({ // tree - only from git - so it is the author's call, not an inference from the // shape of a delta. retire_capabilities: z.boolean().optional(), + // Lifecycle state under `lifecycle: status` mode: the change's position in + // its life is data, not directory location, and nothing ever moves. Closed + // set because tooling attaches consequences to each category: `sync` folds + // only shipped changes' deltas into specs/, and overlap/drift tooling treats + // proposed/applied as holding a live claim on the requirements they touch. + // Absent on projects using the default `lifecycle: archive` mode. + status: z.enum(['proposed', 'applied', 'shipped']).optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 8e6231499d..35cecf3960 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -68,6 +68,12 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, values: ['recent', 'name'], }, + { + name: 'status', + description: 'Filter changes by lifecycle status (proposed, applied, shipped)', + takesValue: true, + values: ['proposed', 'applied', 'shipped'], + }, COMMON_FLAGS.json, COMMON_FLAGS.store, ], @@ -176,6 +182,23 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ COMMON_FLAGS.store, ], }, + { + name: 'sync', + description: "Fold shipped changes' spec deltas into main specs (lifecycle: status projects)", + acceptsPositional: true, + positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], + flags: [ + { + name: 'check', + description: 'Verify only: exit 1 if a shipped change has unfolded deltas', + }, + { + name: 'json', + description: 'Output as JSON (non-interactive)', + }, + ], + }, { name: 'status', description: 'Display artifact completion status for a change', diff --git a/src/core/list.ts b/src/core/list.ts index f6b6faf2f8..ca6224dca5 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { readFileSync, type Dirent } from 'fs'; +import { parse as parseYaml } from 'yaml'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; import { discoverSpecFiles } from '../utils/spec-discovery.js'; @@ -11,12 +12,31 @@ interface ChangeInfo { completedTasks: number; totalTasks: number; lastModified: Date; + lifecycle?: string; } interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; root?: RootOutput; + /** Filter changes by lifecycle status (projects with `lifecycle: status`). */ + status?: string; +} + +const LIFECYCLE_STATES = new Set(['proposed', 'applied', 'shipped']); + +// Non-throwing: list must render even when a change's metadata would fail the +// stricter contract readChangeMetadata enforces — a broken change is status's +// problem to report, not a reason to hide the whole list. +function readLifecycleStatus(changePath: string): string | undefined { + try { + const raw = readFileSync(path.join(changePath, '.openspec.yaml'), 'utf-8'); + const parsed = parseYaml(raw) as Record | null; + const status = parsed?.['status']; + return typeof status === 'string' && LIFECYCLE_STATES.has(status) ? status : undefined; + } catch { + return undefined; + } } function isMissingPathError(error: unknown): boolean { @@ -123,14 +143,30 @@ export class ListCommand { const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); const changePath = path.join(changesDir, changeDir); const lastModified = await getLastModified(changePath); + const lifecycle = readLifecycleStatus(changePath); + if (options.status && lifecycle !== options.status) { + continue; + } changes.push({ name: changeDir, completedTasks: progress.completed, totalTasks: progress.total, - lastModified + lastModified, + ...(lifecycle ? { lifecycle } : {}) }); } + if (changes.length === 0) { + if (json) { + console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2)); + } else { + console.log( + options.status ? `No changes with status '${options.status}'.` : 'No active changes found.' + ); + } + return; + } + // Sort by preference (default: recent first) if (sort === 'recent') { changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); @@ -145,7 +181,10 @@ export class ListCommand { completedTasks: c.completedTasks, totalTasks: c.totalTasks, lastModified: c.lastModified.toISOString(), - status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' + status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress', + // `lifecycle`, not `status`: the task-progress field above already + // owns that name in this payload. + ...(c.lifecycle ? { lifecycle: c.lifecycle } : {}) })); console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2)); return; @@ -159,7 +198,8 @@ export class ListCommand { const paddedName = change.name.padEnd(nameWidth); const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); const timeAgo = formatRelativeTime(change.lastModified); - console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`); + const lifecycle = change.lifecycle ? ` [${change.lifecycle}]` : ''; + console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}${lifecycle}`); } return; } diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 922e31505b..1adb7237ef 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -29,6 +29,9 @@ const OperationConfigSchema = z.object({ * - Single source of truth for type and validation * - Consistent with other OpenSpec schemas */ +export const LIFECYCLE_MODES = ['archive', 'status'] as const; +export type LifecycleMode = (typeof LIFECYCLE_MODES)[number]; + export const ProjectConfigSchema = z.object({ // Required: which schema to use (e.g., "spec-driven", or project-local schema name) schema: z @@ -36,6 +39,16 @@ export const ProjectConfigSchema = z.object({ .min(1) .describe('The workflow schema to use (e.g., "spec-driven")'), + // Optional, experimental: how a change's lifecycle state is recorded. + // 'archive' (default) is the existing behavior: finishing a change moves it + // to changes/archive/. 'status' records state in the change's .openspec.yaml + // `status` field instead; nothing ever moves, and `openspec sync` folds + // shipped changes' deltas into specs/ as a standalone, idempotent step. + lifecycle: z + .enum(LIFECYCLE_MODES) + .optional() + .describe('Experimental: "status" records change state as data instead of moving folders'), + // Optional: project context (injected into all artifact instructions) // Max size: 50KB (enforced during parsing) context: z @@ -264,6 +277,16 @@ export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the r * @param projectRoot - The root directory of the project (where `openspec/` lives) * @returns Parsed config or null if file doesn't exist */ +/** + * The project's lifecycle mode. 'archive' unless openspec/config.yaml + * explicitly opts into 'status'; a missing or unreadable config means the + * default, never an error — mode resolution must not add a failure surface + * to commands that only need to know which workflow applies. + */ +export function resolveLifecycle(projectRoot: string): LifecycleMode { + return readProjectConfig(projectRoot)?.lifecycle ?? 'archive'; +} + export function readProjectConfig(projectRoot: string): ProjectConfig | null { const configPath = resolveConfigFilePath(projectRoot); if (configPath === null) { @@ -290,6 +313,18 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { console.warn(`Invalid 'schema' field in config (must be non-empty string)`); } + // Parse lifecycle field. Invalid values warn and fall back to the default + // ('archive'), like other resilient fields — a typo here must not silently + // change which workflow the project runs. + const lifecycleResult = z.enum(LIFECYCLE_MODES).safeParse(raw.lifecycle); + if (lifecycleResult.success) { + config.lifecycle = lifecycleResult.data; + } else if (raw.lifecycle !== undefined) { + console.warn( + `Invalid 'lifecycle' field in config (must be one of: ${LIFECYCLE_MODES.join(', ')})` + ); + } + // Parse context field with size limit if (raw.context !== undefined) { const contextField = z.string(); diff --git a/src/core/sync.ts b/src/core/sync.ts new file mode 100644 index 0000000000..0fe3c8d480 --- /dev/null +++ b/src/core/sync.ts @@ -0,0 +1,224 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import type { Dirent } from 'fs'; +import { + findSpecUpdates, + buildUpdatedSpec, + writeUpdatedSpec, + type SpecUpdate, +} from './specs-apply.js'; +import { readChangeMetadata, ChangeMetadataError } from '../utils/change-metadata.js'; +import { resolveLifecycle } from './project-config.js'; + +export interface SyncOptions { + check?: boolean; + json?: boolean; +} + +type PendingFold = { + update: SpecUpdate; + rebuilt: string; + counts: { added: number; modified: number; removed: number; renamed: number }; +}; + +export interface ChangeSyncState { + change: string; + state: 'folded' | 'unfolded' | 'conflict'; + /** Capability ids whose main spec does not yet reflect this change's delta. */ + pending: string[]; + error?: string; +} + +export interface SyncReport { + mode: 'archive' | 'status'; + changes: ChangeSyncState[]; + clean: boolean; +} + +/** + * Fold shipped changes' spec deltas into the main specs — the text-merge half + * of what archive does, decoupled from any directory move so it can run at any + * time, idempotently. Only changes declaring `status: shipped` fold; proposed + * and applied changes' deltas stay out of specs/, which is what keeps + * specs/ = shipped reality when state is data instead of location. + * + * "Folded" is decided by regeneration, not bookkeeping: a change is in sync + * when re-applying its delta to the current spec produces byte-identical + * output. That makes --check a pure function of the working tree — no model, + * no network, no VCS history — so the same command gates pre-commit, pre-push + * and CI. + */ +export class SyncCommand { + async execute( + changeName: string | undefined, + targetPath: string = '.', + options: SyncOptions = {} + ): Promise { + const mode = resolveLifecycle(targetPath); + const report: SyncReport = { mode, changes: [], clean: true }; + + if (mode !== 'status') { + // Mode-aware by contract: under `lifecycle: archive` the archive command + // owns the fold and there is no status field to gate on. Report and exit + // 0 rather than misfiring on the default layout. + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log( + "This project uses `lifecycle: archive` (the default) — nothing to sync or gate. `openspec sync` applies under `lifecycle: status`; see openspec/config.yaml." + ); + } + return; + } + + const changesDir = path.join(targetPath, 'openspec', 'changes'); + const specsDir = path.join(targetPath, 'openspec', 'specs'); + + const candidates = changeName + ? [changeName] + : await this.shippedChanges(changesDir, targetPath, report); + + for (const name of candidates) { + const changeDir = path.join(changesDir, name); + const state = await this.evaluate(name, changeDir, specsDir, targetPath, options); + if (state === null) { + continue; + } + report.changes.push(state.report); + if (state.report.state !== 'folded') { + report.clean = false; + } + if (!options.check && state.report.state === 'unfolded') { + for (const fold of state.folds) { + await writeUpdatedSpec(fold.update, fold.rebuilt, fold.counts, { + silent: options.json, + }); + } + state.report.state = 'folded'; + report.clean = report.changes.every((c) => c.state === 'folded'); + } + } + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + this.print(report, options); + } + + if (!report.clean) { + process.exitCode = 1; + } + } + + private async shippedChanges( + changesDir: string, + projectRoot: string, + report: SyncReport + ): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(changesDir, { withFileTypes: true }); + } catch { + return []; + } + + const shipped: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'archive') { + continue; + } + try { + const metadata = readChangeMetadata(path.join(changesDir, entry.name), projectRoot); + if (metadata?.status === 'shipped') { + shipped.push(entry.name); + } + } catch (err) { + // Unreadable metadata cannot prove the change is NOT shipped, so the + // gate fails closed: report it rather than skip it. + report.changes.push({ + change: entry.name, + state: 'conflict', + pending: [], + error: err instanceof ChangeMetadataError ? err.message : String(err), + }); + report.clean = false; + } + } + return shipped; + } + + private async evaluate( + name: string, + changeDir: string, + specsDir: string, + projectRoot: string, + options: SyncOptions + ): Promise<{ report: ChangeSyncState; folds: PendingFold[] } | null> { + try { + await fs.access(changeDir); + } catch { + throw new Error(`Change '${name}' not found in openspec/changes/`); + } + + // An explicitly named change must be shipped before its deltas may touch + // specs/. In check mode a non-shipped change is simply not gated. + const metadata = readChangeMetadata(changeDir, projectRoot); + if (metadata?.status !== 'shipped') { + if (options.check) { + return null; + } + throw new Error( + `Change '${name}' has status '${metadata?.status ?? 'none'}' — only shipped changes fold into specs/. Set \`status: shipped\` in its .openspec.yaml first.` + ); + } + + const result: ChangeSyncState = { change: name, state: 'folded', pending: [] }; + const folds: PendingFold[] = []; + + let updates: SpecUpdate[]; + try { + updates = await findSpecUpdates(changeDir, specsDir); + } catch (err) { + return { + report: { ...result, state: 'conflict', error: (err as Error).message }, + folds: [], + }; + } + + for (const update of updates) { + try { + const built = await buildUpdatedSpec(update, name, { silent: true }); + const current = update.exists ? await fs.readFile(update.target, 'utf-8') : null; + if (current !== built.rebuilt) { + result.state = 'unfolded'; + result.pending.push(update.id); + folds.push({ update, rebuilt: built.rebuilt, counts: built.counts }); + } + } catch (err) { + result.state = 'conflict'; + result.error = (err as Error).message; + return { report: result, folds: [] }; + } + } + + return { report: result, folds }; + } + + private print(report: SyncReport, options: SyncOptions): void { + if (report.changes.length === 0) { + console.log('No shipped changes to sync.'); + return; + } + for (const change of report.changes) { + if (change.state === 'folded') { + console.log(` ✓ ${change.change}`); + } else if (change.state === 'unfolded') { + console.log( + ` ✗ ${change.change} — shipped but not folded into specs/: ${change.pending.join(', ')}${options.check ? ' (run `openspec sync`)' : ''}` + ); + } else { + console.log(` ✗ ${change.change} — ${change.error}`); + } + } + } +} diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index f73ba61bce..85dc7caab7 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -2,7 +2,7 @@ import path from 'path'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; import { formatLocalDate } from './date.js'; -import { readProjectConfig } from '../core/project-config.js'; +import { readProjectConfig, resolveLifecycle } from '../core/project-config.js'; import { isKebabId } from '../core/id.js'; import type { ChangeMetadata } from '../core/change-metadata/index.js'; @@ -186,10 +186,13 @@ export async function createChange( await FileSystemUtils.writeFile(configPath, `schema: ${defaultSchema}\n`); } - // Write metadata file with schema and creation date + // Write metadata file with schema and creation date. Under + // `lifecycle: status` a change is born `proposed` — explicit from the start, + // so no change in that mode ever has an ambiguous lifecycle state. writeChangeMetadata(changeDir, { schema: schemaName, created: formatLocalDate(), + ...(resolveLifecycle(projectRoot) === 'status' ? { status: 'proposed' as const } : {}), ...options.metadata, }, projectRoot); diff --git a/test/core/sync.test.ts b/test/core/sync.test.ts new file mode 100644 index 0000000000..2227435e77 --- /dev/null +++ b/test/core/sync.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SyncCommand } from '../../src/core/sync.js'; +import { ArchiveCommand } from '../../src/core/archive.js'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), +})); + +const DELTA = `# Auth - Changes + +## ADDED Requirements + +### Requirement: The system SHALL support OAuth login + +#### Scenario: OAuth round trip +- **WHEN** a user signs in with a provider +- **THEN** a session is established +`; + +describe('SyncCommand', () => { + let tempDir: string; + let logs: string[]; + const originalLog = console.log; + const originalExitCode = process.exitCode; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sync-test-')); + logs = []; + console.log = (...args: unknown[]) => { + logs.push(args.join(' ')); + }; + process.exitCode = undefined; + }); + + afterEach(async () => { + console.log = originalLog; + process.exitCode = originalExitCode; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function scaffold(options: { + lifecycle?: 'archive' | 'status'; + status?: 'proposed' | 'applied' | 'shipped'; + }): Promise { + const openspec = path.join(tempDir, 'openspec'); + await fs.mkdir(path.join(openspec, 'specs'), { recursive: true }); + const changeDir = path.join(openspec, 'changes', 'add-oauth'); + await fs.mkdir(path.join(changeDir, 'specs', 'auth'), { recursive: true }); + + const lifecycleLine = options.lifecycle ? `lifecycle: ${options.lifecycle}\n` : ''; + await fs.writeFile( + path.join(openspec, 'config.yaml'), + `schema: spec-driven\n${lifecycleLine}` + ); + + const statusLine = options.status ? `status: ${options.status}\n` : ''; + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + `schema: spec-driven\ncreated: 2026-08-11\n${statusLine}` + ); + await fs.writeFile(path.join(changeDir, 'specs', 'auth', 'spec.md'), DELTA); + } + + function targetSpec(): string { + return path.join(tempDir, 'openspec', 'specs', 'auth', 'spec.md'); + } + + it('reports nothing to gate under lifecycle: archive', async () => { + await scaffold({ lifecycle: 'archive', status: 'shipped' }); + await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(process.exitCode).toBeUndefined(); + expect(logs.join('\n')).toContain('lifecycle: archive'); + }); + + it('check fails on a shipped change whose delta is not folded', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(process.exitCode).toBe(1); + expect(logs.join('\n')).toContain('add-oauth'); + expect(logs.join('\n')).toContain('auth'); + }); + + it('ignores proposed changes: their deltas stay out of specs/', async () => { + await scaffold({ lifecycle: 'status', status: 'proposed' }); + await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(process.exitCode).toBeUndefined(); + await expect(fs.access(targetSpec())).rejects.toThrow(); + }); + + it('folds a shipped change, then check passes and a re-run is a no-op', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + + await new SyncCommand().execute(undefined, tempDir, {}); + expect(process.exitCode).toBeUndefined(); + const folded = await fs.readFile(targetSpec(), 'utf-8'); + expect(folded).toContain('OAuth login'); + + process.exitCode = undefined; + logs = []; + await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(process.exitCode).toBeUndefined(); + + await new SyncCommand().execute(undefined, tempDir, {}); + const refolded = await fs.readFile(targetSpec(), 'utf-8'); + expect(refolded).toBe(folded); + }); + + it('refuses to fold an explicitly named change that is not shipped', async () => { + await scaffold({ lifecycle: 'status', status: 'applied' }); + await expect( + new SyncCommand().execute('add-oauth', tempDir, {}) + ).rejects.toThrow(/only shipped changes fold/); + }); +}); + +describe('ArchiveCommand under lifecycle: status', () => { + let tempDir: string; + const originalExitCode = process.exitCode; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sync-archive-test-')); + const openspec = path.join(tempDir, 'openspec'); + await fs.mkdir(path.join(openspec, 'specs'), { recursive: true }); + await fs.mkdir(path.join(openspec, 'changes', 'add-oauth'), { recursive: true }); + await fs.writeFile( + path.join(openspec, 'config.yaml'), + 'schema: spec-driven\nlifecycle: status\n' + ); + await fs.writeFile( + path.join(openspec, 'changes', 'add-oauth', '.openspec.yaml'), + 'schema: spec-driven\nstatus: shipped\n' + ); + }); + + afterEach(async () => { + process.exitCode = originalExitCode; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('refuses to archive and points at the status workflow', async () => { + const cwd = process.cwd(); + process.chdir(tempDir); + try { + await expect( + new ArchiveCommand().execute('add-oauth', { yes: true }) + ).rejects.toThrow(/lifecycle: status/); + } finally { + process.chdir(cwd); + } + }); +}); From 0796687946cdbb3e176ced6f1cd47e391e23b9d2 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 09:39:29 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat(lifecycle):=20openspec=20ship=20?= =?UTF-8?q?=E2=80=94=20declare=20shipped=20and=20fold,=20one=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores archive's declare+fold atomicity as a convenience instead of a mandate: ship sets `status: shipped` in the change's .openspec.yaml and runs the same idempotent sync fold, emitting one working-tree diff so the commit that declares shipped is the commit whose tree satisfies the shipped ⇒ folded predicate. Sugar over the field edit + sync, never the only way. Also neutralizes the folded-spec skeleton wording ("created from change X") — the old text assumed the archive workflow. Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 17 ++++++- src/core/completions/command-registry.ts | 13 +++++ src/core/specs-apply.ts | 2 +- src/core/sync.ts | 48 ++++++++++++++++++- test/core/archive.test.ts | 28 +++++------ test/core/sync.test.ts | 28 ++++++++++- test/specs/source-specs-normalization.test.ts | 2 +- 7 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 2098c54c82..fd2f9f8717 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,7 +19,7 @@ import { } from '../core/version-check.js'; import { ListCommand } from '../core/list.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; -import { SyncCommand } from '../core/sync.js'; +import { SyncCommand, ShipCommand } from '../core/sync.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; @@ -468,6 +468,21 @@ program } }); +program + .command('ship ') + .description( + 'Declare a change shipped and fold its deltas into main specs, as one diff (projects with `lifecycle: status`)' + ) + .option('--json', 'Output as JSON (non-interactive)') + .action(async (changeName: string, options?: { json?: boolean }) => { + try { + await new ShipCommand().execute(changeName, '.', options ?? {}); + } catch (error) { + failWithError(error); + process.exit(1); + } + }); + registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 35cecf3960..9dc8f3a0ad 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -199,6 +199,19 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'ship', + description: 'Declare a change shipped and fold its deltas into main specs (lifecycle: status projects)', + acceptsPositional: true, + positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id' }], + flags: [ + { + name: 'json', + description: 'Output as JSON (non-interactive)', + }, + ], + }, { name: 'status', description: 'Display artifact completion status for a change', diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index f0a8ff3842..6a130417d0 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -1088,6 +1088,6 @@ function readableOverview(skeleton: string, specName: string): string | null { export function buildSpecSkeleton(specFolderName: string, changeName: string, purpose?: string): string { const titleBase = specFolderName; const purposeBody = - purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`; + purpose?.trim() || `TBD - created from change ${changeName}. Update Purpose.`; return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`; } diff --git a/src/core/sync.ts b/src/core/sync.ts index 0fe3c8d480..252a06c4a0 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -7,7 +7,11 @@ import { writeUpdatedSpec, type SpecUpdate, } from './specs-apply.js'; -import { readChangeMetadata, ChangeMetadataError } from '../utils/change-metadata.js'; +import { + readChangeMetadata, + writeChangeMetadata, + ChangeMetadataError, +} from '../utils/change-metadata.js'; import { resolveLifecycle } from './project-config.js'; export interface SyncOptions { @@ -222,3 +226,45 @@ export class SyncCommand { } } } + +/** + * Declare a change shipped and fold its deltas — the two halves of the old + * archive, minus the move, emitted as one working-tree diff so the commit + * that declares "shipped" is the same commit whose tree satisfies the + * shipped ⇒ folded predicate. Restores archive's declare+fold atomicity as + * a convenience instead of a mandate: `ship` is sugar over editing the + * status field and running `sync` by hand, never the only way. + */ +export class ShipCommand { + async execute( + changeName: string, + targetPath: string = '.', + options: { json?: boolean } = {} + ): Promise { + const mode = resolveLifecycle(targetPath); + if (mode !== 'status') { + throw new Error( + 'This project uses `lifecycle: archive` (the default) — finish changes with `openspec archive`. `openspec ship` applies under `lifecycle: status`; see openspec/config.yaml.' + ); + } + + const changeDir = path.join(targetPath, 'openspec', 'changes', changeName); + const metadata = readChangeMetadata(changeDir, targetPath); + if (!metadata) { + throw new Error( + `Change '${changeName}' has no .openspec.yaml — nothing records its lifecycle state.` + ); + } + + if (metadata.status !== 'shipped') { + writeChangeMetadata(changeDir, { ...metadata, status: 'shipped' }, targetPath); + if (!options.json) { + console.log(` ${changeName}: status → shipped`); + } + } else if (!options.json) { + console.log(` ${changeName}: already shipped`); + } + + await new SyncCommand().execute(changeName, targetPath, { json: options.json }); + } +} diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index f64082e6e6..996a244b95 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -728,7 +728,7 @@ Then expected result happens`; const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain('# test-capability Specification'); expect(updatedContent).toContain('## Purpose'); - expect(updatedContent).toContain(`created by archiving change ${changeName}`); + expect(updatedContent).toContain(`created from change ${changeName}`); expect(updatedContent).toContain('## Requirements'); expect(updatedContent).toContain('### Requirement: The system SHALL provide test capability'); expect(updatedContent).toContain('#### Scenario: Basic test'); @@ -1206,7 +1206,7 @@ The system SHALL award loyalty points on each completed order. 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).not.toContain('TBD - created from change'); expect(updatedContent).toContain('### Requirement: Earn Points'); }); @@ -1242,7 +1242,7 @@ The system SHALL normalize config files on load. // The fenced example is part of the authored Purpose - masking fenced // lines out of the body would silently truncate it. expect(updatedContent).toContain('retries: 3'); - expect(updatedContent).not.toContain('TBD - created by archiving change'); + expect(updatedContent).not.toContain('TBD - created from change'); }); it('should keep the TBD Purpose placeholder when the delta has no Purpose (issue #1413)', async () => { @@ -1266,7 +1266,7 @@ The system SHALL send a referral invite. 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.` + `TBD - created from change ${changeName}. Update Purpose.` ); }); @@ -1296,7 +1296,7 @@ Illustration only - not this capability's purpose. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'payouts', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain("Illustration only - not this capability's purpose.\n## Requirements"); }); @@ -1324,7 +1324,7 @@ The system SHALL send a notification. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'notifications', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); }); @@ -1358,7 +1358,7 @@ The system SHALL handle widgets. 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.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('### Requirement: Stray header'); expect(updatedContent).toContain('### Requirement: Real Requirement'); @@ -1401,7 +1401,7 @@ The system SHALL handle gadgets. 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('# Not a spec title'); expect(console.log).toHaveBeenCalledWith( @@ -1444,7 +1444,7 @@ retries: 3 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); // Exactly one Requirements section, and the requirement is still visible. expect(updatedContent.match(/^## Requirements$/gm)).toHaveLength(1); @@ -1566,7 +1566,7 @@ The system SHALL track widgets. // lands in the file, where it can hide the headers the parsers rely on // and blank the document out in a markdown renderer. expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain(' transform --> sink'); - expect(updatedContent).not.toContain('TBD - created by archiving change'); + expect(updatedContent).not.toContain('TBD - created from change'); }); it('should keep the TBD placeholder when the delta Purpose is only a code fence (issue #1413)', async () => { @@ -1688,7 +1688,7 @@ The system SHALL retry failed requests. 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('retries: 3'); }); @@ -1763,7 +1763,7 @@ The system SHALL do the thing. 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('New capabilities only'); }); diff --git a/test/core/sync.test.ts b/test/core/sync.test.ts index 2227435e77..925353e8be 100644 --- a/test/core/sync.test.ts +++ b/test/core/sync.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SyncCommand } from '../../src/core/sync.js'; +import { SyncCommand, ShipCommand } from '../../src/core/sync.js'; import { ArchiveCommand } from '../../src/core/archive.js'; import { promises as fs } from 'fs'; import path from 'path'; @@ -115,6 +115,32 @@ describe('SyncCommand', () => { new SyncCommand().execute('add-oauth', tempDir, {}) ).rejects.toThrow(/only shipped changes fold/); }); + + it('ship flips status and folds in one step; re-ship is a no-op', async () => { + await scaffold({ lifecycle: 'status', status: 'proposed' }); + + await new ShipCommand().execute('add-oauth', tempDir, {}); + expect(process.exitCode).toBeUndefined(); + const metadata = await fs.readFile( + path.join(tempDir, 'openspec', 'changes', 'add-oauth', '.openspec.yaml'), + 'utf-8' + ); + expect(metadata).toContain('status: shipped'); + const folded = await fs.readFile(targetSpec(), 'utf-8'); + expect(folded).toContain('OAuth login'); + + process.exitCode = undefined; + await new ShipCommand().execute('add-oauth', tempDir, {}); + expect(process.exitCode).toBeUndefined(); + expect(await fs.readFile(targetSpec(), 'utf-8')).toBe(folded); + }); + + it('ship refuses under lifecycle: archive and points at the archive workflow', async () => { + await scaffold({ lifecycle: 'archive', status: 'proposed' }); + await expect( + new ShipCommand().execute('add-oauth', tempDir, {}) + ).rejects.toThrow(/openspec archive/); + }); }); describe('ArchiveCommand under lifecycle: status', () => { diff --git a/test/specs/source-specs-normalization.test.ts b/test/specs/source-specs-normalization.test.ts index 2611a85f9d..5caf929935 100644 --- a/test/specs/source-specs-normalization.test.ts +++ b/test/specs/source-specs-normalization.test.ts @@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const specsRoot = path.join(projectRoot, 'openspec', 'specs'); -const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created by archiving change .*?\. Update Purpose after archive\./; +const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created from change .*?\. Update Purpose\./; const REQUIREMENT_HEADER_PATTERN = /^###\s+Requirement:/gm; async function getSpecFiles(): Promise { From 792728d55cea02ac13118a882438d91191be53d3 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 10:53:23 +0200 Subject: [PATCH 03/13] =?UTF-8?q?refactor(lifecycle):=20two=20states=20?= =?UTF-8?q?=E2=80=94=20a=20status=20without=20consequences=20is=20a=20comm?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops 'applied'. The closed set exists because tooling attaches consequences to each state; applied carried none (sync gates shipped, overlap reasoning keys on proposed), and implementation progress is already recorded by tasks.md checkboxes — a duplicate that can drift. The next state to earn a slot is whichever arrives with consequences (abandoned — releasing the live claim — is the obvious candidate). Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 2 +- src/core/change-metadata/schema.ts | 11 +++++++---- src/core/completions/command-registry.ts | 4 ++-- src/core/list.ts | 2 +- src/core/sync.ts | 2 +- test/core/sync.test.ts | 4 ++-- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index fd2f9f8717..1ba8e12a08 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -314,7 +314,7 @@ program .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') - .option('--status ', 'Filter changes by lifecycle status (proposed, applied, shipped)') + .option('--status ', 'Filter changes by lifecycle status (proposed, shipped)') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index e6a109544a..b0884a1fdc 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -48,11 +48,14 @@ export const ChangeMetadataSchema = z.object({ retire_capabilities: z.boolean().optional(), // Lifecycle state under `lifecycle: status` mode: the change's position in // its life is data, not directory location, and nothing ever moves. Closed - // set because tooling attaches consequences to each category: `sync` folds - // only shipped changes' deltas into specs/, and overlap/drift tooling treats - // proposed/applied as holding a live claim on the requirements they touch. + // set because tooling attaches consequences to each state: `sync` folds + // only shipped changes' deltas into specs/, and a proposed change holds a + // live claim on the requirements it touches (what overlap/drift tooling + // reasons over). Deliberately two states: implementation progress is + // already carried by tasks.md checkboxes, and a status with no machine + // consequences would just be a comment that can drift from them. // Absent on projects using the default `lifecycle: archive` mode. - status: z.enum(['proposed', 'applied', 'shipped']).optional(), + status: z.enum(['proposed', 'shipped']).optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 9dc8f3a0ad..6e1afcdb2b 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -70,9 +70,9 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'status', - description: 'Filter changes by lifecycle status (proposed, applied, shipped)', + description: 'Filter changes by lifecycle status (proposed, shipped)', takesValue: true, - values: ['proposed', 'applied', 'shipped'], + values: ['proposed', 'shipped'], }, COMMON_FLAGS.json, COMMON_FLAGS.store, diff --git a/src/core/list.ts b/src/core/list.ts index ca6224dca5..1a1a231295 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -23,7 +23,7 @@ interface ListOptions { status?: string; } -const LIFECYCLE_STATES = new Set(['proposed', 'applied', 'shipped']); +const LIFECYCLE_STATES = new Set(['proposed', 'shipped']); // Non-throwing: list must render even when a change's metadata would fail the // stricter contract readChangeMetadata enforces — a broken change is status's diff --git a/src/core/sync.ts b/src/core/sync.ts index 252a06c4a0..be3de24b93 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -43,7 +43,7 @@ export interface SyncReport { * Fold shipped changes' spec deltas into the main specs — the text-merge half * of what archive does, decoupled from any directory move so it can run at any * time, idempotently. Only changes declaring `status: shipped` fold; proposed - * and applied changes' deltas stay out of specs/, which is what keeps + * changes' deltas stay out of specs/, which is what keeps * specs/ = shipped reality when state is data instead of location. * * "Folded" is decided by regeneration, not bookkeeping: a change is in sync diff --git a/test/core/sync.test.ts b/test/core/sync.test.ts index 925353e8be..4c538da4dc 100644 --- a/test/core/sync.test.ts +++ b/test/core/sync.test.ts @@ -44,7 +44,7 @@ describe('SyncCommand', () => { async function scaffold(options: { lifecycle?: 'archive' | 'status'; - status?: 'proposed' | 'applied' | 'shipped'; + status?: 'proposed' | 'shipped'; }): Promise { const openspec = path.join(tempDir, 'openspec'); await fs.mkdir(path.join(openspec, 'specs'), { recursive: true }); @@ -110,7 +110,7 @@ describe('SyncCommand', () => { }); it('refuses to fold an explicitly named change that is not shipped', async () => { - await scaffold({ lifecycle: 'status', status: 'applied' }); + await scaffold({ lifecycle: 'status', status: 'proposed' }); await expect( new SyncCommand().execute('add-oauth', tempDir, {}) ).rejects.toThrow(/only shipped changes fold/); From 9315379200e399fbd323073d679f66f44df98ff7 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 15:06:53 +0200 Subject: [PATCH 04/13] =?UTF-8?q?fix(lifecycle):=20sync=20returns=20its=20?= =?UTF-8?q?report=20=E2=80=94=20exit=20codes=20live=20at=20the=20CLI=20edg?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Programmatic callers read the returned SyncReport instead of sniffing process.exitCode; a new silent option suppresses output for reuse as a gate. Unreadable metadata on a named sync now yields the same conflict entry the no-arg sweep reports, so CI parses one shape either way, and list rejects an unknown --status value instead of printing an empty list that reads as success. Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 10 +++++-- src/core/list.ts | 6 +++++ src/core/sync.ts | 46 ++++++++++++++++++++++---------- test/core/list.test.ts | 11 ++++++++ test/core/sync.test.ts | 59 ++++++++++++++++++++++++++++++------------ 5 files changed, 101 insertions(+), 31 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1ba8e12a08..b5750c8315 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -461,7 +461,10 @@ program .option('--json', 'Output as JSON (non-interactive)') .action(async (changeName?: string, options?: { check?: boolean; json?: boolean }) => { try { - await new SyncCommand().execute(changeName, '.', options ?? {}); + const report = await new SyncCommand().execute(changeName, '.', options ?? {}); + if (!report.clean) { + process.exitCode = 1; + } } catch (error) { failWithError(error); process.exit(1); @@ -476,7 +479,10 @@ program .option('--json', 'Output as JSON (non-interactive)') .action(async (changeName: string, options?: { json?: boolean }) => { try { - await new ShipCommand().execute(changeName, '.', options ?? {}); + const report = await new ShipCommand().execute(changeName, '.', options ?? {}); + if (!report.clean) { + process.exitCode = 1; + } } catch (error) { failWithError(error); process.exit(1); diff --git a/src/core/list.ts b/src/core/list.ts index 1a1a231295..da5d76e2a6 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -118,6 +118,12 @@ export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { const { sort = 'recent', json = false, root } = options; + if (options.status && !LIFECYCLE_STATES.has(options.status)) { + throw new Error( + `Unknown status '${options.status}' — expected one of: ${[...LIFECYCLE_STATES].join(', ')}.` + ); + } + if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); diff --git a/src/core/sync.ts b/src/core/sync.ts index be3de24b93..6d44f71357 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -17,6 +17,8 @@ import { resolveLifecycle } from './project-config.js'; export interface SyncOptions { check?: boolean; json?: boolean; + /** Suppress all output — programmatic callers read the returned report. */ + silent?: boolean; } type PendingFold = { @@ -57,7 +59,7 @@ export class SyncCommand { changeName: string | undefined, targetPath: string = '.', options: SyncOptions = {} - ): Promise { + ): Promise { const mode = resolveLifecycle(targetPath); const report: SyncReport = { mode, changes: [], clean: true }; @@ -65,6 +67,9 @@ export class SyncCommand { // Mode-aware by contract: under `lifecycle: archive` the archive command // owns the fold and there is no status field to gate on. Report and exit // 0 rather than misfiring on the default layout. + if (options.silent) { + return report; + } if (options.json) { console.log(JSON.stringify(report, null, 2)); } else { @@ -72,7 +77,7 @@ export class SyncCommand { "This project uses `lifecycle: archive` (the default) — nothing to sync or gate. `openspec sync` applies under `lifecycle: status`; see openspec/config.yaml." ); } - return; + return report; } const changesDir = path.join(targetPath, 'openspec', 'changes'); @@ -95,7 +100,7 @@ export class SyncCommand { if (!options.check && state.report.state === 'unfolded') { for (const fold of state.folds) { await writeUpdatedSpec(fold.update, fold.rebuilt, fold.counts, { - silent: options.json, + silent: options.json || options.silent, }); } state.report.state = 'folded'; @@ -103,15 +108,15 @@ export class SyncCommand { } } - if (options.json) { - console.log(JSON.stringify(report, null, 2)); - } else { - this.print(report, options); + if (!options.silent) { + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + this.print(report, options); + } } - if (!report.clean) { - process.exitCode = 1; - } + return report; } private async shippedChanges( @@ -166,7 +171,22 @@ export class SyncCommand { // An explicitly named change must be shipped before its deltas may touch // specs/. In check mode a non-shipped change is simply not gated. - const metadata = readChangeMetadata(changeDir, projectRoot); + // Unreadable metadata is the same conflict entry the no-arg sweep reports, + // so CI sees one shape either way. + let metadata; + try { + metadata = readChangeMetadata(changeDir, projectRoot); + } catch (err) { + return { + report: { + change: name, + state: 'conflict', + pending: [], + error: err instanceof ChangeMetadataError ? err.message : String(err), + }, + folds: [], + }; + } if (metadata?.status !== 'shipped') { if (options.check) { return null; @@ -240,7 +260,7 @@ export class ShipCommand { changeName: string, targetPath: string = '.', options: { json?: boolean } = {} - ): Promise { + ): Promise { const mode = resolveLifecycle(targetPath); if (mode !== 'status') { throw new Error( @@ -265,6 +285,6 @@ export class ShipCommand { console.log(` ${changeName}: already shipped`); } - await new SyncCommand().execute(changeName, targetPath, { json: options.json }); + return new SyncCommand().execute(changeName, targetPath, { json: options.json }); } } diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5b23a5d712..5178d1b49b 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -48,6 +48,17 @@ describe('ListCommand', () => { expect(logOutput).toEqual(['No active changes found.']); }); + it('rejects an unknown --status value instead of silently matching nothing', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + const listCommand = new ListCommand(); + + await expect( + listCommand.execute(tempDir, 'changes', { status: 'bogus' }) + ).rejects.toThrow(/Unknown status 'bogus'/); + }); + it('should not report a malformed openspec/changes path as empty', async () => { await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); diff --git a/test/core/sync.test.ts b/test/core/sync.test.ts index 4c538da4dc..f54eb23531 100644 --- a/test/core/sync.test.ts +++ b/test/core/sync.test.ts @@ -71,44 +71,72 @@ describe('SyncCommand', () => { it('reports nothing to gate under lifecycle: archive', async () => { await scaffold({ lifecycle: 'archive', status: 'shipped' }); - await new SyncCommand().execute(undefined, tempDir, { check: true }); - expect(process.exitCode).toBeUndefined(); + const report = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(report.clean).toBe(true); + expect(report.mode).toBe('archive'); expect(logs.join('\n')).toContain('lifecycle: archive'); }); it('check fails on a shipped change whose delta is not folded', async () => { await scaffold({ lifecycle: 'status', status: 'shipped' }); - await new SyncCommand().execute(undefined, tempDir, { check: true }); - expect(process.exitCode).toBe(1); + const report = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(report.clean).toBe(false); expect(logs.join('\n')).toContain('add-oauth'); expect(logs.join('\n')).toContain('auth'); }); it('ignores proposed changes: their deltas stay out of specs/', async () => { await scaffold({ lifecycle: 'status', status: 'proposed' }); - await new SyncCommand().execute(undefined, tempDir, { check: true }); - expect(process.exitCode).toBeUndefined(); + const report = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(report.clean).toBe(true); await expect(fs.access(targetSpec())).rejects.toThrow(); }); it('folds a shipped change, then check passes and a re-run is a no-op', async () => { await scaffold({ lifecycle: 'status', status: 'shipped' }); - await new SyncCommand().execute(undefined, tempDir, {}); - expect(process.exitCode).toBeUndefined(); + const fold = await new SyncCommand().execute(undefined, tempDir, {}); + expect(fold.clean).toBe(true); const folded = await fs.readFile(targetSpec(), 'utf-8'); expect(folded).toContain('OAuth login'); - process.exitCode = undefined; logs = []; - await new SyncCommand().execute(undefined, tempDir, { check: true }); - expect(process.exitCode).toBeUndefined(); + const check = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(check.clean).toBe(true); await new SyncCommand().execute(undefined, tempDir, {}); const refolded = await fs.readFile(targetSpec(), 'utf-8'); expect(refolded).toBe(folded); }); + it('silent mode emits nothing and still returns the report', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + const report = await new SyncCommand().execute(undefined, tempDir, { + check: true, + silent: true, + }); + expect(report.clean).toBe(false); + expect(logs).toEqual([]); + }); + + it('reports unreadable metadata as the same conflict entry named or swept', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + await fs.writeFile( + path.join(tempDir, 'openspec', 'changes', 'add-oauth', '.openspec.yaml'), + 'status: [unclosed\n' + ); + + const swept = await new SyncCommand().execute(undefined, tempDir, { check: true, silent: true }); + const named = await new SyncCommand().execute('add-oauth', tempDir, { check: true, silent: true }); + + for (const report of [swept, named]) { + expect(report.clean).toBe(false); + expect(report.changes).toHaveLength(1); + expect(report.changes[0].state).toBe('conflict'); + expect(report.changes[0].error).toBeTruthy(); + } + }); + it('refuses to fold an explicitly named change that is not shipped', async () => { await scaffold({ lifecycle: 'status', status: 'proposed' }); await expect( @@ -119,8 +147,8 @@ describe('SyncCommand', () => { it('ship flips status and folds in one step; re-ship is a no-op', async () => { await scaffold({ lifecycle: 'status', status: 'proposed' }); - await new ShipCommand().execute('add-oauth', tempDir, {}); - expect(process.exitCode).toBeUndefined(); + const shipped = await new ShipCommand().execute('add-oauth', tempDir, {}); + expect(shipped.clean).toBe(true); const metadata = await fs.readFile( path.join(tempDir, 'openspec', 'changes', 'add-oauth', '.openspec.yaml'), 'utf-8' @@ -129,9 +157,8 @@ describe('SyncCommand', () => { const folded = await fs.readFile(targetSpec(), 'utf-8'); expect(folded).toContain('OAuth login'); - process.exitCode = undefined; - await new ShipCommand().execute('add-oauth', tempDir, {}); - expect(process.exitCode).toBeUndefined(); + const reshipped = await new ShipCommand().execute('add-oauth', tempDir, {}); + expect(reshipped.clean).toBe(true); expect(await fs.readFile(targetSpec(), 'utf-8')).toBe(folded); }); From 93b0c77e98411538b6c383afa8124784ba256216 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Mon, 17 Aug 2026 14:34:20 +0300 Subject: [PATCH 05/13] docs(lifecycle): dogfood the change proposal and add a changeset The repo tracks its own features as OpenSpec changes; this adds the proposal, design note, tasks and capability spec for lifecycle: status, plus the release changeset. The design note records why the state set is closed at two, why folded-ness is decided by regeneration rather than bookkeeping, and why archive refuses under the mode. Co-Authored-By: Claude Opus 5 --- .changeset/add-lifecycle-status-mode.md | 5 + .../add-lifecycle-status-mode/.openspec.yaml | 2 + .../add-lifecycle-status-mode/design.md | 67 ++++++++++++ .../add-lifecycle-status-mode/proposal.md | 44 ++++++++ .../specs/lifecycle-status-mode/spec.md | 101 ++++++++++++++++++ .../add-lifecycle-status-mode/tasks.md | 47 ++++++++ 6 files changed, 266 insertions(+) create mode 100644 .changeset/add-lifecycle-status-mode.md create mode 100644 openspec/changes/add-lifecycle-status-mode/.openspec.yaml create mode 100644 openspec/changes/add-lifecycle-status-mode/design.md create mode 100644 openspec/changes/add-lifecycle-status-mode/proposal.md create mode 100644 openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md create mode 100644 openspec/changes/add-lifecycle-status-mode/tasks.md diff --git a/.changeset/add-lifecycle-status-mode.md b/.changeset/add-lifecycle-status-mode.md new file mode 100644 index 0000000000..f1a8b030a5 --- /dev/null +++ b/.changeset/add-lifecycle-status-mode.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add an opt-in experimental `lifecycle: status` mode, in which a change's lifecycle state is a field in its metadata rather than its position in the filesystem. Under `lifecycle: status` a change carries `status: proposed | shipped` in `.openspec.yaml` and never moves: `openspec sync` folds every shipped change's deltas into `openspec/specs/` idempotently, `openspec sync --check` gates the `shipped ⇒ folded` predicate deterministically for pre-commit, pre-push and CI, and `openspec ship ` declares and folds in one diff. `openspec list` gains a lifecycle column and `--status` filter, and `openspec archive` refuses under status mode so the two models stay disjoint. Projects that do not set `lifecycle` resolve to `archive` and are entirely unaffected. diff --git a/openspec/changes/add-lifecycle-status-mode/.openspec.yaml b/openspec/changes/add-lifecycle-status-mode/.openspec.yaml new file mode 100644 index 0000000000..149631464a --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/add-lifecycle-status-mode/design.md b/openspec/changes/add-lifecycle-status-mode/design.md new file mode 100644 index 0000000000..5e0625cbd3 --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/design.md @@ -0,0 +1,67 @@ +## Context + +`archive` conflates a state transition with a text merge. The merge itself is fine; welding it to a directory move is what makes it hard to schedule. On a solo repo the two are indistinguishable. On a team with review, every possible moment to run `archive` is wrong somewhere: + +| Moment | Why it breaks | +|---|---| +| During the PR | Review feedback invalidates the fold; un-archive does not exist and re-archive is not a no-op | +| After merge | A bot commit to a protected branch, racing concurrent merges | +| At approval | GitLab has no approval event (`CI_MERGE_REQUEST_APPROVED` is pre-pipeline), and pushes reset approvals | + +## Goals / Non-Goals + +**Goals** + +- Make lifecycle state a first-class fact that merges trivially and can be edited to correct a mistake. +- Make the fold a standalone, idempotent operation that is safe to run late, twice, or never-yet. +- Make "is this repo consistent?" a pure function of the working tree, so one predicate gates pre-commit, pre-push and CI. +- Change nothing for projects that do not opt in. + +**Non-Goals** + +- Concurrent modification of the same requirement by two open changes (see #1669 and the parallel-merge plan). +- Replacing the archive workflow. This is an experiment with an exit; if it does not graduate, it is removed. +- Deciding where change directories live (see #1367). + +## Decisions + +### The state set is closed, and every state has machine consequences + +`status: proposed | shipped`. Two states, because a state with no attached consequence is a comment: + +- `shipped` means "these deltas belong in `specs/`" — what `sync` folds and what `--check` gates. +- `proposed` means "this change holds a live claim on the requirements it touches" — what overlap and drift tooling can reason over without inferring liveness from a directory path. + +An `applied` state was prototyped and dropped: "implementation done" is already recorded by `tasks.md` checkboxes, and a duplicate record drifts. Further states are possible later — `abandoned` would release the live claim — but each must earn its place with a consequence. + +### "Folded" is decided by regeneration, not bookkeeping + +A change is in sync when re-applying its delta to the current spec produces byte-identical output. No lockfile, no hash sidecar, no timestamp comparison — the check rebuilds and compares. + +This costs O(shipped history) per run rather than O(active changes), which is negligible for young histories and is the reason a `--changed` scope is named as future work rather than shipped here. In exchange the gate has no state of its own to corrupt, and — importantly — `--check` and the fold share one code path. A checker that reimplements the doer is how #1112 happened: `validate` passed what `archive` then refused. Here the only difference between checking and doing is whether the rebuilt bytes get written. + +### The gate is a tree predicate, not a timing condition + +`shipped ⇒ folded`. This is what makes the mode enforceable rather than merely conventional. A timing condition ("archive ran at the right moment") cannot be evaluated mid-PR, precisely when the invariant is supposed to be violated. A tree predicate can be evaluated on any tree by anyone: + +```sh +openspec sync --check # pre-commit · pre-push · CI — same command, same verdict +``` + +Hooks are advisory (`--no-verify` skips them), so CI remains the authority for the tree-level property. The one property that inverts this is atomicity: whether declaring and folding happened in the *same commit* is a history-level fact that CI, which sees only the head tree, is structurally blind to. `ship` makes the atomic path the default one, and a pre-push sweep over the pushed range can enforce it where a team cares. + +### `archive` refuses rather than coexists + +Under `lifecycle: status`, `openspec archive` throws and names the alternative. Two models that can both claim a change is finished would let `specs/` disagree with itself. The refusal is what keeps `specs/` = shipped reality true in both modes, which is also what makes migration between them a pure relayout: neither mode's `specs/` content differs. + +## Risks / Trade-offs + +- **`ls` stops being the answer to "what's active."** Once state is data, the filesystem is no longer the UI for state; `openspec list --status proposed` is. This is the honest cost of the whole design and is why the mode is opt-in. +- **The fold diff relocates, it does not disappear.** It lands wherever `sync` ran instead of in the archive commit. Deterministic output makes it reviewable the way a lockfile is: regenerate and compare. +- **Editing a delta after it was folded** re-merges over an earlier fold, which needs base snapshots to do correctly. This window pre-exists; making fold-anytime first-class means it sees more traffic. `sync --check` detects the state and fails closed rather than corrupting `specs/`. `sync` is a natural recording point for the parallel-merge plan's base snapshots when those arrive. + +## Migration + +None required. Adoption is a config line: existing `changes/archive/` history stays where it is with its folds already in `specs/`, so `sync --check` is green on day one. Changes authored before the flip carry no `status` field and are simply not gated until `ship` stamps them. + +Leaving is the same edit in reverse, with one caveat this change does not yet address: a change shipped under status mode sits flat in `changes/` and would need to be moved into `changes/archive/` by hand. A `migrate` command covering both directions was prototyped alongside this change and is held back for a follow-up, so this PR stays one reviewable idea. diff --git a/openspec/changes/add-lifecycle-status-mode/proposal.md b/openspec/changes/add-lifecycle-status-mode/proposal.md new file mode 100644 index 0000000000..ce6278b77a --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/proposal.md @@ -0,0 +1,44 @@ +## Why + +`archive` does two unrelated jobs in one command: a **state transition** (declaring a change shipped) and a **text merge** (folding deltas into `specs/`). Encoding the transition as a directory move welds the merge to a single moment in the PR lifecycle — and on a team with review, that moment does not exist. Review feedback forces un-archive → edit → re-archive; archiving after merge means a bot commit to a protected branch; and GitLab has no approval event to hang it on. + +The team-workflow docs offer both conventions and say "pick one and be consistent" — a choice of costs, not an answer. + +This change adds an opt-in experimental mode where a change's lifecycle state is a **field in its metadata** rather than its position in the filesystem, so the merge becomes a standalone idempotent command that can run at any time and be checked deterministically in CI. + +## What Changes + +- `openspec/config.yaml` accepts `lifecycle: archive | status`. `archive` is the default and current behavior; nothing changes for existing projects. +- Under `lifecycle: status`, a change's `.openspec.yaml` carries `status: proposed | shipped`. New changes are born `proposed`. +- `openspec sync` folds every `shipped` change's deltas into `specs/`, idempotently. It is the text-merge half of archive, decoupled from any move. +- `openspec sync --check` exits 1 if any `shipped` change has unfolded deltas — a deterministic, model-free gate that runs identically at pre-commit, pre-push and in CI. +- `openspec ship ` sets `status: shipped` and folds in one working-tree diff, restoring archive's declare-and-fold atomicity as a convenience rather than a mandate. +- `openspec list` shows the lifecycle state and accepts `--status ` to filter. +- `openspec archive` refuses to run under `lifecycle: status` and points at the status workflow, so the two models can never both claim a change. + +## Capabilities + +### New Capabilities + +- `lifecycle-status-mode`: the experimental `lifecycle: status` mode — the config flag, the `status` metadata field, the `sync`/`sync --check`/`ship` commands, the `list` surface, and the `archive` refusal that keeps the two models disjoint. + +### Modified Capabilities + +_None._ The mode is opt-in and inert under the default `lifecycle: archive`: `sync` and `ship` report that the project uses archive mode and exit 0, `list` renders no lifecycle column when no change declares a status, and `archive` is untouched. Existing capability specs describe archive-mode behavior, which this change does not alter. + +## Impact + +- `src/core/project-config.ts` — the `lifecycle` config field and its resolver +- `src/core/change-metadata/schema.ts` — the optional `status` field +- `src/core/sync.ts` — new `SyncCommand` and `ShipCommand` +- `src/core/archive.ts` — refusal guard under status mode +- `src/core/list.ts` — lifecycle column and `--status` filter +- `src/utils/change-utils.ts` — new changes are born `proposed` under status mode +- `src/cli/index.ts`, `src/core/completions/command-registry.ts` — command surface and completions +- `src/core/specs-apply.ts` — the generated skeleton's Purpose line no longer says "by archiving", since a fold can now happen without one + +## Out of scope + +- **Concurrent modification of the same requirement** by two open changes. This changes *when* the merge may run, not *how* it merges; it composes with the parallel-merge plan and with #1669. +- **Deriving shipped-ness from git.** Git proves a change folder landed on a branch, not that the change was implemented. Status stays an explicit declaration; git facts can cross-check it, not replace it. +- **Where changes live on disk.** Creation-date sharding was prototyped alongside this and is deliberately excluded — see the design note on #1367. diff --git a/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md new file mode 100644 index 0000000000..dcac613efa --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md @@ -0,0 +1,101 @@ +## ADDED Requirements + +### Requirement: Projects select a lifecycle mode in config + +`openspec/config.yaml` SHALL accept a `lifecycle` field with the values `archive` or `status`. When the field is absent, unreadable, or carries an unrecognized value, the project SHALL resolve to `archive`, which is the existing behavior. No project acquires status-mode behavior without declaring it. + +#### Scenario: No lifecycle field declared +- **WHEN** a project's `openspec/config.yaml` has no `lifecycle` field +- **THEN** the project resolves to `lifecycle: archive` and every command behaves exactly as before + +#### Scenario: Status mode declared +- **WHEN** a project's `openspec/config.yaml` contains `lifecycle: status` +- **THEN** the project resolves to `lifecycle: status` + +#### Scenario: Unrecognized value falls back rather than failing +- **WHEN** a project's `openspec/config.yaml` contains `lifecycle: bogus` +- **THEN** the project resolves to `lifecycle: archive` rather than raising a fatal error + +### Requirement: A change records its lifecycle state as metadata + +A change's `.openspec.yaml` SHALL accept an optional `status` field with the values `proposed` or `shipped`. Under `lifecycle: status`, a newly created change SHALL be written with `status: proposed` so that no change in that mode has an ambiguous state. Under `lifecycle: archive`, change creation SHALL NOT write a status field. + +#### Scenario: New change under status mode is born proposed +- **WHEN** a user runs `openspec new change add-auth` in a project resolving to `lifecycle: status` +- **THEN** the created `.openspec.yaml` contains `status: proposed` + +#### Scenario: New change under archive mode carries no status +- **WHEN** a user runs `openspec new change add-auth` in a project resolving to `lifecycle: archive` +- **THEN** the created `.openspec.yaml` contains no `status` field + +### Requirement: Sync folds shipped changes into the main specs + +`openspec sync` SHALL fold the spec deltas of every change declaring `status: shipped` into `openspec/specs/`, and SHALL leave the deltas of changes in any other state out of `openspec/specs/`. A change SHALL be considered folded when re-applying its delta to the current spec produces byte-identical output, so that a repeated run writes nothing. + +#### Scenario: Shipped change is folded +- **WHEN** a user runs `openspec sync` in a status-mode project containing a change with `status: shipped` whose delta is not yet in the main spec +- **THEN** the delta is applied to the main spec + +#### Scenario: Proposed change is not folded +- **WHEN** a user runs `openspec sync` in a status-mode project whose only change declares `status: proposed` +- **THEN** the main spec is not created or modified + +#### Scenario: Repeated sync is a no-op +- **WHEN** a user runs `openspec sync` twice in succession +- **THEN** the second run leaves every main spec byte-identical to the first run's output + +#### Scenario: Naming a change that is not shipped +- **WHEN** a user runs `openspec sync ` naming a change whose status is not `shipped` +- **THEN** the command fails with a message stating that only shipped changes fold into `specs/` + +### Requirement: Sync check gates the shipped-implies-folded predicate + +`openspec sync --check` SHALL report whether every `shipped` change's deltas are folded, without writing to `openspec/specs/`, and SHALL cause a non-zero exit when any shipped change has unfolded deltas. A change whose metadata cannot be read SHALL be reported as a conflict rather than skipped, so that the gate fails closed. The check SHALL use the same fold implementation the write path uses. + +#### Scenario: Shipped but unfolded fails the gate +- **WHEN** a user runs `openspec sync --check` in a status-mode project containing a shipped change whose delta is not folded +- **THEN** the command names the change and the affected capability, does not modify any spec, and exits non-zero + +#### Scenario: Fully folded tree passes the gate +- **WHEN** a user runs `openspec sync --check` in a status-mode project where every shipped change is folded +- **THEN** the command exits zero + +#### Scenario: Unreadable metadata fails closed +- **WHEN** a user runs `openspec sync --check` in a status-mode project containing a change whose `.openspec.yaml` cannot be parsed +- **THEN** that change is reported as a conflict and the command exits non-zero + +### Requirement: Ship declares and folds in one diff + +`openspec ship ` SHALL set the named change's status to `shipped` and then fold its deltas, so that the working-tree diff which declares a change shipped is the same diff that satisfies the shipped-implies-folded predicate. Shipping an already-shipped change SHALL be a no-op. + +#### Scenario: Ship flips status and folds +- **WHEN** a user runs `openspec ship add-auth` in a status-mode project where `add-auth` is proposed +- **THEN** the change's `.openspec.yaml` records `status: shipped` and its delta is applied to the main spec + +#### Scenario: Re-shipping changes nothing +- **WHEN** a user runs `openspec ship add-auth` on a change that is already shipped and folded +- **THEN** no spec file is modified + +### Requirement: List surfaces and filters lifecycle state + +`openspec list` SHALL display the lifecycle state of each change that declares one, and SHALL accept `--status ` to show only changes in that state. An unrecognized `--status` value SHALL be rejected with a message naming the valid states, rather than silently matching nothing. + +#### Scenario: Filtering by state +- **WHEN** a user runs `openspec list --status shipped` in a project containing both shipped and proposed changes +- **THEN** only the shipped changes are listed + +#### Scenario: Unknown state is rejected +- **WHEN** a user runs `openspec list --status bogus` +- **THEN** the command fails with a message naming the valid lifecycle states + +### Requirement: Archive and status modes stay disjoint + +`openspec archive` SHALL refuse to run in a project resolving to `lifecycle: status`, and the message SHALL point at the status-mode workflow. `openspec sync` and `openspec ship` SHALL report that there is nothing to do in a project resolving to `lifecycle: archive` and exit zero. Neither mode's commands SHALL act on a project that has selected the other. + +#### Scenario: Archive refuses under status mode +- **WHEN** a user runs `openspec archive add-auth` in a status-mode project +- **THEN** the command fails with a message naming `lifecycle: status` and pointing at the status workflow, and no files are moved or modified + +#### Scenario: Sync reports nothing to do under archive mode +- **WHEN** a user runs `openspec sync --check` in a project resolving to `lifecycle: archive` +- **THEN** the command reports that the project uses archive mode and exits zero diff --git a/openspec/changes/add-lifecycle-status-mode/tasks.md b/openspec/changes/add-lifecycle-status-mode/tasks.md new file mode 100644 index 0000000000..51749bad7a --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/tasks.md @@ -0,0 +1,47 @@ +## 1. Configuration + +- [x] 1.1 Add the `lifecycle` field to the project config schema with `archive | status` values and `archive` as the default +- [x] 1.2 Add `resolveLifecycle(projectRoot)` and make an unreadable or invalid value fall back to the default rather than throw + +## 2. Metadata + +- [x] 2.1 Add the optional `status: proposed | shipped` field to the change metadata schema +- [x] 2.2 Create new changes with `status: proposed` under `lifecycle: status`, and unchanged under `lifecycle: archive` + +## 3. Sync + +- [x] 3.1 Implement `SyncCommand`: discover `shipped` changes, rebuild each affected spec, write only where the rebuild differs +- [x] 3.2 Decide "folded" by byte-identical regeneration so `--check` and the fold share one code path +- [x] 3.3 Implement `--check`: report without writing, and return a report whose `clean` flag drives the exit code at the CLI edge +- [x] 3.4 Report unreadable metadata as a conflict rather than skipping it, so the gate fails closed +- [x] 3.5 Report nothing to gate and exit 0 under `lifecycle: archive` + +## 4. Ship + +- [x] 4.1 Implement `ShipCommand`: set `status: shipped`, then delegate to `SyncCommand` so both halves land in one diff +- [x] 4.2 Make a re-ship a no-op +- [x] 4.3 Refuse under `lifecycle: archive` and point at `openspec archive` + +## 5. Surfaces + +- [x] 5.1 Show the lifecycle state in `openspec list` and add `--status ` filtering +- [x] 5.2 Reject an unknown `--status` value instead of printing an empty list +- [x] 5.3 Refuse `openspec archive` under `lifecycle: status` and point at the status workflow +- [x] 5.4 Register `sync`, `ship` and `list --status` in the completion command registry +- [x] 5.5 Reword the generated spec skeleton's Purpose line, which claimed the spec was created by archiving + +## 6. Tests + +- [x] 6.1 Gate is green under `lifecycle: archive` regardless of any status field +- [x] 6.2 Gate fails on a shipped change whose delta is not folded, naming the capability +- [x] 6.3 Proposed changes are not gated and their deltas stay out of `specs/` +- [x] 6.4 Fold then re-check is green, and a second fold is a byte-identical no-op +- [x] 6.5 A named non-shipped change refuses to fold +- [x] 6.6 `ship` flips and folds in one step; re-ship is a no-op; refuses under archive mode +- [x] 6.7 Unreadable metadata produces the same conflict entry whether swept or named +- [x] 6.8 `archive` refuses under `lifecycle: status` +- [x] 6.9 `list` rejects an unknown `--status` value + +## 7. Release + +- [x] 7.1 Add a changeset describing the new experimental mode From 1a90b2e6367d6dc05c6b3dd730a1dc50b7371f7e Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Mon, 17 Aug 2026 14:51:36 +0300 Subject: [PATCH 06/13] fix(lifecycle): the gate fails closed on an unreadable tree; --json failures stay JSON Review findings from the PR: - sync swallowed every readdir error and reported no shipped changes, so an unreadable changes/ dir passed a gate whose whole claim is failing closed. Only ENOENT means 'no changes' now; anything else propagates. - the sync and ship CLI handlers dropped the --json flag on the error path, breaking the agent contract that a --json failure leaves exactly one JSON document on stdout. - the capability spec claimed ship is a no-op under archive mode; it refuses, as the implementation and its test have always done. Added the fail-closed requirement the first fix creates. Co-Authored-By: Claude Opus 5 --- .../specs/lifecycle-status-mode/spec.md | 22 +++++++-- src/cli/index.ts | 4 +- src/core/sync.ts | 11 ++++- test/core/list.test.ts | 40 +++++++++++++++ test/core/sync.test.ts | 49 +++++++++++++++++++ 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md index dcac613efa..3fb4037667 100644 --- a/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md +++ b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md @@ -90,12 +90,28 @@ A change's `.openspec.yaml` SHALL accept an optional `status` field with the val ### Requirement: Archive and status modes stay disjoint -`openspec archive` SHALL refuse to run in a project resolving to `lifecycle: status`, and the message SHALL point at the status-mode workflow. `openspec sync` and `openspec ship` SHALL report that there is nothing to do in a project resolving to `lifecycle: archive` and exit zero. Neither mode's commands SHALL act on a project that has selected the other. +Neither mode's commands SHALL act on a project that has selected the other. `openspec archive` SHALL refuse to run in a project resolving to `lifecycle: status`, and `openspec ship` SHALL refuse to run in a project resolving to `lifecycle: archive`; both messages SHALL name the resolved mode and point at the other mode's workflow. `openspec sync` SHALL instead report that there is nothing to gate under `lifecycle: archive` and exit zero, so that a repository-wide gate invocation is harmless in a project that has not opted in. #### Scenario: Archive refuses under status mode - **WHEN** a user runs `openspec archive add-auth` in a status-mode project - **THEN** the command fails with a message naming `lifecycle: status` and pointing at the status workflow, and no files are moved or modified -#### Scenario: Sync reports nothing to do under archive mode +#### Scenario: Ship refuses under archive mode +- **WHEN** a user runs `openspec ship add-auth` in a project resolving to `lifecycle: archive` +- **THEN** the command fails with a message naming `lifecycle: archive` and pointing at `openspec archive`, and no status field is written + +#### Scenario: Sync is a harmless no-op under archive mode - **WHEN** a user runs `openspec sync --check` in a project resolving to `lifecycle: archive` -- **THEN** the command reports that the project uses archive mode and exits zero +- **THEN** the command reports that the project uses archive mode, modifies nothing, and exits zero + +### Requirement: The gate fails closed when it cannot read the tree + +`openspec sync` SHALL treat an absent `openspec/changes/` directory as "no changes" and exit zero, but SHALL propagate any other error encountered while enumerating changes rather than reporting an empty result. A tree the gate cannot read SHALL NOT be reported as a passing tree. + +#### Scenario: Missing changes directory is not an error +- **WHEN** a user runs `openspec sync --check` in a status-mode project that has no `openspec/changes/` directory +- **THEN** the command reports no shipped changes to sync and exits zero + +#### Scenario: Unreadable changes directory fails rather than passing +- **WHEN** a user runs `openspec sync --check` in a status-mode project whose `openspec/changes/` path cannot be enumerated +- **THEN** the command fails rather than reporting a clean tree diff --git a/src/cli/index.ts b/src/cli/index.ts index b5750c8315..101c49762e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -466,7 +466,7 @@ program process.exitCode = 1; } } catch (error) { - failWithError(error); + failWithError(error, { enabled: options?.json, fallbackCode: 'sync_error' }); process.exit(1); } }); @@ -484,7 +484,7 @@ program process.exitCode = 1; } } catch (error) { - failWithError(error); + failWithError(error, { enabled: options?.json, fallbackCode: 'ship_error' }); process.exit(1); } }); diff --git a/src/core/sync.ts b/src/core/sync.ts index 6d44f71357..12f0399450 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -127,8 +127,15 @@ export class SyncCommand { let entries: Dirent[]; try { entries = await fs.readdir(changesDir, { withFileTypes: true }); - } catch { - return []; + } catch (err) { + // A missing changes/ dir means "no changes". Anything else (EACCES, + // ENOTDIR, ...) means the gate cannot see what it is meant to verify, + // and a gate that reports green on an unreadable tree is worse than no + // gate — so fail closed rather than claim there is nothing shipped. + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return []; + } + throw err; } const shipped: string[] = []; diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5178d1b49b..bc70f0295e 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -48,6 +48,46 @@ describe('ListCommand', () => { expect(logOutput).toEqual(['No active changes found.']); }); + it('filters changes by lifecycle status and reports an empty match honestly', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + for (const [name, status] of [['add-oauth', 'shipped'], ['add-billing', 'proposed']]) { + await fs.mkdir(path.join(changesDir, name), { recursive: true }); + await fs.writeFile( + path.join(changesDir, name, '.openspec.yaml'), + `schema: spec-driven\nstatus: ${status}\n` + ); + await fs.writeFile(path.join(changesDir, name, 'tasks.md'), '- [x] 1.1 done\n'); + } + + const listCommand = new ListCommand(); + + await listCommand.execute(tempDir, 'changes', { status: 'shipped' }); + expect(logOutput.join('\n')).toContain('add-oauth'); + expect(logOutput.join('\n')).not.toContain('add-billing'); + + logOutput = []; + await listCommand.execute(tempDir, 'changes', { status: 'proposed' }); + expect(logOutput.join('\n')).toContain('add-billing'); + expect(logOutput.join('\n')).not.toContain('add-oauth'); + }); + + it('keeps task status and lifecycle as separate JSON fields', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(path.join(changesDir, 'add-oauth'), { recursive: true }); + await fs.writeFile( + path.join(changesDir, 'add-oauth', '.openspec.yaml'), + 'schema: spec-driven\nstatus: shipped\n' + ); + await fs.writeFile(path.join(changesDir, 'add-oauth', 'tasks.md'), '- [x] 1.1 done\n'); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'changes', { json: true }); + + const payload = JSON.parse(logOutput.join('\n')); + expect(payload.changes[0].status).toBe('complete'); + expect(payload.changes[0].lifecycle).toBe('shipped'); + }); + it('rejects an unknown --status value instead of silently matching nothing', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(changesDir, { recursive: true }); diff --git a/test/core/sync.test.ts b/test/core/sync.test.ts index f54eb23531..413783c93a 100644 --- a/test/core/sync.test.ts +++ b/test/core/sync.test.ts @@ -109,6 +109,30 @@ describe('SyncCommand', () => { expect(refolded).toBe(folded); }); + it('fails closed when the changes directory cannot be enumerated', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + // A file where changes/ should be: readable project, unreadable tree. A + // gate that reports green here is worse than no gate. + await fs.rm(path.join(tempDir, 'openspec', 'changes'), { recursive: true, force: true }); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); + + await expect( + new SyncCommand().execute(undefined, tempDir, { check: true, silent: true }) + ).rejects.toThrow(); + }); + + it('treats an absent changes directory as no changes', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + await fs.rm(path.join(tempDir, 'openspec', 'changes'), { recursive: true, force: true }); + + const report = await new SyncCommand().execute(undefined, tempDir, { + check: true, + silent: true, + }); + expect(report.clean).toBe(true); + expect(report.changes).toEqual([]); + }); + it('silent mode emits nothing and still returns the report', async () => { await scaffold({ lifecycle: 'status', status: 'shipped' }); const report = await new SyncCommand().execute(undefined, tempDir, { @@ -205,4 +229,29 @@ describe('ArchiveCommand under lifecycle: status', () => { process.chdir(cwd); } }); + + it('refuses in JSON mode with a diagnostic and leaves the change in place', async () => { + const cwd = process.cwd(); + const logs: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.join(' ')); + }; + process.chdir(tempDir); + process.exitCode = undefined; + try { + await new ArchiveCommand().execute('add-oauth', { yes: true, json: true }); + } finally { + console.log = originalLog; + process.chdir(cwd); + } + + const payload = JSON.parse(logs.join('\n')); + expect(payload.archive).toBeNull(); + expect(payload.status?.[0]?.code).toBe('lifecycle_status_mode'); + expect(process.exitCode).toBe(1); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'add-oauth')) + ).resolves.not.toThrow(); + }); }); From ece59ed5cfd42fdba9942e2ef49d21671ba23266 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 11:17:26 +0200 Subject: [PATCH 07/13] feat(lifecycle): creation-date sharding + openspec migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes under `lifecycle: status` shard as changes/YYYY/MM/DD-/ — assigned at birth, immutable, so location encodes only the creation date and nothing ever moves. A shared discovery module enumerates both layouts (YYYY/MM dirs are shards to walk into; anything else is a change; the DD- prefix strips from the id), list/sync/ship resolve ids through it, and createChange shards new changes. Duplicate ids across shard dates are rejected at creation and on lookup. `openspec migrate` converts a legacy project one way: archived changes become changes/YYYY/MM/DD-/ with status: shipped (the folder date's meaning shifts from archival to creation — the closest surviving record), active changes shard by their created date as proposed, config gains lifecycle: status. Metadata edits are tolerant raw-YAML key writes, never strict-schema round-trips — a migration that drops fields it does not understand destroys history. --dry-run prints the plan. Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 16 ++ src/core/change-discovery.ts | 83 ++++++++++ src/core/completions/command-registry.ts | 10 ++ src/core/lifecycle-migrate.ts | 183 +++++++++++++++++++++++ src/core/list.ts | 36 ++--- src/core/sync.ts | 58 ++++--- src/utils/change-utils.ts | 21 ++- test/core/lifecycle-sharding.test.ts | 183 +++++++++++++++++++++++ 8 files changed, 534 insertions(+), 56 deletions(-) create mode 100644 src/core/change-discovery.ts create mode 100644 src/core/lifecycle-migrate.ts create mode 100644 test/core/lifecycle-sharding.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 101c49762e..6f19f04b9f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ import { import { ListCommand } from '../core/list.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { SyncCommand, ShipCommand } from '../core/sync.js'; +import { MigrateCommand } from '../core/lifecycle-migrate.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; @@ -489,6 +490,21 @@ program } }); +program + .command('migrate') + .description( + 'Migrate this project from `lifecycle: archive` to `lifecycle: status` (archived changes become shipped, sharded by date; nothing is deleted)' + ) + .option('--dry-run', 'Print the migration plan without writing anything') + .action(async (options?: { dryRun?: boolean }) => { + try { + await new MigrateCommand().execute('.', options ?? {}); + } catch (error) { + failWithError(error); + process.exit(1); + } + }); + registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); diff --git a/src/core/change-discovery.ts b/src/core/change-discovery.ts new file mode 100644 index 0000000000..b56e428379 --- /dev/null +++ b/src/core/change-discovery.ts @@ -0,0 +1,83 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface DiscoveredChange { + /** The change's id — the folder name minus any `DD-` shard prefix. */ + id: string; + /** Absolute path to the change directory. */ + dir: string; +} + +const YEAR_DIR = /^\d{4}$/; +const MONTH_DIR = /^\d{2}$/; +const DAY_PREFIX = /^\d{2}-/; + +/** + * Enumerate change directories under openspec/changes/, supporting both the + * flat layout (`changes//`) and the creation-date sharded layout used + * by `lifecycle: status` projects (`changes/YYYY/MM/DD-/`). + * + * The rule: `YYYY` and `MM` directories are shards to walk into; any other + * directory is a change. Location encodes only the creation date — fixed at + * birth — so nothing here ever needs to know a change's lifecycle state. + * `archive/` is excluded at the top level, matching the flat layout's + * long-standing behavior. + */ +export async function discoverChanges(changesDir: string): Promise { + const found: DiscoveredChange[] = []; + + async function walkShard(dir: string, depth: number): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + // A missing changes/ dir means "no changes"; anything else (ENOTDIR, + // EACCES, ...) is a malformed root the caller must hear about rather + // than mistake for an empty project. + const code = (error as NodeJS.ErrnoException)?.code; + if (depth === 0 && code !== 'ENOENT') { + throw error; + } + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = path.join(dir, entry.name); + if (depth === 0 && entry.name === 'archive') continue; + if (depth === 0 && YEAR_DIR.test(entry.name)) { + await walkShard(full, 1); + } else if (depth === 1 && MONTH_DIR.test(entry.name)) { + await walkShard(full, 2); + } else { + const id = depth === 2 ? entry.name.replace(DAY_PREFIX, '') : entry.name; + found.push({ id, dir: full }); + } + } + } + + await walkShard(changesDir, 0); + return found.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** + * Resolve a change id to its directory in either layout. Throws when the id + * is ambiguous — two shard dates carrying the same name — because guessing + * would silently act on the wrong change. + */ +export async function resolveChangeDir(changesDir: string, id: string): Promise { + const flat = path.join(changesDir, id); + try { + const stat = await fs.stat(flat); + if (stat.isDirectory()) return flat; + } catch { + // fall through to sharded lookup + } + + const matches = (await discoverChanges(changesDir)).filter((c) => c.id === id); + if (matches.length > 1) { + throw new Error( + `Change '${id}' is ambiguous: ${matches.map((m) => path.relative(changesDir, m.dir)).join(', ')}` + ); + } + return matches[0]?.dir ?? null; +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 6e1afcdb2b..4fcb9f19ab 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -212,6 +212,16 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'migrate', + description: 'Migrate from lifecycle: archive to lifecycle: status', + flags: [ + { + name: 'dry-run', + description: 'Print the migration plan without writing anything', + }, + ], + }, { name: 'status', description: 'Display artifact completion status for a change', diff --git a/src/core/lifecycle-migrate.ts b/src/core/lifecycle-migrate.ts new file mode 100644 index 0000000000..fb26f9e1ed --- /dev/null +++ b/src/core/lifecycle-migrate.ts @@ -0,0 +1,183 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { resolveLifecycle } from './project-config.js'; + +const ARCHIVE_DIR_NAME = /^(\d{4})-(\d{2})-(\d{2})-(.+)$/; +const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +export interface MigrateOptions { + dryRun?: boolean; +} + +interface PlannedMove { + from: string; + to: string; + id: string; + status: 'proposed' | 'shipped'; + created: string; +} + +/** + * One-way migration from `lifecycle: archive` to `lifecycle: status`. + * + * - `changes/archive/YYYY-MM-DD-/` → `changes/YYYY/MM/DD-/` with + * `status: shipped`. The folder date's meaning shifts from archival to + * creation — the closest surviving record, and explicitly documented. + * - `changes//` (active) → sharded by its metadata `created` date + * (today when absent) with `status: proposed` unless a status already exists. + * - `openspec/config.yaml` gains `lifecycle: status`. + * + * Metadata is edited tolerantly — raw YAML keys, no strict schema round-trip — + * because legacy archived changes predate today's metadata contract and a + * migration that drops fields it does not understand is a migration that + * destroys history. + */ +export class MigrateCommand { + async execute(targetPath: string = '.', options: MigrateOptions = {}): Promise { + if (resolveLifecycle(targetPath) === 'status') { + console.log('Already on `lifecycle: status` — nothing to migrate.'); + return; + } + + const openspecDir = path.join(targetPath, 'openspec'); + const changesDir = path.join(openspecDir, 'changes'); + const archiveDir = path.join(changesDir, 'archive'); + const today = new Date().toISOString().slice(0, 10); + + const moves: PlannedMove[] = []; + + for (const entry of await this.dirs(archiveDir)) { + const match = ARCHIVE_DIR_NAME.exec(entry); + const [year, month, day, id] = match + ? [match[1], match[2], match[3], match[4]] + : [...today.split('-'), entry] as [string, string, string, string]; + moves.push({ + from: path.join(archiveDir, entry), + to: path.join(changesDir, year, month, `${day}-${id}`), + id, + status: 'shipped', + created: `${year}-${month}-${day}`, + }); + } + + for (const entry of await this.dirs(changesDir)) { + if (entry === 'archive') continue; + const from = path.join(changesDir, entry); + const meta = await this.readRawMetadata(from); + const created = DATE.test(String(meta?.created ?? '')) ? String(meta?.created) : today; + const [year, month, day] = created.split('-'); + moves.push({ + from, + to: path.join(changesDir, year, month, `${day}-${entry}`), + id: entry, + status: meta?.status === 'shipped' ? 'shipped' : 'proposed', + created, + }); + } + + if (moves.length === 0) { + console.log('No changes to migrate.'); + } + + for (const move of moves) { + console.log( + ` ${move.status === 'shipped' ? '✓' : '…'} ${move.id} → ${path.relative(targetPath, move.to)} [${move.status}]` + ); + if (options.dryRun) continue; + await fs.mkdir(path.dirname(move.to), { recursive: true }); + await fs.rename(move.from, move.to); + await this.stampMetadata(move, targetPath); + } + + if (options.dryRun) { + console.log('Dry run — nothing written.'); + return; + } + + if (!(await this.dirs(archiveDir)).length) { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + await this.setLifecycle(openspecDir); + + console.log('Migrated to `lifecycle: status`.'); + console.log( + 'Verify with `openspec sync --check`. Historical changes superseded by later edits to the same requirement may report unfolded — that is the base-snapshot gap, not a migration error; resolve by reviewing the named capability.' + ); + } + + private async dirs(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } + } + + private async readRawMetadata(changeDir: string): Promise | null> { + try { + const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); + const parsed = parseYaml(raw); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } + } + + private async stampMetadata(move: PlannedMove, targetPath: string): Promise { + const existing = (await this.readRawMetadata(move.to)) ?? {}; + const schema = existing.schema ?? (await this.projectSchema(targetPath)); + const stamped = { + ...existing, + schema, + created: existing.created ?? move.created, + status: move.status, + }; + await fs.writeFile( + path.join(move.to, '.openspec.yaml'), + stringifyYaml(stamped), + 'utf-8' + ); + } + + private async projectSchema(targetPath: string): Promise { + const raw = await this.readRawConfig(targetPath); + const schema = raw?.schema; + return typeof schema === 'string' && schema.length > 0 ? schema : 'spec-driven'; + } + + private async readRawConfig(targetPath: string): Promise | null> { + for (const name of ['config.yaml', 'config.yml']) { + try { + const raw = await fs.readFile(path.join(targetPath, 'openspec', name), 'utf-8'); + const parsed = parseYaml(raw); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + continue; + } + } + return null; + } + + private async setLifecycle(openspecDir: string): Promise { + for (const name of ['config.yaml', 'config.yml']) { + const file = path.join(openspecDir, name); + try { + const raw = await fs.readFile(file, 'utf-8'); + const updated = /^lifecycle:.*$/m.test(raw) + ? raw.replace(/^lifecycle:.*$/m, 'lifecycle: status') + : `${raw.trimEnd()}\nlifecycle: status\n`; + await fs.writeFile(file, updated, 'utf-8'); + return; + } catch { + continue; + } + } + await fs.writeFile( + path.join(openspecDir, 'config.yaml'), + 'schema: spec-driven\nlifecycle: status\n', + 'utf-8' + ); + } +} diff --git a/src/core/list.ts b/src/core/list.ts index da5d76e2a6..478d83e88f 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,8 +1,9 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; -import { readFileSync, type Dirent } from 'fs'; +import { readFileSync } from 'fs'; import { parse as parseYaml } from 'yaml'; +import { discoverChanges } from './change-discovery.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; import { discoverSpecFiles } from '../utils/spec-discovery.js'; @@ -48,15 +49,6 @@ function isMissingPathError(error: unknown): boolean { ); } -async function readChangeDirectoryEntries(changesDir: string): Promise { - try { - return await fs.readdir(changesDir, { withFileTypes: true }); - } catch (error) { - if (isMissingPathError(error)) return []; - throw error; - } -} - /** * Get the most recent modification time of any file in a directory (recursive). * Falls back to the directory's own mtime if no files are found. @@ -127,13 +119,11 @@ export class ListCommand { if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - // Get all directories in changes (excluding archive) - const entries = await readChangeDirectoryEntries(changesDir); - const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') - .map(entry => entry.name); + // Both layouts: flat (changes/) and creation-date sharded + // (changes/YYYY/MM/DD-), enumerated by the shared discovery. + const discovered = await discoverChanges(changesDir); - if (changeDirs.length === 0) { + if (discovered.length === 0) { if (json) { console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2)); } else { @@ -145,16 +135,18 @@ export class ListCommand { // Collect information about each change const changes: ChangeInfo[] = []; - for (const changeDir of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); - const changePath = path.join(changesDir, changeDir); - const lastModified = await getLastModified(changePath); - const lifecycle = readLifecycleStatus(changePath); + for (const change of discovered) { + // Task-progress helpers join changesDir with the segment they get, so + // sharded changes pass their relative path while displaying the id. + const relPath = path.relative(changesDir, change.dir); + const progress = await getTaskProgressForChange(changesDir, relPath, targetPath); + const lastModified = await getLastModified(change.dir); + const lifecycle = readLifecycleStatus(change.dir); if (options.status && lifecycle !== options.status) { continue; } changes.push({ - name: changeDir, + name: change.id, completedTasks: progress.completed, totalTasks: progress.total, lastModified, diff --git a/src/core/sync.ts b/src/core/sync.ts index 12f0399450..0ea4c8bbdc 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; -import type { Dirent } from 'fs'; +import { discoverChanges, resolveChangeDir } from './change-discovery.js'; import { findSpecUpdates, buildUpdatedSpec, @@ -83,12 +83,18 @@ export class SyncCommand { const changesDir = path.join(targetPath, 'openspec', 'changes'); const specsDir = path.join(targetPath, 'openspec', 'specs'); - const candidates = changeName - ? [changeName] - : await this.shippedChanges(changesDir, targetPath, report); + let candidates: Array<{ id: string; dir: string }>; + if (changeName) { + const dir = await resolveChangeDir(changesDir, changeName); + if (dir === null) { + throw new Error(`Change '${changeName}' not found in openspec/changes/`); + } + candidates = [{ id: changeName, dir }]; + } else { + candidates = await this.shippedChanges(changesDir, targetPath, report); + } - for (const name of candidates) { - const changeDir = path.join(changesDir, name); + for (const { id: name, dir: changeDir } of candidates) { const state = await this.evaluate(name, changeDir, specsDir, targetPath, options); if (state === null) { continue; @@ -123,36 +129,22 @@ export class SyncCommand { changesDir: string, projectRoot: string, report: SyncReport - ): Promise { - let entries: Dirent[]; - try { - entries = await fs.readdir(changesDir, { withFileTypes: true }); - } catch (err) { - // A missing changes/ dir means "no changes". Anything else (EACCES, - // ENOTDIR, ...) means the gate cannot see what it is meant to verify, - // and a gate that reports green on an unreadable tree is worse than no - // gate — so fail closed rather than claim there is nothing shipped. - if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { - return []; - } - throw err; - } - - const shipped: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === 'archive') { - continue; - } + ): Promise> { + // Discovery owns the fail-closed rule: a missing changes/ dir means "no + // changes", but any other error propagates, because a gate that reports + // green on a tree it could not read is worse than no gate. + const shipped: Array<{ id: string; dir: string }> = []; + for (const change of await discoverChanges(changesDir)) { try { - const metadata = readChangeMetadata(path.join(changesDir, entry.name), projectRoot); + const metadata = readChangeMetadata(change.dir, projectRoot); if (metadata?.status === 'shipped') { - shipped.push(entry.name); + shipped.push(change); } } catch (err) { // Unreadable metadata cannot prove the change is NOT shipped, so the // gate fails closed: report it rather than skip it. report.changes.push({ - change: entry.name, + change: change.id, state: 'conflict', pending: [], error: err instanceof ChangeMetadataError ? err.message : String(err), @@ -275,7 +267,13 @@ export class ShipCommand { ); } - const changeDir = path.join(targetPath, 'openspec', 'changes', changeName); + const changeDir = await resolveChangeDir( + path.join(targetPath, 'openspec', 'changes'), + changeName + ); + if (changeDir === null) { + throw new Error(`Change '${changeName}' not found in openspec/changes/`); + } const metadata = readChangeMetadata(changeDir, targetPath); if (!metadata) { throw new Error( diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 85dc7caab7..bd119d82d2 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -3,6 +3,7 @@ import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; import { formatLocalDate } from './date.js'; import { readProjectConfig, resolveLifecycle } from '../core/project-config.js'; +import { discoverChanges } from '../core/change-discovery.js'; import { isKebabId } from '../core/id.js'; import type { ChangeMetadata } from '../core/change-metadata/index.js'; @@ -157,13 +158,25 @@ export async function createChange( // Validate the resolved schema validateSchemaName(schemaName, projectRoot); - // Build the change directory path - const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); - - // Check if change already exists + // Build the change directory path. Under `lifecycle: status` changes shard + // by creation date — changes/YYYY/MM/DD-/ — assigned at birth and + // immutable, so location never encodes lifecycle state and nothing moves. + const changesRoot = options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'); + const created = formatLocalDate(); + const [year, month, day] = created.split('-'); + const changeDir = + resolveLifecycle(projectRoot) === 'status' + ? path.join(changesRoot, year, month, `${day}-${name}`) + : path.join(changesRoot, name); + + // Check if change already exists — under sharding, by id anywhere, since two + // shard dates carrying the same name would make the id ambiguous forever. if (await FileSystemUtils.directoryExists(changeDir)) { throw new Error(`Change '${name}' already exists at ${changeDir}`); } + if ((await discoverChanges(changesRoot)).some((c) => c.id === name)) { + throw new Error(`Change '${name}' already exists in openspec/changes/`); + } // Creating a change may scaffold or complete the root itself (an // implicit root, or a config-only/incomplete clone). Never leave a diff --git a/test/core/lifecycle-sharding.test.ts b/test/core/lifecycle-sharding.test.ts new file mode 100644 index 0000000000..ea8fe92f27 --- /dev/null +++ b/test/core/lifecycle-sharding.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { discoverChanges, resolveChangeDir } from '../../src/core/change-discovery.js'; +import { MigrateCommand } from '../../src/core/lifecycle-migrate.js'; +import { SyncCommand } from '../../src/core/sync.js'; +import { createChange } from '../../src/utils/change-utils.js'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +const DELTA = `# Auth - Changes + +## ADDED Requirements + +### Requirement: Operator authentication + +The system SHALL authenticate operators. + +#### Scenario: Valid token +- **WHEN** a valid token is presented +- **THEN** the request is accepted +`; + +describe('change discovery across layouts', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-shard-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('finds flat, sharded, and mixed changes; strips the day prefix; skips archive', async () => { + const changes = path.join(tempDir, 'changes'); + await fs.mkdir(path.join(changes, 'flat-change'), { recursive: true }); + await fs.mkdir(path.join(changes, '2026', '03', '15-old-change'), { recursive: true }); + await fs.mkdir(path.join(changes, 'archive', '2026-01-01-buried'), { recursive: true }); + + const found = await discoverChanges(changes); + expect(found.map((c) => c.id)).toEqual(['flat-change', 'old-change']); + + expect(await resolveChangeDir(changes, 'old-change')).toBe( + path.join(changes, '2026', '03', '15-old-change') + ); + expect(await resolveChangeDir(changes, 'flat-change')).toBe( + path.join(changes, 'flat-change') + ); + expect(await resolveChangeDir(changes, 'nope')).toBeNull(); + }); + + it('rejects an ambiguous id present under two shard dates', async () => { + const changes = path.join(tempDir, 'changes'); + await fs.mkdir(path.join(changes, '2026', '03', '15-dupe'), { recursive: true }); + await fs.mkdir(path.join(changes, '2026', '04', '01-dupe'), { recursive: true }); + + await expect(resolveChangeDir(changes, 'dupe')).rejects.toThrow(/ambiguous/); + }); + + it('createChange shards by creation date under lifecycle: status', async () => { + await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\nlifecycle: status\n' + ); + + const result = await createChange(tempDir, 'fresh-change'); + const rel = path.relative(path.join(tempDir, 'openspec', 'changes'), result.changeDir); + expect(rel).toMatch(/^\d{4}[/\\]\d{2}[/\\]\d{2}-fresh-change$/); + const metadata = await fs.readFile(path.join(result.changeDir, '.openspec.yaml'), 'utf-8'); + expect(metadata).toContain('status: proposed'); + }); +}); + +describe('MigrateCommand', () => { + let tempDir: string; + let logs: string[]; + const originalLog = console.log; + const originalExitCode = process.exitCode; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-migrate-test-')); + logs = []; + console.log = (...args: unknown[]) => { + logs.push(args.join(' ')); + }; + process.exitCode = undefined; + + const openspec = path.join(tempDir, 'openspec'); + // Legacy layout: one archived change whose fold sits in specs/ exactly as + // archive left it. Hand-writing the folded spec fails the byte-identity + // check on whitespace canon, so generate it with the same engine archive + // uses, via a scratch status-mode project. + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-migrate-scratch-')); + await fs.mkdir(path.join(scratch, 'openspec', 'changes', 'seed', 'specs', 'auth'), { + recursive: true, + }); + await fs.mkdir(path.join(scratch, 'openspec', 'specs'), { recursive: true }); + await fs.writeFile( + path.join(scratch, 'openspec', 'config.yaml'), + 'schema: spec-driven\nlifecycle: status\n' + ); + await fs.writeFile( + path.join(scratch, 'openspec', 'changes', 'seed', '.openspec.yaml'), + 'schema: spec-driven\nstatus: shipped\n' + ); + await fs.writeFile( + path.join(scratch, 'openspec', 'changes', 'seed', 'specs', 'auth', 'spec.md'), + DELTA + ); + await new SyncCommand().execute('seed', scratch, { json: true }); + const foldedSpec = await fs.readFile( + path.join(scratch, 'openspec', 'specs', 'auth', 'spec.md'), + 'utf-8' + ); + await fs.rm(scratch, { recursive: true, force: true }); + + await fs.mkdir(path.join(openspec, 'specs', 'auth'), { recursive: true }); + await fs.writeFile(path.join(openspec, 'specs', 'auth', 'spec.md'), foldedSpec); + await fs.writeFile(path.join(openspec, 'config.yaml'), 'schema: spec-driven\n'); + + const archived = path.join(openspec, 'changes', 'archive', '2026-03-15-add-user-auth'); + await fs.mkdir(path.join(archived, 'specs', 'auth'), { recursive: true }); + await fs.writeFile(path.join(archived, 'specs', 'auth', 'spec.md'), DELTA); + + const active = path.join(openspec, 'changes', 'batch-upload'); + await fs.mkdir(active, { recursive: true }); + await fs.writeFile( + path.join(active, '.openspec.yaml'), + 'schema: spec-driven\ncreated: 2026-08-01\n' + ); + }); + + afterEach(async () => { + console.log = originalLog; + process.exitCode = originalExitCode; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('dry run plans without writing', async () => { + await new MigrateCommand().execute(tempDir, { dryRun: true }); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'archive', '2026-03-15-add-user-auth')) + ).resolves.not.toThrow(); + const config = await fs.readFile(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8'); + expect(config).not.toContain('lifecycle: status'); + }); + + it('migrates both eras, stamps statuses, flips the config, and the gate is green', async () => { + await new MigrateCommand().execute(tempDir, {}); + + const shipped = path.join( + tempDir, 'openspec', 'changes', '2026', '03', '15-add-user-auth' + ); + const shippedMeta = await fs.readFile(path.join(shipped, '.openspec.yaml'), 'utf-8'); + expect(shippedMeta).toContain('status: shipped'); + expect(shippedMeta).toContain('created: 2026-03-15'); + + const proposed = path.join(tempDir, 'openspec', 'changes', '2026', '08', '01-batch-upload'); + const proposedMeta = await fs.readFile(path.join(proposed, '.openspec.yaml'), 'utf-8'); + expect(proposedMeta).toContain('status: proposed'); + + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'archive')) + ).rejects.toThrow(); + + const config = await fs.readFile(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8'); + expect(config).toContain('lifecycle: status'); + + // The migrated shipped change re-verifies: its delta re-applied to the + // already-folded spec is a no-op, so the gate passes. + process.exitCode = undefined; + await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(process.exitCode).toBeUndefined(); + }); + + it('is a no-op on an already-migrated project', async () => { + await new MigrateCommand().execute(tempDir, {}); + logs = []; + await new MigrateCommand().execute(tempDir, {}); + expect(logs.join('\n')).toContain('Already on'); + }); +}); From 2afc31e359b847f1222bf6ec6d25387de0c72f18 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 11:48:26 +0200 Subject: [PATCH 08/13] =?UTF-8?q?feat(lifecycle):=20migrate=20--to=20archi?= =?UTF-8?q?ve=20=E2=80=94=20the=20experiment=20is=20leaveable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reversal moves only bookkeeping, like the forward direction: shipped changes return to changes/archive/YYYY-MM-DD-/ (dates from the shard path), proposed changes return flat, the status key is stripped (under archive mode, location is the state), empty shard dirs prune, and the config line disappears. No spec text changes in either direction — archive-mode specs/ is folded shipped reality, which is exactly what status-mode maintains, so the round-trip is a pure relayout (covered by a round-trip test). Refuses while any shipped change has unfolded deltas: the archive layout asserts folds that must actually exist. One honest asymmetry, printed on completion: changes shipped under status mode carry their creation date into the archive folder name, where convention reads an archival date. This is the exit ramp the experimental flag's exit criteria require — if the mode is ever removed rather than graduated, --to archive is how projects return to supported ground. Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 11 +- src/core/completions/command-registry.ts | 8 +- src/core/lifecycle-migrate.ts | 187 ++++++++++++++++++----- test/core/lifecycle-sharding.test.ts | 60 +++++++- 4 files changed, 223 insertions(+), 43 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 6f19f04b9f..c0fa58b522 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -493,12 +493,17 @@ program program .command('migrate') .description( - 'Migrate this project from `lifecycle: archive` to `lifecycle: status` (archived changes become shipped, sharded by date; nothing is deleted)' + 'Migrate this project between lifecycle modes (default: to `lifecycle: status`). Both directions move only bookkeeping; nothing is deleted and no spec text changes' ) + .option('--to ', 'Target lifecycle mode: "status" (default) or "archive"', 'status') .option('--dry-run', 'Print the migration plan without writing anything') - .action(async (options?: { dryRun?: boolean }) => { + .action(async (options?: { to?: string; dryRun?: boolean }) => { try { - await new MigrateCommand().execute('.', options ?? {}); + const to = options?.to ?? 'status'; + if (to !== 'status' && to !== 'archive') { + throw new Error(`Unknown lifecycle mode '${to}' (expected 'status' or 'archive')`); + } + await new MigrateCommand().execute('.', { to, dryRun: options?.dryRun }); } catch (error) { failWithError(error); process.exit(1); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 4fcb9f19ab..d7705f6db4 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -214,8 +214,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'migrate', - description: 'Migrate from lifecycle: archive to lifecycle: status', + description: 'Migrate between lifecycle modes (default: to lifecycle: status)', flags: [ + { + name: 'to', + description: 'Target lifecycle mode', + takesValue: true, + values: ['status', 'archive'], + }, { name: 'dry-run', description: 'Print the migration plan without writing anything', diff --git a/src/core/lifecycle-migrate.ts b/src/core/lifecycle-migrate.ts index fb26f9e1ed..cdb4d16528 100644 --- a/src/core/lifecycle-migrate.ts +++ b/src/core/lifecycle-migrate.ts @@ -1,13 +1,17 @@ import { promises as fs } from 'fs'; import path from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { resolveLifecycle } from './project-config.js'; +import { resolveLifecycle, type LifecycleMode } from './project-config.js'; +import { discoverChanges } from './change-discovery.js'; +import { SyncCommand } from './sync.js'; const ARCHIVE_DIR_NAME = /^(\d{4})-(\d{2})-(\d{2})-(.+)$/; +const SHARD_PATH = /^(\d{4})[/\\](\d{2})[/\\](\d{2})-(.+)$/; const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; export interface MigrateOptions { dryRun?: boolean; + to?: LifecycleMode; } interface PlannedMove { @@ -19,27 +23,43 @@ interface PlannedMove { } /** - * One-way migration from `lifecycle: archive` to `lifecycle: status`. + * Migration between lifecycle modes, in both directions. Neither direction + * touches spec text: archive-mode's `specs/` is folded shipped reality, which + * is exactly what status-mode maintains, so only bookkeeping moves — renames + * and a config line. That symmetry is what makes the experiment leaveable. * - * - `changes/archive/YYYY-MM-DD-/` → `changes/YYYY/MM/DD-/` with - * `status: shipped`. The folder date's meaning shifts from archival to - * creation — the closest surviving record, and explicitly documented. - * - `changes//` (active) → sharded by its metadata `created` date - * (today when absent) with `status: proposed` unless a status already exists. - * - `openspec/config.yaml` gains `lifecycle: status`. + * → status: `changes/archive/YYYY-MM-DD-/` becomes + * `changes/YYYY/MM/DD-/` with `status: shipped` (the folder date's + * meaning shifts from archival to creation — the closest surviving record); + * active flat changes shard by their `created` date as `status: proposed`. + * + * → archive: shipped changes return to `changes/archive/YYYY-MM-DD-/` + * (dates from the shard path), proposed changes return to flat + * `changes//`, and the `status` key is stripped — under archive mode, + * location is the state. Refuses while any shipped change has unfolded + * deltas: the archive layout asserts folds that must actually exist. * * Metadata is edited tolerantly — raw YAML keys, no strict schema round-trip — - * because legacy archived changes predate today's metadata contract and a - * migration that drops fields it does not understand is a migration that - * destroys history. + * because legacy changes predate today's metadata contract and a migration + * that drops fields it does not understand is a migration that destroys + * history. */ export class MigrateCommand { async execute(targetPath: string = '.', options: MigrateOptions = {}): Promise { - if (resolveLifecycle(targetPath) === 'status') { - console.log('Already on `lifecycle: status` — nothing to migrate.'); + const target = options.to ?? 'status'; + const current = resolveLifecycle(targetPath); + if (current === target) { + console.log(`Already on \`lifecycle: ${target}\` — nothing to migrate.`); return; } + if (target === 'status') { + await this.toStatus(targetPath, options); + } else { + await this.toArchive(targetPath, options); + } + } + private async toStatus(targetPath: string, options: MigrateOptions): Promise { const openspecDir = path.join(targetPath, 'openspec'); const changesDir = path.join(openspecDir, 'changes'); const archiveDir = path.join(changesDir, 'archive'); @@ -51,7 +71,7 @@ export class MigrateCommand { const match = ARCHIVE_DIR_NAME.exec(entry); const [year, month, day, id] = match ? [match[1], match[2], match[3], match[4]] - : [...today.split('-'), entry] as [string, string, string, string]; + : ([...today.split('-'), entry] as [string, string, string, string]); moves.push({ from: path.join(archiveDir, entry), to: path.join(changesDir, year, month, `${day}-${id}`), @@ -76,6 +96,81 @@ export class MigrateCommand { }); } + await this.apply(moves, targetPath, options, async () => { + if (!(await this.dirs(archiveDir)).length) { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + await this.setLifecycle(openspecDir, 'status'); + console.log('Migrated to `lifecycle: status`.'); + console.log( + 'Verify with `openspec sync --check`. Historical changes superseded by later edits to the same requirement may report unfolded — that is the base-snapshot gap, not a migration error; resolve by reviewing the named capability.' + ); + }); + } + + private async toArchive(targetPath: string, options: MigrateOptions): Promise { + const openspecDir = path.join(targetPath, 'openspec'); + const changesDir = path.join(openspecDir, 'changes'); + const archiveDir = path.join(changesDir, 'archive'); + const today = new Date().toISOString().slice(0, 10); + + // The archive layout asserts every archived change's fold happened, so a + // shipped-but-unfolded change must be folded (or unshipped) first. Reuse + // the gate itself rather than a parallel reimplementation of its verdict. + const exitBefore = process.exitCode; + const silencedLog = console.log; + console.log = () => {}; + try { + await new SyncCommand().execute(undefined, targetPath, { check: true, json: true }); + } finally { + console.log = silencedLog; + } + const gateRed = process.exitCode === 1 && exitBefore !== 1; + process.exitCode = exitBefore; + if (gateRed) { + throw new Error( + 'Refusing to migrate to `lifecycle: archive`: a shipped change has unfolded deltas (the archive layout would assert a fold that never happened). Run `openspec sync` first.' + ); + } + + const moves: PlannedMove[] = []; + for (const change of await discoverChanges(changesDir)) { + const meta = await this.readRawMetadata(change.dir); + const rel = path.relative(changesDir, change.dir); + const shard = SHARD_PATH.exec(rel); + const created = shard + ? `${shard[1]}-${shard[2]}-${shard[3]}` + : DATE.test(String(meta?.created ?? '')) + ? String(meta?.created) + : today; + const shipped = meta?.status === 'shipped'; + moves.push({ + from: change.dir, + to: shipped + ? path.join(archiveDir, `${created}-${change.id}`) + : path.join(changesDir, change.id), + id: change.id, + status: shipped ? 'shipped' : 'proposed', + created, + }); + } + + await this.apply(moves, targetPath, options, async () => { + await this.pruneShardDirs(changesDir); + await this.setLifecycle(openspecDir, 'archive'); + console.log('Migrated to `lifecycle: archive`.'); + console.log( + 'Note: changes shipped under status mode carry their creation date in the archive folder name, where convention reads an archival date.' + ); + }); + } + + private async apply( + moves: PlannedMove[], + targetPath: string, + options: MigrateOptions, + finish: () => Promise + ): Promise { if (moves.length === 0) { console.log('No changes to migrate.'); } @@ -85,25 +180,17 @@ export class MigrateCommand { ` ${move.status === 'shipped' ? '✓' : '…'} ${move.id} → ${path.relative(targetPath, move.to)} [${move.status}]` ); if (options.dryRun) continue; + if (move.from === move.to) continue; await fs.mkdir(path.dirname(move.to), { recursive: true }); await fs.rename(move.from, move.to); - await this.stampMetadata(move, targetPath); + await this.stampMetadata(move, targetPath, options.to ?? 'status'); } if (options.dryRun) { console.log('Dry run — nothing written.'); return; } - - if (!(await this.dirs(archiveDir)).length) { - await fs.rm(archiveDir, { recursive: true, force: true }); - } - await this.setLifecycle(openspecDir); - - console.log('Migrated to `lifecycle: status`.'); - console.log( - 'Verify with `openspec sync --check`. Historical changes superseded by later edits to the same requirement may report unfolded — that is the base-snapshot gap, not a migration error; resolve by reviewing the named capability.' - ); + await finish(); } private async dirs(dir: string): Promise { @@ -125,20 +212,38 @@ export class MigrateCommand { } } - private async stampMetadata(move: PlannedMove, targetPath: string): Promise { + private async stampMetadata( + move: PlannedMove, + targetPath: string, + target: LifecycleMode + ): Promise { const existing = (await this.readRawMetadata(move.to)) ?? {}; const schema = existing.schema ?? (await this.projectSchema(targetPath)); - const stamped = { + const stamped: Record = { ...existing, schema, created: existing.created ?? move.created, - status: move.status, }; - await fs.writeFile( - path.join(move.to, '.openspec.yaml'), - stringifyYaml(stamped), - 'utf-8' - ); + if (target === 'status') { + stamped.status = move.status; + } else { + // Under archive mode location is the state; a lingering status field + // would be a second, contradicting record. + delete stamped.status; + } + await fs.writeFile(path.join(move.to, '.openspec.yaml'), stringifyYaml(stamped), 'utf-8'); + } + + /** Remove now-empty YYYY/MM shard directories after a reverse migration. */ + private async pruneShardDirs(changesDir: string): Promise { + for (const year of await this.dirs(changesDir)) { + if (!/^\d{4}$/.test(year)) continue; + const yearDir = path.join(changesDir, year); + for (const month of await this.dirs(yearDir)) { + await fs.rmdir(path.join(yearDir, month)).catch(() => {}); + } + await fs.rmdir(yearDir).catch(() => {}); + } } private async projectSchema(targetPath: string): Promise { @@ -160,14 +265,20 @@ export class MigrateCommand { return null; } - private async setLifecycle(openspecDir: string): Promise { + private async setLifecycle(openspecDir: string, mode: LifecycleMode): Promise { for (const name of ['config.yaml', 'config.yml']) { const file = path.join(openspecDir, name); try { const raw = await fs.readFile(file, 'utf-8'); - const updated = /^lifecycle:.*$/m.test(raw) - ? raw.replace(/^lifecycle:.*$/m, 'lifecycle: status') - : `${raw.trimEnd()}\nlifecycle: status\n`; + let updated: string; + if (mode === 'archive') { + // The default mode needs no line at all. + updated = raw.replace(/^lifecycle:.*\n?/m, ''); + } else { + updated = /^lifecycle:.*$/m.test(raw) + ? raw.replace(/^lifecycle:.*$/m, 'lifecycle: status') + : `${raw.trimEnd()}\nlifecycle: status\n`; + } await fs.writeFile(file, updated, 'utf-8'); return; } catch { @@ -176,7 +287,7 @@ export class MigrateCommand { } await fs.writeFile( path.join(openspecDir, 'config.yaml'), - 'schema: spec-driven\nlifecycle: status\n', + mode === 'status' ? 'schema: spec-driven\nlifecycle: status\n' : 'schema: spec-driven\n', 'utf-8' ); } diff --git a/test/core/lifecycle-sharding.test.ts b/test/core/lifecycle-sharding.test.ts index ea8fe92f27..2c021f7136 100644 --- a/test/core/lifecycle-sharding.test.ts +++ b/test/core/lifecycle-sharding.test.ts @@ -124,11 +124,26 @@ describe('MigrateCommand', () => { await fs.writeFile(path.join(archived, 'specs', 'auth', 'spec.md'), DELTA); const active = path.join(openspec, 'changes', 'batch-upload'); - await fs.mkdir(active, { recursive: true }); + await fs.mkdir(path.join(active, 'specs', 'beacons'), { recursive: true }); await fs.writeFile( path.join(active, '.openspec.yaml'), 'schema: spec-driven\ncreated: 2026-08-01\n' ); + await fs.writeFile( + path.join(active, 'specs', 'beacons', 'spec.md'), + `# Beacons - Changes + +## ADDED Requirements + +### Requirement: Batched upload + +The system SHALL accept batched readings. + +#### Scenario: Replay +- **WHEN** a gateway replays a batch +- **THEN** all readings are accepted +` + ); }); afterEach(async () => { @@ -180,4 +195,47 @@ describe('MigrateCommand', () => { await new MigrateCommand().execute(tempDir, {}); expect(logs.join('\n')).toContain('Already on'); }); + + it('round-trips: migrate → migrate --to archive restores the legacy layout', async () => { + await new MigrateCommand().execute(tempDir, {}); + await new MigrateCommand().execute(tempDir, { to: 'archive' }); + + // Shipped change back in archive/ under its date; active change flat. + const archived = path.join( + tempDir, 'openspec', 'changes', 'archive', '2026-03-15-add-user-auth' + ); + await expect(fs.access(archived)).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'batch-upload')) + ).resolves.not.toThrow(); + + // Location is the state again: no status key survives. + const archivedMeta = await fs.readFile(path.join(archived, '.openspec.yaml'), 'utf-8'); + expect(archivedMeta).not.toContain('status:'); + expect(archivedMeta).toContain('created: 2026-03-15'); + + // Shard dirs pruned; config back to the default mode. + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', '2026')) + ).rejects.toThrow(); + const config = await fs.readFile(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8'); + expect(config).not.toContain('lifecycle:'); + }); + + it('refuses --to archive while a shipped change has unfolded deltas', async () => { + await new MigrateCommand().execute(tempDir, {}); + // Flip the proposed change to shipped WITHOUT folding: gate red. + const meta = path.join( + tempDir, 'openspec', 'changes', '2026', '08', '01-batch-upload', '.openspec.yaml' + ); + await fs.writeFile( + meta, + (await fs.readFile(meta, 'utf-8')).replace('status: proposed', 'status: shipped') + ); + + await expect( + new MigrateCommand().execute(tempDir, { to: 'archive' }) + ).rejects.toThrow(/unfolded deltas/); + expect(process.exitCode).toBeUndefined(); + }); }); From db169fa54b8b61e4d70cd93f303f22bcd6383aa1 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 15:18:16 +0200 Subject: [PATCH 09/13] fix(sharding): every surface resolves both layouts; migrate refuses ambiguity and survives interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the sharded layout: - getActiveChangeIds, getAvailableChanges and the view dashboard enumerate through discoverChanges, so a migrated tree no longer reports the year shard as a change named '2026'; show, validate, status and instructions resolve sharded dirs via resolveChangeDir (with the flat join kept as fallback behind the traversal guard). - resolveChangeDir returns null for ids discovery could never produce (separators, dot segments), so hostile ids cannot address anything outside changes/; discovery skips hidden dirs like the flat scan did. - migrate pre-flights id ambiguity: a legacy name reused across archive eras — idiomatic under archive mode — would shard into two dirs no bare id can address, so the plan is refused with the collisions named before the first rename (dry-run included). Duplicate targets refuse likewise instead of clobbering. - an interrupted migration now resumes: the flat scan skips year shards left by a partial run instead of renaming changes/YYYY into itself. - the reverse-migration gate reads SyncCommand's returned report via the new silent option instead of monkey-patching console.log and sniffing process.exitCode. - metadata stamping edits the YAML document in place, preserving comments and key order legacy files may carry. Co-Authored-By: Claude Fable 5 --- src/commands/change.ts | 38 ++++++--- src/commands/validate.ts | 7 +- src/commands/workflow/instructions.ts | 6 +- src/commands/workflow/shared.ts | 24 ++---- src/commands/workflow/status.ts | 4 +- src/core/change-discovery.ts | 9 ++- src/core/lifecycle-migrate.ts | 106 ++++++++++++++++++++------ src/core/planning-home.ts | 16 ++++ src/core/view.ts | 33 ++++---- src/utils/item-discovery.ts | 10 +-- test/core/lifecycle-sharding.test.ts | 85 ++++++++++++++++++++- 11 files changed, 254 insertions(+), 84 deletions(-) diff --git a/src/commands/change.ts b/src/commands/change.ts index 4c58af7892..76c4f45858 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -8,6 +8,7 @@ import { Change } from '../core/schemas/index.js'; import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { discoverChanges, resolveChangeDir } from '../core/change-discovery.js'; import { getTaskProgressForChange } from '../utils/task-progress.js'; import { FileSystemUtils } from '../utils/file-system.js'; @@ -79,10 +80,13 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); + // Resolve in either layout; the flat fallback keeps not-found errors + // pathed and stays behind the traversal guard. + const resolved = await resolveChangeDir(changesPath, changeName); + const changeDir = resolved ?? path.join(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); - if (!isChangeDirectoryName(changesPath, changeDir)) { + if (resolved === null && !isChangeDirectoryName(changesPath, changeDir)) { throw new Error(`Change "${changeName}" not found at ${proposalPath}`); } @@ -146,23 +150,30 @@ export class ChangeCommand { */ async list(options?: { json?: boolean; long?: boolean }): Promise { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); - + // Same directory-based resolution as `openspec list`, the command this // deprecated alias points users at. Every output path below already // tolerates a change whose proposal.md is missing or unreadable. - const changes = await getActiveChangeIds(); + const discovered = await discoverChanges(changesPath).catch(() => []); + const changes = discovered.map((change) => change.id); + const dirs = new Map(discovered.map((change) => [change.id, change.dir])); if (options?.json) { const changeDetails = await Promise.all( changes.map(async (changeName) => { - const changeDir = path.join(changesPath, changeName); + const changeDir = dirs.get(changeName) ?? path.join(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); // Resolve task progress through the shared tracked-tasks helper so // this deprecated noun-form list cannot re-fork the resolution // (#1202). Tasks are independent of the proposal: a change can carry - // tasks before, or without, a proposal.md. - const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + // tasks before, or without, a proposal.md. Sharded changes pass + // their relative path; the helper joins changesPath with it. + const taskStatus = await getTaskProgressForChange( + changesPath, + path.relative(changesPath, changeDir), + process.cwd() + ); // No proposal yet is an ordinary state (scaffolded change, or a // schema with no proposal artifact), so name the change rather than @@ -206,9 +217,13 @@ export class ChangeCommand { // Long format: id: title and minimal counts for (const changeName of sorted) { - const changeDir = path.join(changesPath, changeName); + const changeDir = dirs.get(changeName) ?? path.join(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); - const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + const { total, completed } = await getTaskProgressForChange( + changesPath, + path.relative(changesPath, changeDir), + process.cwd() + ); const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; if (await isDefinitelyMissing(proposalPath)) { console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`); @@ -254,8 +269,9 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); - if (!isChangeDirectoryName(changesPath, changeDir)) { + const resolved = await resolveChangeDir(changesPath, changeName); + const changeDir = resolved ?? path.join(changesPath, changeName); + if (resolved === null && !isChangeDirectoryName(changesPath, changeDir)) { throw new Error(`Change "${changeName}" not found at ${changeDir}`); } try { diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 8f7428e647..e5f5f98ca9 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -11,6 +11,7 @@ import { } from '../core/root-selection.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; +import { resolveChangeDir } from '../core/change-discovery.js'; import { getAvailableChanges } from './workflow/shared.js'; import { nearestMatches } from '../utils/match.js'; import { promises as fs } from 'fs'; @@ -215,7 +216,8 @@ export class ValidateCommand { private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { const validator = new Validator(opts.strict); if (type === 'change') { - const changeDir = path.join(root.changesDir, id); + const changeDir = + (await resolveChangeDir(root.changesDir, id)) ?? path.join(root.changesDir, id); const start = Date.now(); const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir, @@ -301,7 +303,8 @@ export class ValidateCommand { for (const id of changeIds) { queue.push(async () => { const start = Date.now(); - const changeDir = path.join(root.changesDir, id); + const changeDir = + (await resolveChangeDir(root.changesDir, id)) ?? path.join(root.changesDir, id); const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir, projectRoot: root.path, diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 1ae6fac7c0..d2d5a94038 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -17,7 +17,7 @@ import { type ArtifactInstructions, } from '../../core/artifact-graph/index.js'; import { - getChangeDir, + resolvePlanningChangeDir, resolveCurrentPlanningHomeSync, type PlanningHome, } from '../../core/planning-home.js'; @@ -137,7 +137,7 @@ export async function instructionsCommand( // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, options.schema, { - changeDir: getChangeDir(planningHome, changeName), + changeDir: await resolvePlanningChangeDir(planningHome, changeName), planningHome, projectConfig, }); @@ -372,7 +372,7 @@ export async function generateApplyInstructions( const references = options.references; // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, schemaName, { - changeDir: getChangeDir(planningHome, changeName), + changeDir: await resolvePlanningChangeDir(planningHome, changeName), planningHome, projectConfig: options.projectConfig, }); diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 2840e004ed..2051c1dd30 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; import path from 'path'; import * as fs from 'fs'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; +import { discoverChanges, resolveChangeDir } from '../../core/change-discovery.js'; import type { ReferenceIndexEntry } from '../../core/references.js'; import { isRootSelectionError } from '../../core/root-selection.js'; @@ -130,23 +131,15 @@ export function getStatusIndicator(status: 'done' | 'skipped' | 'ready' | 'block } /** - * Returns the list of available change directory names under openspec/changes/. - * Excludes the archive directory and hidden directories. + * Returns the list of available change ids under openspec/changes/, in either + * layout — flat or creation-date sharded. Excludes the archive directory and + * hidden directories. */ export async function getAvailableChanges( projectRoot: string, changesDir = path.join(projectRoot, 'openspec', 'changes') ): Promise { - const changesPath = changesDir; - try { - const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); - return entries - .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) - .map((e) => e.name); - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; - throw error; - } + return (await discoverChanges(changesDir)).map((change) => change.id); } /** @@ -207,11 +200,10 @@ export async function validateChangeExists( throw new Error(`Invalid change name '${changeName}': ${lookupError}`); } - // Check directory existence directly - const changePath = path.join(changesDir, changeName); - const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); + // Resolve in either layout — flat or creation-date sharded + const changePath = await resolveChangeDir(changesDir, changeName); - if (!exists) { + if (changePath === null) { const available = await getAvailableChanges(projectRoot, changesDir); if (available.length === 0) { throw new Error( diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 32f5950716..c20afe910b 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -6,7 +6,7 @@ import ora from 'ora'; import chalk from 'chalk'; -import { getChangeDir } from '../../core/planning-home.js'; +import { resolvePlanningChangeDir } from '../../core/planning-home.js'; import { resolveRootForCommand, toPlanningHome, @@ -99,7 +99,7 @@ export async function statusCommand(options: StatusOptions): Promise { // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, options.schema, { - changeDir: getChangeDir(planningHome, changeName), + changeDir: await resolvePlanningChangeDir(planningHome, changeName), planningHome, }); const status = formatChangeStatus( diff --git a/src/core/change-discovery.ts b/src/core/change-discovery.ts index b56e428379..6f84514f0f 100644 --- a/src/core/change-discovery.ts +++ b/src/core/change-discovery.ts @@ -41,7 +41,7 @@ export async function discoverChanges(changesDir: string): Promise