From f20196f31d5013b1a31a56c8a25653b44d99e5e4 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 11:17:26 +0200 Subject: [PATCH 1/6] 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 | 48 +++--- src/utils/change-utils.ts | 21 ++- test/core/lifecycle-sharding.test.ts | 183 +++++++++++++++++++++++ 8 files changed, 531 insertions(+), 49 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 b5750c8315..9831738d11 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 6d44f71357..9a75142210 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,29 +129,19 @@ export class SyncCommand { 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; - } + ): Promise> { + 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), @@ -268,7 +264,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 fb311030d1f378b9fd8a69597a50677afe3580a4 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 11:48:26 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(lifecycle):=20migrate=20--to=20archive?= =?UTF-8?q?=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 9831738d11..594760e154 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 049cca145c8ab54b0c5834ec6b0a364ae7e6cedb Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 13 Aug 2026 15:18:16 +0200 Subject: [PATCH 3/6] 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