diff --git a/src/cli/index.ts b/src/cli/index.ts index b5750c8315..594760e154 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,26 @@ program } }); +program + .command('migrate') + .description( + '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?: { to?: string; dryRun?: boolean }) => { + try { + 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); + } + }); + registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); 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/new-change.ts b/src/commands/workflow/new-change.ts index 3e059242dc..e99d22a7c7 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -10,11 +10,9 @@ import ora from 'ora'; import path from 'path'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; -import { formatChangeLocation } from '../../core/planning-home.js'; import { resolveRootForCommand, RootSelectionError, - toPlanningHome, toRootOutput, withStoreFlag, type ResolvedOpenSpecRoot, @@ -75,10 +73,12 @@ function printCreatedChangeHuman( root: ResolvedOpenSpecRoot ): void { // A relative path is only honest when the root is where the user - // stands; a distant ancestor root gets the absolute path. + // stands; a distant ancestor root gets the absolute path. Derived from + // the dir createChange actually made — sharded under `lifecycle: status` — + // not from a flat join of the id. const location = !isStoreSelectedRoot(root) && root.path === process.cwd() - ? formatChangeLocation(toPlanningHome(root), payload.change.id) + ? path.relative(process.cwd(), payload.change.path) : payload.change.path; console.log(`Created change '${payload.change.id}' at ${location}/`); console.log(`Schema: ${payload.change.schema}`); 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 new file mode 100644 index 0000000000..8109896e75 --- /dev/null +++ b/src/core/change-discovery.ts @@ -0,0 +1,128 @@ +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() || entry.name.startsWith('.')) 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. Ids that could never come out of + * discovery (path separators, dot segments, hidden names, shard and archive + * dir names) resolve to null, so the resolver and discovery agree on the + * addressable namespace and a hostile id cannot address anything outside + * changes/. + */ +export async function resolveChangeDir(changesDir: string, id: string): Promise { + if ( + !id || + id === 'archive' || + YEAR_DIR.test(id) || + id.startsWith('.') || + id.includes('/') || + id.includes('\\') || + id.includes('\0') + ) { + return null; + } + 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; +} + +/** + * Derive an item's name from a file path inside it: the segment after the + * innermost `specs` or `changes` directory, minus the `DD-` prefix when the + * path runs through a creation-date shard (`changes/YYYY/MM/DD-/...`). + * Falls back to the file name without extension. + */ +export function itemNameFromPath(filePath: string): string { + const parts = filePath.split(/[/\\]/); + + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i] === 'specs' || parts[i] === 'changes') { + if (i < parts.length - 1) { + if ( + parts[i] === 'changes' && + YEAR_DIR.test(parts[i + 1] ?? '') && + MONTH_DIR.test(parts[i + 2] ?? '') && + DAY_PREFIX.test(parts[i + 3] ?? '') + ) { + return parts[i + 3].replace(DAY_PREFIX, ''); + } + return parts[i + 1]; + } + } + } + + const fileName = parts[parts.length - 1] ?? ''; + const dotIndex = fileName.lastIndexOf('.'); + return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName; +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 6e1afcdb2b..d7705f6db4 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -212,6 +212,22 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'migrate', + 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', + }, + ], + }, { name: 'status', description: 'Display artifact completion status for a change', diff --git a/src/core/converters/json-converter.ts b/src/core/converters/json-converter.ts index b8468c2aca..9f4181029d 100644 --- a/src/core/converters/json-converter.ts +++ b/src/core/converters/json-converter.ts @@ -3,7 +3,7 @@ import path from 'path'; import { MarkdownParser } from '../parsers/markdown-parser.js'; import { ChangeParser } from '../parsers/change-parser.js'; import { Spec, Change } from '../schemas/index.js'; -import { FileSystemUtils } from '../../utils/file-system.js'; +import { itemNameFromPath } from '../change-discovery.js'; export class JsonConverter { convertSpecToJson(filePath: string): string { @@ -44,19 +44,6 @@ export class JsonConverter { } private extractNameFromPath(filePath: string): string { - const normalizedPath = FileSystemUtils.toPosixPath(filePath); - const parts = normalizedPath.split('/'); - - for (let i = parts.length - 1; i >= 0; i--) { - if (parts[i] === 'specs' || parts[i] === 'changes') { - if (i < parts.length - 1) { - return parts[i + 1]; - } - } - } - - const fileName = parts[parts.length - 1] ?? ''; - const dotIndex = fileName.lastIndexOf('.'); - return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName; + return itemNameFromPath(filePath); } } diff --git a/src/core/lifecycle-migrate.ts b/src/core/lifecycle-migrate.ts new file mode 100644 index 0000000000..1a053c3cb0 --- /dev/null +++ b/src/core/lifecycle-migrate.ts @@ -0,0 +1,354 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml'; +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})$/; +const YEAR_DIR = /^\d{4}$/; + +export interface MigrateOptions { + dryRun?: boolean; + to?: LifecycleMode; +} + +interface PlannedMove { + from: string; + to: string; + id: string; + status: 'proposed' | 'shipped'; + created: string; +} + +/** + * 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. + * + * → 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 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 { + 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'); + 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)) { + // Year dirs are shards left by an interrupted earlier run, not changes; + // scanning into them would try to rename changes/YYYY into itself. + if (entry === 'archive' || YEAR_DIR.test(entry)) 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, + }); + } + + // In the sharded layout every command addresses a change by bare id, so a + // legacy name reused across archive eras (the date prefix exists to allow + // exactly that) would become permanently ambiguous. Refuse before the + // first rename; already-sharded entries from an interrupted run count too. + const claimed = new Map(); + for (const change of await discoverChanges(changesDir)) { + const rel = path.relative(changesDir, change.dir); + if (SHARD_PATH.test(rel)) { + claimed.set(change.id, [...(claimed.get(change.id) ?? []), rel]); + } + } + for (const move of moves) { + claimed.set(move.id, [ + ...(claimed.get(move.id) ?? []), + path.relative(changesDir, move.from), + ]); + } + const ambiguous = [...claimed.entries()].filter(([, sources]) => sources.length > 1); + if (ambiguous.length > 0) { + const listing = ambiguous + .map(([id, sources]) => ` ${id}: ${sources.join(', ')}`) + .join('\n'); + throw new Error( + `Refusing to migrate: these change ids would be ambiguous in the sharded layout, where commands address changes by bare id:\n${listing}\nRename the colliding folders first (e.g. ${ambiguous[0][0]}-v2), then re-run.` + ); + } + + 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 gate = await new SyncCommand().execute(undefined, targetPath, { + check: true, + silent: true, + }); + if (!gate.clean) { + 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.'); + } + + // Two sources mapping to one target would silently clobber the second; + // reachable when a hand-edited tree reuses an id within one shard date. + const targets = new Map(); + for (const move of moves) { + const prior = targets.get(move.to); + if (prior !== undefined) { + throw new Error( + `Refusing to migrate: '${prior}' and '${path.relative(targetPath, move.from)}' both map to '${path.relative(targetPath, move.to)}'. Rename one and re-run.` + ); + } + targets.set(move.to, path.relative(targetPath, move.from)); + } + + for (const move of moves) { + console.log( + ` ${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, options.to ?? 'status'); + } + + if (options.dryRun) { + console.log('Dry run — nothing written.'); + return; + } + await finish(); + } + + 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, + target: LifecycleMode + ): Promise { + const file = path.join(move.to, '.openspec.yaml'); + let raw: string | null = null; + try { + raw = await fs.readFile(file, 'utf-8'); + } catch { + raw = null; + } + + // Edit the document, not a re-serialization: legacy metadata may carry + // comments and key order this migration has no business rewriting. A file + // that does not parse gets a fresh minimal stamp — same as before. + const doc = parseDocument(raw ?? ''); + if (doc.errors.length > 0) { + const stamped: Record = { + schema: await this.projectSchema(targetPath), + created: move.created, + }; + if (target === 'status') { + stamped.status = move.status; + } + await fs.writeFile(file, stringifyYaml(stamped), 'utf-8'); + return; + } + + if (!doc.has('schema')) { + doc.set('schema', await this.projectSchema(targetPath)); + } + if (!doc.has('created')) { + doc.set('created', move.created); + } + if (target === 'status') { + doc.set('status', move.status); + } else { + // Under archive mode location is the state; a lingering status field + // would be a second, contradicting record. + doc.delete('status'); + } + await fs.writeFile(file, doc.toString(), '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 { + 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, 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'); + 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 { + continue; + } + } + await fs.writeFile( + path.join(openspecDir, 'config.yaml'), + mode === 'status' ? 'schema: spec-driven\nlifecycle: status\n' : 'schema: spec-driven\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/planning-home.ts b/src/core/planning-home.ts index c27a8ccbe7..1f3fa664de 100644 --- a/src/core/planning-home.ts +++ b/src/core/planning-home.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { FileSystemUtils } from '../utils/file-system.js'; +import { resolveChangeDir } from './change-discovery.js'; export type PlanningHomeKind = 'repo'; @@ -93,6 +94,21 @@ export function getChangeDir(planningHome: PlanningHome, changeName: string): st return FileSystemUtils.joinPath(planningHome.changesDir, changeName); } +/** + * Shard-aware variant of `getChangeDir`: resolves the change in either layout + * (flat or creation-date sharded), falling back to the flat join so callers + * on a not-yet-created change still get a path to report. + */ +export async function resolvePlanningChangeDir( + planningHome: PlanningHome, + changeName: string +): Promise { + return ( + (await resolveChangeDir(planningHome.changesDir, changeName)) ?? + getChangeDir(planningHome, changeName) + ); +} + export function formatChangeLocation(planningHome: PlanningHome, changeName: string): string { // Repo homes always nest changesDir under the root. return path.relative(planningHome.root, getChangeDir(planningHome, changeName)); 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/core/validation/validator.ts b/src/core/validation/validator.ts index 56f771e1a8..a21fa99188 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -5,6 +5,7 @@ import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js'; import { MarkdownParser } from '../parsers/markdown-parser.js'; import { ChangeParser } from '../parsers/change-parser.js'; import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js'; +import { itemNameFromPath } from '../change-discovery.js'; import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, @@ -747,22 +748,7 @@ export class Validator { } private extractNameFromPath(filePath: string): string { - const normalizedPath = FileSystemUtils.toPosixPath(filePath); - const parts = normalizedPath.split('/'); - - // Look for the directory name after 'specs' or 'changes' - for (let i = parts.length - 1; i >= 0; i--) { - if (parts[i] === 'specs' || parts[i] === 'changes') { - if (i < parts.length - 1) { - return parts[i + 1]; - } - } - } - - // Fallback to filename without extension if not in expected structure - const fileName = parts[parts.length - 1] ?? ''; - const dotIndex = fileName.lastIndexOf('.'); - return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName; + return itemNameFromPath(filePath); } private createReport(issues: ValidationIssue[]): ValidationReport { diff --git a/src/core/view.ts b/src/core/view.ts index e79c1905a7..b4036e5023 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; import { discoverSpecFiles } from '../utils/spec-discovery.js'; +import { discoverChanges } from './change-discovery.js'; export class ViewCommand { async execute(targetPath: string = '.'): Promise { @@ -94,22 +95,22 @@ export class ViewCommand { const active: Array<{ name: string; progress: { total: number; completed: number } }> = []; const completed: Array<{ name: string }> = []; - const entries = fs.readdirSync(changesDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'archive') { - const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir)); - - if (progress.total === 0) { - // No tasks defined yet - still in planning/draft phase - draft.push({ name: entry.name }); - } else if (progress.completed === progress.total) { - // All tasks complete - completed.push({ name: entry.name }); - } else { - // Has tasks but not all complete - active.push({ name: entry.name, progress }); - } + // Both layouts: the task-progress helper joins changesDir with the + // segment it gets, so sharded changes pass their relative path while + // displaying the id. + for (const change of await discoverChanges(changesDir)) { + const relPath = path.relative(changesDir, change.dir); + const progress = await getTaskProgressForChange(changesDir, relPath, path.dirname(openspecDir)); + + if (progress.total === 0) { + // No tasks defined yet - still in planning/draft phase + draft.push({ name: change.id }); + } else if (progress.completed === progress.total) { + // All tasks complete + completed.push({ name: change.id }); + } else { + // Has tasks but not all complete + active.push({ name: change.id, progress }); } } diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 85dc7caab7..7893e5eb2c 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,26 +158,41 @@ 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 // half-root behind that doctor immediately calls unhealthy: ensure // specs/ and changes/archive/ exist, and write a config only when // none exists. The config records the PROJECT default schema, never - // a one-change --schema override. + // a one-change --schema override. Under `lifecycle: status` there is + // no archive directory to scaffold — state lives in metadata. const openspecDir = path.join(projectRoot, 'openspec'); // Create the directory (including parent directories if needed) await FileSystemUtils.createDirectory(changeDir); await FileSystemUtils.createDirectory(path.join(openspecDir, 'specs')); - await FileSystemUtils.createDirectory(path.join(openspecDir, 'changes', 'archive')); + if (resolveLifecycle(projectRoot) !== 'status') { + await FileSystemUtils.createDirectory(path.join(openspecDir, 'changes', 'archive')); + } const configPath = path.join(openspecDir, 'config.yaml'); const configYmlPath = path.join(openspecDir, 'config.yml'); if ( diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 65d5b45fab..d1fecf10ae 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,9 +1,11 @@ import { promises as fs } from 'fs'; import path from 'path'; import { discoverSpecFiles } from './spec-discovery.js'; +import { discoverChanges } from '../core/change-discovery.js'; /** - * Returns the ids of active changes: every directory under openspec/changes/ + * Returns the ids of active changes: every change directory under + * openspec/changes/ in either layout — flat or creation-date sharded — * except the archive and hidden directories. * * A change is resolved by its directory alone - the same rule `list`, @@ -16,11 +18,7 @@ import { discoverSpecFiles } from './spec-discovery.js'; export async function getActiveChangeIds(root: string = process.cwd()): Promise { const changesPath = path.join(root, 'openspec', 'changes'); try { - const entries = await fs.readdir(changesPath, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory() && entry.name !== 'archive' && !entry.name.startsWith('.')) - .map((entry) => entry.name) - .sort(); + return (await discoverChanges(changesPath)).map((change) => change.id); } catch { return []; } diff --git a/test/core/lifecycle-sharding.test.ts b/test/core/lifecycle-sharding.test.ts new file mode 100644 index 0000000000..ca4c63c2d5 --- /dev/null +++ b/test/core/lifecycle-sharding.test.ts @@ -0,0 +1,346 @@ +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 { getActiveChangeIds } from '../../src/utils/item-discovery.js'; +import { getAvailableChanges } from '../../src/commands/workflow/shared.js'; +import { JsonConverter } from '../../src/core/converters/json-converter.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('resolves hostile ids to null instead of escaping changes/', async () => { + const changes = path.join(tempDir, 'changes'); + await fs.mkdir(path.join(changes, 'real-change'), { recursive: true }); + + expect(await resolveChangeDir(changes, '..')).toBeNull(); + expect(await resolveChangeDir(changes, '../outside')).toBeNull(); + expect(await resolveChangeDir(changes, '.hidden')).toBeNull(); + expect(await resolveChangeDir(changes, '')).toBeNull(); + }); + + it('never hands out shard or archive dirs as changes', async () => { + const changes = path.join(tempDir, 'changes'); + await fs.mkdir(path.join(changes, '2026', '03', '15-real'), { recursive: true }); + await fs.mkdir(path.join(changes, 'archive'), { recursive: true }); + + expect(await resolveChangeDir(changes, '2026')).toBeNull(); + expect(await resolveChangeDir(changes, 'archive')).toBeNull(); + }); + + it('derives the change id, not the year shard, from a sharded path', async () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', '2026', '03', '15-old-change'); + await fs.mkdir(changeDir, { recursive: true }); + const proposal = path.join(changeDir, 'proposal.md'); + await fs.writeFile(proposal, '# Change: Old Change\n\n## Why\n\nBecause.\n\n## What Changes\n\n- stuff\n'); + + const parsed = JSON.parse(await new JsonConverter().convertChangeToJson(proposal)); + expect(parsed.name).toBe('old-change'); + }); + + it('the shared enumerators see sharded changes, not shard dirs', async () => { + const changes = path.join(tempDir, 'openspec', '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 }); + + expect(await getActiveChangeIds(tempDir)).toEqual(['flat-change', 'old-change']); + expect(await getAvailableChanges(tempDir)).toEqual(['flat-change', 'old-change']); + }); + + 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'); + + // The root-completion scaffold must not resurrect the directory the + // mode abolished. + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'archive')) + ).rejects.toThrow(); + }); +}); + +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(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 () => { + 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. + const gate = await new SyncCommand().execute(undefined, tempDir, { + check: true, + silent: true, + }); + expect(gate.clean).toBe(true); + }); + + it('refuses to migrate when a legacy name reuse would shard into an ambiguous id', async () => { + // Reusing an archived change's name is idiomatic under archive mode — the + // date prefix exists to allow it — but bare ids cannot address two shards. + const reused = path.join(tempDir, 'openspec', 'changes', 'add-user-auth'); + await fs.mkdir(reused, { recursive: true }); + + await expect(new MigrateCommand().execute(tempDir, { dryRun: true })).rejects.toThrow( + /ambiguous/ + ); + await expect(new MigrateCommand().execute(tempDir, {})).rejects.toThrow(/add-user-auth/); + + // Nothing moved: the plan was refused before the first rename. + 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('resumes after an interrupted migration instead of renaming shards into themselves', async () => { + // Simulate a crash after the archived change moved but before the flat + // scan, the archive cleanup, and the config flip. + const changes = path.join(tempDir, 'openspec', 'changes'); + const shard = path.join(changes, '2026', '03', '15-add-user-auth'); + await fs.mkdir(path.dirname(shard), { recursive: true }); + await fs.rename(path.join(changes, 'archive', '2026-03-15-add-user-auth'), shard); + + await new MigrateCommand().execute(tempDir, {}); + + await expect(fs.access(shard)).resolves.not.toThrow(); + await expect( + fs.access(path.join(changes, '2026', '08', '01-batch-upload')) + ).resolves.not.toThrow(); + const config = await fs.readFile(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8'); + expect(config).toContain('lifecycle: status'); + }); + + it('preserves metadata comments and key order when stamping', async () => { + const meta = path.join(tempDir, 'openspec', 'changes', 'batch-upload', '.openspec.yaml'); + await fs.writeFile( + meta, + '# provenance: imported from wiki\nschema: spec-driven\ncreated: 2026-08-01\n' + ); + + await new MigrateCommand().execute(tempDir, {}); + + const stamped = await fs.readFile( + path.join(tempDir, 'openspec', 'changes', '2026', '08', '01-batch-upload', '.openspec.yaml'), + 'utf-8' + ); + expect(stamped).toContain('# provenance: imported from wiki'); + expect(stamped).toContain('status: proposed'); + expect(stamped.indexOf('schema:')).toBeLessThan(stamped.indexOf('created:')); + }); + + 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'); + }); + + 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(); + }); +});