From 8182b0d3f4f977a19ec88b87bb2c8d1cbc316ae1 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 20 Aug 2026 00:42:38 +0300 Subject: [PATCH 1/4] refactor(changes): one layout-agnostic discovery for change directories Every surface that enumerated changes did its own readdir of openspec/changes/, and every surface that resolved a change id did its own path join. That was fine while a change was always exactly one directory deep, and it is the reason adding any other layout would otherwise have to be repeated a dozen times. This introduces a single discovery module and routes the enumeration and resolution surfaces through it: show, validate, status, instructions, shell completions, the dashboard view, and the path-derived name helpers in the JSON converter and the validator. Behaviour is unchanged for the flat layout every project uses today. The module also fixes two things the scattered copies got wrong: an id containing a separator or dot segment now resolves to nothing rather than being joined into a path, and a directory the walk cannot read propagates its error instead of being reported as an empty result. Co-Authored-By: Claude Opus 5 --- src/commands/change.ts | 67 ++++++----- 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 | 159 ++++++++++++++++++++++++++ src/core/converters/json-converter.ts | 17 +-- src/core/planning-home.ts | 16 +++ src/core/validation/validator.ts | 18 +-- src/core/view.ts | 33 +++--- src/utils/item-discovery.ts | 10 +- 11 files changed, 256 insertions(+), 105 deletions(-) create mode 100644 src/core/change-discovery.ts diff --git a/src/commands/change.ts b/src/commands/change.ts index 4c58af7892..e5fa0a168f 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'; @@ -23,15 +24,6 @@ async function isDefinitelyMissing(target: string): Promise { .catch((error: NodeJS.ErrnoException) => error?.code === 'ENOENT'); } -/** - * A change is a directory directly under changes/. Rejecting anything else up - * front keeps a traversing name (`../..`) from reading a proposal outside the - * changes directory, and keeps the missing-proposal message honest. - */ -function isChangeDirectoryName(changesPath: string, changeDir: string): boolean { - return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath); -} - export class ChangeCommand { private converter: JsonConverter; private rootPath?: string; @@ -79,12 +71,16 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); - const proposalPath = path.join(changeDir, 'proposal.md'); - - if (!isChangeDirectoryName(changesPath, changeDir)) { - throw new Error(`Change "${changeName}" not found at ${proposalPath}`); + // Resolution decides what is addressable, in either layout. A refusal is + // final: falling back to a flat join would re-admit exactly the ids the + // resolver exists to reject — a bare year shard, `archive`, a hidden name. + const changeDir = await resolveChangeDir(changesPath, changeName); + if (changeDir === null) { + throw new Error( + `Change "${changeName}" not found at ${path.join(changesPath, changeName, 'proposal.md')}` + ); } + const proposalPath = path.join(changeDir, 'proposal.md'); try { await fs.access(proposalPath); @@ -146,23 +142,29 @@ 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(); + // Not caught: discoverChanges already treats an absent changes/ as empty, + // so anything it throws is a tree this command could not read. + const discovered = await discoverChanges(changesPath); if (options?.json) { const changeDetails = await Promise.all( - changes.map(async (changeName) => { - const changeDir = path.join(changesPath, changeName); + discovered.map(async ({ id: changeName, dir: changeDir }) => { 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 @@ -193,22 +195,27 @@ export class ChangeCommand { const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id)); console.log(JSON.stringify(sorted, null, 2)); } else { - if (changes.length === 0) { + if (discovered.length === 0) { console.log('No items found'); return; } - const sorted = [...changes].sort(); + // Sorted as entries, not as bare ids: two directories can share an id, + // and collapsing them by name would report both under one directory. + const sorted = [...discovered].sort((a, b) => a.id.localeCompare(b.id)); if (!options?.long) { // IDs only - sorted.forEach(id => console.log(id)); + sorted.forEach(({ id }) => console.log(id)); return; } // Long format: id: title and minimal counts - for (const changeName of sorted) { - const changeDir = path.join(changesPath, changeName); + for (const { id: changeName, dir: changeDir } of sorted) { 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,9 +261,11 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); - if (!isChangeDirectoryName(changesPath, changeDir)) { - throw new Error(`Change "${changeName}" not found at ${changeDir}`); + const changeDir = await resolveChangeDir(changesPath, changeName); + if (changeDir === null) { + throw new Error( + `Change "${changeName}" not found at ${path.join(changesPath, changeName)}` + ); } try { await fs.access(changeDir); 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 new file mode 100644 index 0000000000..a197c23539 --- /dev/null +++ b/src/core/change-discovery.ts @@ -0,0 +1,159 @@ +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 directory that is simply absent means "no changes" — at the root + // because the project has none yet, inside a shard because a concurrent + // move removed it. Anything else (ENOTDIR, EACCES, EIO, ...) means the + // walk cannot see what it is meant to enumerate, and reporting a clean + // gate on a tree it could not read is worse than no gate. Depth does not + // change that: an unreadable month shard hides shipped changes exactly + // as effectively as an unreadable root. + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { + return; + } + throw error; + } + 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)); +} + +/** + * Ids discovery could never produce, and which therefore name no change: + * separators and dot segments (which would escape changes/), hidden names, + * and the two directory names the layout itself owns — `archive` and a bare + * year shard. Creation shares this predicate with resolution, so a name that + * could not be addressed later cannot be created now. + */ +export function isReservedChangeId(id: string): boolean { + return ( + !id || + id === 'archive' || + YEAR_DIR.test(id) || + id.startsWith('.') || + id.includes('/') || + id.includes('\\') || + id.includes('\0') + ); +} + +/** + * Resolve a change id to its directory in either layout. Throws when the id is + * ambiguous — two directories carrying the same id — because guessing would + * silently act on the wrong change. Reserved ids resolve to null, so the + * resolver and discovery agree on the addressable namespace. + */ +export async function resolveChangeDir(changesDir: string, id: string): Promise { + if (isReservedChangeId(id)) { + return null; + } + // Enumerate before deciding. Returning a flat hit early would let + // changes// silently win over changes/YYYY/MM/DD-/, so a command + // would act on a different change than `openspec list` displays. + const matches = (await discoverChanges(changesDir)).filter((c) => c.id === id); + + // The walk reads dirents, which do not follow symlinks, so a change + // directory linked into changes/ is invisible to it. stat does follow, so + // look the flat path up separately — merged into the match set rather than + // returned early, so it still cannot mask a sharded twin. + const flat = path.join(changesDir, id); + if (!matches.some((match) => match.dir === flat)) { + try { + if ((await fs.stat(flat)).isDirectory()) { + // Compare physical identity, not path strings: a compatibility symlink + // left pointing at the sharded directory is the same change, and + // calling that pair ambiguous would fail on a tree that is fine. + const real = await fs.realpath(flat); + const seen = await Promise.all( + matches.map((match) => fs.realpath(match.dir).catch(() => match.dir)) + ); + if (!seen.includes(real)) { + matches.push({ id, dir: flat }); + } + } + } catch { + // no flat directory under this 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/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/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/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/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 []; } From d3a43619c8a136c050790d2841800e27f11124f7 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 20 Aug 2026 00:42:52 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(lifecycle):=20experimental=20`lifecycl?= =?UTF-8?q?e:=20status`=20mode=20=E2=80=94=20state=20as=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `archive` does two unrelated jobs in one command: a state transition (declaring a change shipped) and a text merge (folding deltas into specs/). Encoding the transition as a directory move welds the merge to one moment in the review lifecycle, and on a team with code review that moment does not exist — which is why "is everything archived?" cannot be enforced in CI without being red for the whole life of every PR. Under the opt-in `lifecycle: status` mode a change records its own state in `.openspec.yaml` as `status: proposed | shipped` and never moves: openspec sync fold every shipped change's deltas into specs/ openspec sync --check exit 1 if a shipped change has unfolded deltas openspec ship declare shipped and fold, as one diff The gate becomes a predicate over the working tree — shipped implies folded — which a proposed change satisfies for free, so green is the resting state and red means a real mistake. Folded-ness is decided by regeneration rather than bookkeeping: a change is in sync when re-applying its delta produces byte-identical output, so `--check` and the write path run the same code and cannot drift apart the way #1112's validate and archive did. `openspec list` gains a lifecycle column and a `--status` filter, and `openspec archive` refuses under status mode while `openspec ship` refuses under archive mode, so the two models can never both claim a change. Projects that do not set `lifecycle` resolve to `archive` and are entirely unaffected; the one visible change there is that a generated spec skeleton no longer says it was created by archiving, since a fold can now happen without one. Co-Authored-By: Claude Opus 5 --- src/commands/workflow/new-change.ts | 8 +- src/core/archive.ts | 19 ++ src/core/change-metadata/schema.ts | 10 + src/core/list.ts | 86 +++-- src/core/project-config.ts | 35 +++ src/core/specs-apply.ts | 2 +- src/core/sync.ts | 295 ++++++++++++++++++ src/utils/change-utils.ts | 49 ++- test/core/archive.test.ts | 28 +- test/core/list.test.ts | 65 ++++ test/core/sync.test.ts | 257 +++++++++++++++ test/specs/source-specs-normalization.test.ts | 2 +- 12 files changed, 803 insertions(+), 53 deletions(-) create mode 100644 src/core/sync.ts create mode 100644 test/core/sync.test.ts 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/core/archive.ts b/src/core/archive.ts index 888a6135a6..97899f7d10 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -28,6 +28,7 @@ import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js'; import { FileSystemUtils } from '../utils/file-system.js'; import { folderStyleNameProblem } from './id.js'; +import { resolveLifecycle } from './project-config.js'; function isMissingPathError(error: unknown): boolean { return ( @@ -1068,6 +1069,24 @@ export class ArchiveCommand { throw error; } + // Under `lifecycle: status` nothing ever moves: shipping is a metadata + // edit and the spec fold belongs to `openspec sync`. Refusing here keeps + // one mode from half-running the other's workflow. + if (resolveLifecycle(root.path) === 'status') { + const diagnostic: ArchiveDiagnostic = { + severity: 'error', + code: 'lifecycle_status_mode', + message: + 'This project uses `lifecycle: status` — changes are never moved to archive/.', + fix: 'Set `status: shipped` in the change\'s .openspec.yaml, then run `openspec sync`.', + }; + if (json) { + this.printJsonFailure(root, diagnostic); + return; + } + throw new Error(`${diagnostic.message} ${diagnostic.fix}`); + } + if (json) { try { const result = await this.run(changeName, options, root, true); diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index 3644160052..b0884a1fdc 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -46,6 +46,16 @@ export const ChangeMetadataSchema = z.object({ // tree - only from git - so it is the author's call, not an inference from the // shape of a delta. retire_capabilities: z.boolean().optional(), + // Lifecycle state under `lifecycle: status` mode: the change's position in + // its life is data, not directory location, and nothing ever moves. Closed + // set because tooling attaches consequences to each state: `sync` folds + // only shipped changes' deltas into specs/, and a proposed change holds a + // live claim on the requirements it touches (what overlap/drift tooling + // reasons over). Deliberately two states: implementation progress is + // already carried by tasks.md checkboxes, and a status with no machine + // consequences would just be a comment that can drift from them. + // Absent on projects using the default `lifecycle: archive` mode. + status: z.enum(['proposed', 'shipped']).optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/list.ts b/src/core/list.ts index f6b6faf2f8..478d83e88f 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,7 +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'; @@ -11,12 +13,31 @@ interface ChangeInfo { completedTasks: number; totalTasks: number; lastModified: Date; + lifecycle?: string; } interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; root?: RootOutput; + /** Filter changes by lifecycle status (projects with `lifecycle: status`). */ + status?: string; +} + +const LIFECYCLE_STATES = new Set(['proposed', 'shipped']); + +// Non-throwing: list must render even when a change's metadata would fail the +// stricter contract readChangeMetadata enforces — a broken change is status's +// problem to report, not a reason to hide the whole list. +function readLifecycleStatus(changePath: string): string | undefined { + try { + const raw = readFileSync(path.join(changePath, '.openspec.yaml'), 'utf-8'); + const parsed = parseYaml(raw) as Record | null; + const status = parsed?.['status']; + return typeof status === 'string' && LIFECYCLE_STATES.has(status) ? status : undefined; + } catch { + return undefined; + } } function isMissingPathError(error: unknown): boolean { @@ -28,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. @@ -98,16 +110,20 @@ export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { const { sort = 'recent', json = false, root } = options; + if (options.status && !LIFECYCLE_STATES.has(options.status)) { + throw new Error( + `Unknown status '${options.status}' — expected one of: ${[...LIFECYCLE_STATES].join(', ')}.` + ); + } + if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - // 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 { @@ -119,18 +135,36 @@ 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); + 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 + lastModified, + ...(lifecycle ? { lifecycle } : {}) }); } + if (changes.length === 0) { + if (json) { + console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2)); + } else { + console.log( + options.status ? `No changes with status '${options.status}'.` : 'No active changes found.' + ); + } + return; + } + // Sort by preference (default: recent first) if (sort === 'recent') { changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); @@ -145,7 +179,10 @@ export class ListCommand { completedTasks: c.completedTasks, totalTasks: c.totalTasks, lastModified: c.lastModified.toISOString(), - status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' + status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress', + // `lifecycle`, not `status`: the task-progress field above already + // owns that name in this payload. + ...(c.lifecycle ? { lifecycle: c.lifecycle } : {}) })); console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2)); return; @@ -159,7 +196,8 @@ export class ListCommand { const paddedName = change.name.padEnd(nameWidth); const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); const timeAgo = formatRelativeTime(change.lastModified); - console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`); + const lifecycle = change.lifecycle ? ` [${change.lifecycle}]` : ''; + console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}${lifecycle}`); } return; } diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 922e31505b..1adb7237ef 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -29,6 +29,9 @@ const OperationConfigSchema = z.object({ * - Single source of truth for type and validation * - Consistent with other OpenSpec schemas */ +export const LIFECYCLE_MODES = ['archive', 'status'] as const; +export type LifecycleMode = (typeof LIFECYCLE_MODES)[number]; + export const ProjectConfigSchema = z.object({ // Required: which schema to use (e.g., "spec-driven", or project-local schema name) schema: z @@ -36,6 +39,16 @@ export const ProjectConfigSchema = z.object({ .min(1) .describe('The workflow schema to use (e.g., "spec-driven")'), + // Optional, experimental: how a change's lifecycle state is recorded. + // 'archive' (default) is the existing behavior: finishing a change moves it + // to changes/archive/. 'status' records state in the change's .openspec.yaml + // `status` field instead; nothing ever moves, and `openspec sync` folds + // shipped changes' deltas into specs/ as a standalone, idempotent step. + lifecycle: z + .enum(LIFECYCLE_MODES) + .optional() + .describe('Experimental: "status" records change state as data instead of moving folders'), + // Optional: project context (injected into all artifact instructions) // Max size: 50KB (enforced during parsing) context: z @@ -264,6 +277,16 @@ export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the r * @param projectRoot - The root directory of the project (where `openspec/` lives) * @returns Parsed config or null if file doesn't exist */ +/** + * The project's lifecycle mode. 'archive' unless openspec/config.yaml + * explicitly opts into 'status'; a missing or unreadable config means the + * default, never an error — mode resolution must not add a failure surface + * to commands that only need to know which workflow applies. + */ +export function resolveLifecycle(projectRoot: string): LifecycleMode { + return readProjectConfig(projectRoot)?.lifecycle ?? 'archive'; +} + export function readProjectConfig(projectRoot: string): ProjectConfig | null { const configPath = resolveConfigFilePath(projectRoot); if (configPath === null) { @@ -290,6 +313,18 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { console.warn(`Invalid 'schema' field in config (must be non-empty string)`); } + // Parse lifecycle field. Invalid values warn and fall back to the default + // ('archive'), like other resilient fields — a typo here must not silently + // change which workflow the project runs. + const lifecycleResult = z.enum(LIFECYCLE_MODES).safeParse(raw.lifecycle); + if (lifecycleResult.success) { + config.lifecycle = lifecycleResult.data; + } else if (raw.lifecycle !== undefined) { + console.warn( + `Invalid 'lifecycle' field in config (must be one of: ${LIFECYCLE_MODES.join(', ')})` + ); + } + // Parse context field with size limit if (raw.context !== undefined) { const contextField = z.string(); diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index f0a8ff3842..6a130417d0 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -1088,6 +1088,6 @@ function readableOverview(skeleton: string, specName: string): string | null { export function buildSpecSkeleton(specFolderName: string, changeName: string, purpose?: string): string { const titleBase = specFolderName; const purposeBody = - purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`; + purpose?.trim() || `TBD - created from change ${changeName}. Update Purpose.`; return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`; } diff --git a/src/core/sync.ts b/src/core/sync.ts new file mode 100644 index 0000000000..0ea4c8bbdc --- /dev/null +++ b/src/core/sync.ts @@ -0,0 +1,295 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { discoverChanges, resolveChangeDir } from './change-discovery.js'; +import { + findSpecUpdates, + buildUpdatedSpec, + writeUpdatedSpec, + type SpecUpdate, +} from './specs-apply.js'; +import { + readChangeMetadata, + writeChangeMetadata, + ChangeMetadataError, +} from '../utils/change-metadata.js'; +import { resolveLifecycle } from './project-config.js'; + +export interface SyncOptions { + check?: boolean; + json?: boolean; + /** Suppress all output — programmatic callers read the returned report. */ + silent?: boolean; +} + +type PendingFold = { + update: SpecUpdate; + rebuilt: string; + counts: { added: number; modified: number; removed: number; renamed: number }; +}; + +export interface ChangeSyncState { + change: string; + state: 'folded' | 'unfolded' | 'conflict'; + /** Capability ids whose main spec does not yet reflect this change's delta. */ + pending: string[]; + error?: string; +} + +export interface SyncReport { + mode: 'archive' | 'status'; + changes: ChangeSyncState[]; + clean: boolean; +} + +/** + * Fold shipped changes' spec deltas into the main specs — the text-merge half + * of what archive does, decoupled from any directory move so it can run at any + * time, idempotently. Only changes declaring `status: shipped` fold; proposed + * changes' deltas stay out of specs/, which is what keeps + * specs/ = shipped reality when state is data instead of location. + * + * "Folded" is decided by regeneration, not bookkeeping: a change is in sync + * when re-applying its delta to the current spec produces byte-identical + * output. That makes --check a pure function of the working tree — no model, + * no network, no VCS history — so the same command gates pre-commit, pre-push + * and CI. + */ +export class SyncCommand { + async execute( + changeName: string | undefined, + targetPath: string = '.', + options: SyncOptions = {} + ): Promise { + const mode = resolveLifecycle(targetPath); + const report: SyncReport = { mode, changes: [], clean: true }; + + if (mode !== 'status') { + // Mode-aware by contract: under `lifecycle: archive` the archive command + // owns the fold and there is no status field to gate on. Report and exit + // 0 rather than misfiring on the default layout. + if (options.silent) { + return report; + } + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log( + "This project uses `lifecycle: archive` (the default) — nothing to sync or gate. `openspec sync` applies under `lifecycle: status`; see openspec/config.yaml." + ); + } + return report; + } + + const changesDir = path.join(targetPath, 'openspec', 'changes'); + const specsDir = path.join(targetPath, 'openspec', 'specs'); + + 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 { id: name, dir: changeDir } of candidates) { + const state = await this.evaluate(name, changeDir, specsDir, targetPath, options); + if (state === null) { + continue; + } + report.changes.push(state.report); + if (state.report.state !== 'folded') { + report.clean = false; + } + if (!options.check && state.report.state === 'unfolded') { + for (const fold of state.folds) { + await writeUpdatedSpec(fold.update, fold.rebuilt, fold.counts, { + silent: options.json || options.silent, + }); + } + state.report.state = 'folded'; + report.clean = report.changes.every((c) => c.state === 'folded'); + } + } + + if (!options.silent) { + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + this.print(report, options); + } + } + + return report; + } + + private async shippedChanges( + changesDir: string, + projectRoot: string, + report: SyncReport + ): Promise> { + // Discovery owns the fail-closed rule: a missing changes/ dir means "no + // changes", but any other error propagates, because a gate that reports + // green on a tree it could not read is worse than no gate. + const shipped: Array<{ id: string; dir: string }> = []; + for (const change of await discoverChanges(changesDir)) { + try { + const metadata = readChangeMetadata(change.dir, projectRoot); + if (metadata?.status === 'shipped') { + 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: change.id, + state: 'conflict', + pending: [], + error: err instanceof ChangeMetadataError ? err.message : String(err), + }); + report.clean = false; + } + } + return shipped; + } + + private async evaluate( + name: string, + changeDir: string, + specsDir: string, + projectRoot: string, + options: SyncOptions + ): Promise<{ report: ChangeSyncState; folds: PendingFold[] } | null> { + try { + await fs.access(changeDir); + } catch { + throw new Error(`Change '${name}' not found in openspec/changes/`); + } + + // An explicitly named change must be shipped before its deltas may touch + // specs/. In check mode a non-shipped change is simply not gated. + // Unreadable metadata is the same conflict entry the no-arg sweep reports, + // so CI sees one shape either way. + let metadata; + try { + metadata = readChangeMetadata(changeDir, projectRoot); + } catch (err) { + return { + report: { + change: name, + state: 'conflict', + pending: [], + error: err instanceof ChangeMetadataError ? err.message : String(err), + }, + folds: [], + }; + } + if (metadata?.status !== 'shipped') { + if (options.check) { + return null; + } + throw new Error( + `Change '${name}' has status '${metadata?.status ?? 'none'}' — only shipped changes fold into specs/. Set \`status: shipped\` in its .openspec.yaml first.` + ); + } + + const result: ChangeSyncState = { change: name, state: 'folded', pending: [] }; + const folds: PendingFold[] = []; + + let updates: SpecUpdate[]; + try { + updates = await findSpecUpdates(changeDir, specsDir); + } catch (err) { + return { + report: { ...result, state: 'conflict', error: (err as Error).message }, + folds: [], + }; + } + + for (const update of updates) { + try { + const built = await buildUpdatedSpec(update, name, { silent: true }); + const current = update.exists ? await fs.readFile(update.target, 'utf-8') : null; + if (current !== built.rebuilt) { + result.state = 'unfolded'; + result.pending.push(update.id); + folds.push({ update, rebuilt: built.rebuilt, counts: built.counts }); + } + } catch (err) { + result.state = 'conflict'; + result.error = (err as Error).message; + return { report: result, folds: [] }; + } + } + + return { report: result, folds }; + } + + private print(report: SyncReport, options: SyncOptions): void { + if (report.changes.length === 0) { + console.log('No shipped changes to sync.'); + return; + } + for (const change of report.changes) { + if (change.state === 'folded') { + console.log(` ✓ ${change.change}`); + } else if (change.state === 'unfolded') { + console.log( + ` ✗ ${change.change} — shipped but not folded into specs/: ${change.pending.join(', ')}${options.check ? ' (run `openspec sync`)' : ''}` + ); + } else { + console.log(` ✗ ${change.change} — ${change.error}`); + } + } + } +} + +/** + * Declare a change shipped and fold its deltas — the two halves of the old + * archive, minus the move, emitted as one working-tree diff so the commit + * that declares "shipped" is the same commit whose tree satisfies the + * shipped ⇒ folded predicate. Restores archive's declare+fold atomicity as + * a convenience instead of a mandate: `ship` is sugar over editing the + * status field and running `sync` by hand, never the only way. + */ +export class ShipCommand { + async execute( + changeName: string, + targetPath: string = '.', + options: { json?: boolean } = {} + ): Promise { + const mode = resolveLifecycle(targetPath); + if (mode !== 'status') { + throw new Error( + 'This project uses `lifecycle: archive` (the default) — finish changes with `openspec archive`. `openspec ship` applies under `lifecycle: status`; see openspec/config.yaml.' + ); + } + + const changeDir = 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( + `Change '${changeName}' has no .openspec.yaml — nothing records its lifecycle state.` + ); + } + + if (metadata.status !== 'shipped') { + writeChangeMetadata(changeDir, { ...metadata, status: 'shipped' }, targetPath); + if (!options.json) { + console.log(` ${changeName}: status → shipped`); + } + } else if (!options.json) { + console.log(` ${changeName}: already shipped`); + } + + return new SyncCommand().execute(changeName, targetPath, { json: options.json }); + } +} diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 803405953e..7b904f0c9a 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -2,7 +2,8 @@ import path from 'path'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; import { formatLocalDate } from './date.js'; -import { readProjectConfig } from '../core/project-config.js'; +import { readProjectConfig, resolveLifecycle } from '../core/project-config.js'; +import { discoverChanges, isReservedChangeId } from '../core/change-discovery.js'; import { isKebabId } from '../core/id.js'; import { resolveSchema } from '../core/artifact-graph/resolver.js'; import { isSpecsArtifactPath } from '../core/artifact-graph/outputs.js'; @@ -103,6 +104,16 @@ export function validateChangeName(name: string): ValidationResult { return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' }; } + // A name the resolver refuses is a change nothing could later address, so it + // must not be creatable either: 'archive' collides with the archive + // directory, and a bare year is indistinguishable from a creation-date shard. + if (isReservedChangeId(name)) { + return { + valid: false, + error: `Change name '${name}' is reserved: 'archive' and bare four-digit years name directories the change layout owns`, + }; + } + return { valid: true }; } @@ -159,13 +170,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/`); + } const schema = resolveSchema(schemaName, projectRoot); const skipsSpecs = !schema.artifacts.some(artifact => @@ -177,13 +200,16 @@ export async function createChange( // 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 ( @@ -193,11 +219,16 @@ export async function createChange( await FileSystemUtils.writeFile(configPath, `schema: ${defaultSchema}\n`); } - // Write metadata file with schema and creation date + // Write metadata file with schema and creation date. Under + // `lifecycle: status` a change is born `proposed` — explicit from the start, + // so no change in that mode ever has an ambiguous lifecycle state. writeChangeMetadata(changeDir, { schema: schemaName, - created: formatLocalDate(), + // The same reading that chose the shard directory: a second call could + // cross local midnight and date the metadata a day off its own path. + created, ...(skipsSpecs ? { skip_specs: true } : {}), + ...(resolveLifecycle(projectRoot) === 'status' ? { status: 'proposed' as const } : {}), ...options.metadata, }, projectRoot); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index b120bb0faf..a034e28308 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -728,7 +728,7 @@ Then expected result happens`; const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain('# test-capability Specification'); expect(updatedContent).toContain('## Purpose'); - expect(updatedContent).toContain(`created by archiving change ${changeName}`); + expect(updatedContent).toContain(`created from change ${changeName}`); expect(updatedContent).toContain('## Requirements'); expect(updatedContent).toContain('### Requirement: The system SHALL provide test capability'); expect(updatedContent).toContain('#### Scenario: Basic test'); @@ -1206,7 +1206,7 @@ The system SHALL award loyalty points on each completed order. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'loyalty', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain('Tracks loyalty points earned and redeemed across the storefront.'); - expect(updatedContent).not.toContain('TBD - created by archiving change'); + expect(updatedContent).not.toContain('TBD - created from change'); expect(updatedContent).toContain('### Requirement: Earn Points'); }); @@ -1242,7 +1242,7 @@ The system SHALL normalize config files on load. // The fenced example is part of the authored Purpose - masking fenced // lines out of the body would silently truncate it. expect(updatedContent).toContain('retries: 3'); - expect(updatedContent).not.toContain('TBD - created by archiving change'); + expect(updatedContent).not.toContain('TBD - created from change'); }); it('should keep the TBD Purpose placeholder when the delta has no Purpose (issue #1413)', async () => { @@ -1266,7 +1266,7 @@ The system SHALL send a referral invite. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'referrals', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); }); @@ -1296,7 +1296,7 @@ Illustration only - not this capability's purpose. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'payouts', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain("Illustration only - not this capability's purpose.\n## Requirements"); }); @@ -1324,7 +1324,7 @@ The system SHALL send a notification. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'notifications', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); }); @@ -1358,7 +1358,7 @@ The system SHALL handle widgets. const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'widgets', 'spec.md'); const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('### Requirement: Stray header'); expect(updatedContent).toContain('### Requirement: Real Requirement'); @@ -1401,7 +1401,7 @@ The system SHALL handle gadgets. 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('# Not a spec title'); expect(console.log).toHaveBeenCalledWith( @@ -1444,7 +1444,7 @@ retries: 3 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); // Exactly one Requirements section, and the requirement is still visible. expect(updatedContent.match(/^## Requirements$/gm)).toHaveLength(1); @@ -1566,7 +1566,7 @@ The system SHALL track widgets. // lands in the file, where it can hide the headers the parsers rely on // and blank the document out in a markdown renderer. expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain(' transform --> sink'); - expect(updatedContent).not.toContain('TBD - created by archiving change'); + expect(updatedContent).not.toContain('TBD - created from change'); }); it('should keep the TBD placeholder when the delta Purpose is only a code fence (issue #1413)', async () => { @@ -1688,7 +1688,7 @@ The system SHALL retry failed requests. 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('retries: 3'); }); @@ -1763,7 +1763,7 @@ The system SHALL do the thing. 'utf-8' ); expect(updatedContent).toContain( - `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + `TBD - created from change ${changeName}. Update Purpose.` ); expect(updatedContent).not.toContain('New capabilities only'); }); diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5b23a5d712..97ba1543ed 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -48,6 +48,71 @@ describe('ListCommand', () => { expect(logOutput).toEqual(['No active changes found.']); }); + it('filters changes by lifecycle status and reports an empty match honestly', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + for (const [name, status] of [['add-oauth', 'shipped'], ['add-billing', 'proposed']]) { + await fs.mkdir(path.join(changesDir, name), { recursive: true }); + await fs.writeFile( + path.join(changesDir, name, '.openspec.yaml'), + `schema: spec-driven\nstatus: ${status}\n` + ); + await fs.writeFile(path.join(changesDir, name, 'tasks.md'), '- [x] 1.1 done\n'); + } + + const listCommand = new ListCommand(); + + await listCommand.execute(tempDir, 'changes', { status: 'shipped' }); + expect(logOutput.join('\n')).toContain('add-oauth'); + expect(logOutput.join('\n')).not.toContain('add-billing'); + + logOutput = []; + await listCommand.execute(tempDir, 'changes', { status: 'proposed' }); + expect(logOutput.join('\n')).toContain('add-billing'); + expect(logOutput.join('\n')).not.toContain('add-oauth'); + }); + + it('says so when a valid status matches nothing', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(path.join(changesDir, 'add-oauth'), { recursive: true }); + await fs.writeFile( + path.join(changesDir, 'add-oauth', '.openspec.yaml'), + 'schema: spec-driven\nstatus: shipped\n' + ); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'changes', { status: 'proposed' }); + + expect(logOutput.join('\n')).toContain("No changes with status 'proposed'"); + }); + + it('keeps task status and lifecycle as separate JSON fields', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(path.join(changesDir, 'add-oauth'), { recursive: true }); + await fs.writeFile( + path.join(changesDir, 'add-oauth', '.openspec.yaml'), + 'schema: spec-driven\nstatus: shipped\n' + ); + await fs.writeFile(path.join(changesDir, 'add-oauth', 'tasks.md'), '- [x] 1.1 done\n'); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'changes', { json: true }); + + const payload = JSON.parse(logOutput.join('\n')); + expect(payload.changes[0].status).toBe('complete'); + expect(payload.changes[0].lifecycle).toBe('shipped'); + }); + + it('rejects an unknown --status value instead of silently matching nothing', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + const listCommand = new ListCommand(); + + await expect( + listCommand.execute(tempDir, 'changes', { status: 'bogus' }) + ).rejects.toThrow(/Unknown status 'bogus'/); + }); + it('should not report a malformed openspec/changes path as empty', async () => { await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); diff --git a/test/core/sync.test.ts b/test/core/sync.test.ts new file mode 100644 index 0000000000..413783c93a --- /dev/null +++ b/test/core/sync.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SyncCommand, ShipCommand } from '../../src/core/sync.js'; +import { ArchiveCommand } from '../../src/core/archive.js'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), +})); + +const DELTA = `# Auth - Changes + +## ADDED Requirements + +### Requirement: The system SHALL support OAuth login + +#### Scenario: OAuth round trip +- **WHEN** a user signs in with a provider +- **THEN** a session is established +`; + +describe('SyncCommand', () => { + let tempDir: string; + let logs: string[]; + const originalLog = console.log; + const originalExitCode = process.exitCode; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sync-test-')); + logs = []; + console.log = (...args: unknown[]) => { + logs.push(args.join(' ')); + }; + process.exitCode = undefined; + }); + + afterEach(async () => { + console.log = originalLog; + process.exitCode = originalExitCode; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function scaffold(options: { + lifecycle?: 'archive' | 'status'; + status?: 'proposed' | 'shipped'; + }): Promise { + const openspec = path.join(tempDir, 'openspec'); + await fs.mkdir(path.join(openspec, 'specs'), { recursive: true }); + const changeDir = path.join(openspec, 'changes', 'add-oauth'); + await fs.mkdir(path.join(changeDir, 'specs', 'auth'), { recursive: true }); + + const lifecycleLine = options.lifecycle ? `lifecycle: ${options.lifecycle}\n` : ''; + await fs.writeFile( + path.join(openspec, 'config.yaml'), + `schema: spec-driven\n${lifecycleLine}` + ); + + const statusLine = options.status ? `status: ${options.status}\n` : ''; + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + `schema: spec-driven\ncreated: 2026-08-11\n${statusLine}` + ); + await fs.writeFile(path.join(changeDir, 'specs', 'auth', 'spec.md'), DELTA); + } + + function targetSpec(): string { + return path.join(tempDir, 'openspec', 'specs', 'auth', 'spec.md'); + } + + it('reports nothing to gate under lifecycle: archive', async () => { + await scaffold({ lifecycle: 'archive', status: 'shipped' }); + const report = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(report.clean).toBe(true); + expect(report.mode).toBe('archive'); + expect(logs.join('\n')).toContain('lifecycle: archive'); + }); + + it('check fails on a shipped change whose delta is not folded', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + const report = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(report.clean).toBe(false); + expect(logs.join('\n')).toContain('add-oauth'); + expect(logs.join('\n')).toContain('auth'); + }); + + it('ignores proposed changes: their deltas stay out of specs/', async () => { + await scaffold({ lifecycle: 'status', status: 'proposed' }); + const report = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(report.clean).toBe(true); + await expect(fs.access(targetSpec())).rejects.toThrow(); + }); + + it('folds a shipped change, then check passes and a re-run is a no-op', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + + const fold = await new SyncCommand().execute(undefined, tempDir, {}); + expect(fold.clean).toBe(true); + const folded = await fs.readFile(targetSpec(), 'utf-8'); + expect(folded).toContain('OAuth login'); + + logs = []; + const check = await new SyncCommand().execute(undefined, tempDir, { check: true }); + expect(check.clean).toBe(true); + + await new SyncCommand().execute(undefined, tempDir, {}); + const refolded = await fs.readFile(targetSpec(), 'utf-8'); + expect(refolded).toBe(folded); + }); + + it('fails closed when the changes directory cannot be enumerated', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + // A file where changes/ should be: readable project, unreadable tree. A + // gate that reports green here is worse than no gate. + await fs.rm(path.join(tempDir, 'openspec', 'changes'), { recursive: true, force: true }); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); + + await expect( + new SyncCommand().execute(undefined, tempDir, { check: true, silent: true }) + ).rejects.toThrow(); + }); + + it('treats an absent changes directory as no changes', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + await fs.rm(path.join(tempDir, 'openspec', 'changes'), { recursive: true, force: true }); + + const report = await new SyncCommand().execute(undefined, tempDir, { + check: true, + silent: true, + }); + expect(report.clean).toBe(true); + expect(report.changes).toEqual([]); + }); + + it('silent mode emits nothing and still returns the report', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + const report = await new SyncCommand().execute(undefined, tempDir, { + check: true, + silent: true, + }); + expect(report.clean).toBe(false); + expect(logs).toEqual([]); + }); + + it('reports unreadable metadata as the same conflict entry named or swept', async () => { + await scaffold({ lifecycle: 'status', status: 'shipped' }); + await fs.writeFile( + path.join(tempDir, 'openspec', 'changes', 'add-oauth', '.openspec.yaml'), + 'status: [unclosed\n' + ); + + const swept = await new SyncCommand().execute(undefined, tempDir, { check: true, silent: true }); + const named = await new SyncCommand().execute('add-oauth', tempDir, { check: true, silent: true }); + + for (const report of [swept, named]) { + expect(report.clean).toBe(false); + expect(report.changes).toHaveLength(1); + expect(report.changes[0].state).toBe('conflict'); + expect(report.changes[0].error).toBeTruthy(); + } + }); + + it('refuses to fold an explicitly named change that is not shipped', async () => { + await scaffold({ lifecycle: 'status', status: 'proposed' }); + await expect( + new SyncCommand().execute('add-oauth', tempDir, {}) + ).rejects.toThrow(/only shipped changes fold/); + }); + + it('ship flips status and folds in one step; re-ship is a no-op', async () => { + await scaffold({ lifecycle: 'status', status: 'proposed' }); + + const shipped = await new ShipCommand().execute('add-oauth', tempDir, {}); + expect(shipped.clean).toBe(true); + const metadata = await fs.readFile( + path.join(tempDir, 'openspec', 'changes', 'add-oauth', '.openspec.yaml'), + 'utf-8' + ); + expect(metadata).toContain('status: shipped'); + const folded = await fs.readFile(targetSpec(), 'utf-8'); + expect(folded).toContain('OAuth login'); + + const reshipped = await new ShipCommand().execute('add-oauth', tempDir, {}); + expect(reshipped.clean).toBe(true); + expect(await fs.readFile(targetSpec(), 'utf-8')).toBe(folded); + }); + + it('ship refuses under lifecycle: archive and points at the archive workflow', async () => { + await scaffold({ lifecycle: 'archive', status: 'proposed' }); + await expect( + new ShipCommand().execute('add-oauth', tempDir, {}) + ).rejects.toThrow(/openspec archive/); + }); +}); + +describe('ArchiveCommand under lifecycle: status', () => { + let tempDir: string; + const originalExitCode = process.exitCode; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sync-archive-test-')); + const openspec = path.join(tempDir, 'openspec'); + await fs.mkdir(path.join(openspec, 'specs'), { recursive: true }); + await fs.mkdir(path.join(openspec, 'changes', 'add-oauth'), { recursive: true }); + await fs.writeFile( + path.join(openspec, 'config.yaml'), + 'schema: spec-driven\nlifecycle: status\n' + ); + await fs.writeFile( + path.join(openspec, 'changes', 'add-oauth', '.openspec.yaml'), + 'schema: spec-driven\nstatus: shipped\n' + ); + }); + + afterEach(async () => { + process.exitCode = originalExitCode; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('refuses to archive and points at the status workflow', async () => { + const cwd = process.cwd(); + process.chdir(tempDir); + try { + await expect( + new ArchiveCommand().execute('add-oauth', { yes: true }) + ).rejects.toThrow(/lifecycle: status/); + } finally { + process.chdir(cwd); + } + }); + + it('refuses in JSON mode with a diagnostic and leaves the change in place', async () => { + const cwd = process.cwd(); + const logs: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.join(' ')); + }; + process.chdir(tempDir); + process.exitCode = undefined; + try { + await new ArchiveCommand().execute('add-oauth', { yes: true, json: true }); + } finally { + console.log = originalLog; + process.chdir(cwd); + } + + const payload = JSON.parse(logs.join('\n')); + expect(payload.archive).toBeNull(); + expect(payload.status?.[0]?.code).toBe('lifecycle_status_mode'); + expect(process.exitCode).toBe(1); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'add-oauth')) + ).resolves.not.toThrow(); + }); +}); diff --git a/test/specs/source-specs-normalization.test.ts b/test/specs/source-specs-normalization.test.ts index 2611a85f9d..5caf929935 100644 --- a/test/specs/source-specs-normalization.test.ts +++ b/test/specs/source-specs-normalization.test.ts @@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const specsRoot = path.join(projectRoot, 'openspec', 'specs'); -const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created by archiving change .*?\. Update Purpose after archive\./; +const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created from change .*?\. Update Purpose\./; const REQUIREMENT_HEADER_PATTERN = /^###\s+Requirement:/gm; async function getSpecFiles(): Promise { From 3fce61d255de659629e23263bd134268b6ba9d55 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 20 Aug 2026 00:43:07 +0300 Subject: [PATCH 3/4] feat(lifecycle): creation-date sharding and bidirectional `openspec migrate` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If nothing ever moves, changes/ accumulates, so under `lifecycle: status` a change is stored at changes/YYYY/MM/DD-/ — sharded by a date assigned at birth. Creation date specifically, because it can never change; sharding by shipped date would smuggle the move back in. `openspec migrate` converts a project between the two modes in either direction, moving only bookkeeping. Neither direction touches spec text: archive-mode specs/ is folded shipped reality, which is exactly what status mode maintains, so reversal is a pure relayout. An experiment users can leave is an experiment that can actually be removed. The forward direction refuses before moving anything when the result would contain two changes sharing a bare id — the case a legacy name reused across archive eras produces, which the archive date prefix permits — and it writes the config line last so an interrupted run resumes. The reverse direction refuses while any shipped change has unfolded deltas, reusing the gate's own verdict rather than reimplementing it, since the archive layout asserts folds that must already exist. This commit also registers sync, ship and migrate on the CLI and in the completion registry. Note for review: this layout is the part of the proposal I expect to lose. #1367 answers the same question with user-chosen domains found by a leaf marker rather than a date convention parsed out of regexes, which is the better mechanism — and if both landed, a domain named 2026 would be ambiguous with a year shard. The mode above does not depend on which layout wins, only on nothing moving. Co-Authored-By: Claude Opus 5 --- src/cli/index.ts | 72 +++- src/core/completions/command-registry.ts | 52 +++ src/core/lifecycle-migrate.ts | 366 +++++++++++++++++++ test/core/lifecycle-sharding.test.ts | 426 +++++++++++++++++++++++ 4 files changed, 915 insertions(+), 1 deletion(-) 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 d1bb282998..0f4a289040 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,6 +19,8 @@ import { } from '../core/version-check.js'; 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'; @@ -355,10 +357,11 @@ program .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') + .option('--status ', 'Filter changes by lifecycle status (proposed, shipped)') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; status?: string; json?: boolean; store?: string; storePath?: string }) => { try { const root = await resolveRootForCommand(options ?? {}, { json: options?.json, @@ -376,6 +379,7 @@ program await listCommand.execute(root.path, mode, { sort, json: options?.json, + ...(options?.status ? { status: options.status } : {}), ...(options?.json ? { root: toRootOutput(root) } : {}), }); } catch (error) { @@ -493,6 +497,72 @@ program } }); +program + .command('sync [change-name]') + .description( + "Fold shipped changes' spec deltas into main specs (projects with `lifecycle: status`)" + ) + .option('--check', 'Verify only: exit 1 if a shipped change has unfolded deltas') + .option('--json', 'Output as JSON (non-interactive)') + .action(async (changeName?: string, options?: { check?: boolean; json?: boolean }) => { + try { + const report = await new SyncCommand().execute(changeName, '.', options ?? {}); + if (!report.clean) { + process.exitCode = 1; + } + } catch (error) { + failWithError(error, { enabled: options?.json, fallbackCode: 'sync_error' }); + process.exit(1); + } + }); + +program + .command('ship ') + .description( + 'Declare a change shipped and fold its deltas into main specs, as one diff (projects with `lifecycle: status`)' + ) + .option('--json', 'Output as JSON (non-interactive)') + .action(async (changeName: string, options?: { json?: boolean }) => { + try { + const report = await new ShipCommand().execute(changeName, '.', options ?? {}); + if (!report.clean) { + process.exitCode = 1; + } + } catch (error) { + failWithError(error, { enabled: options?.json, fallbackCode: 'ship_error' }); + process.exit(1); + } + }); + +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')`); + } + // Resolved rather than assumed: migrating rewrites a project's layout, so + // it must act on the root the rest of the CLI reports on, not on whichever + // directory the user happened to be standing in. No `--store` yet: that + // flag's surface is mirrored in generated skill guidance, so adding it + // here belongs in its own change. + const root = await resolveRootForCommand({}); + if (!root) { + return; + } + await new MigrateCommand().execute(root.path, { to, dryRun: options?.dryRun }); + } catch (error) { + failWithError(error); + process.exit(1); + } + }); + registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 67cd8d8172..93f121047f 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -73,6 +73,12 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, values: ['recent', 'name'], }, + { + name: 'status', + description: 'Filter changes by lifecycle status (proposed, shipped)', + takesValue: true, + values: ['proposed', 'shipped'], + }, COMMON_FLAGS.json, COMMON_FLAGS.store, ], @@ -181,6 +187,52 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ COMMON_FLAGS.store, ], }, + { + name: 'sync', + description: "Fold shipped changes' spec deltas into main specs (lifecycle: status projects)", + acceptsPositional: true, + positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], + flags: [ + { + name: 'check', + description: 'Verify only: exit 1 if a shipped change has unfolded deltas', + }, + { + name: 'json', + description: 'Output as JSON (non-interactive)', + }, + ], + }, + { + name: 'ship', + description: 'Declare a change shipped and fold its deltas into main specs (lifecycle: status projects)', + acceptsPositional: true, + positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id' }], + flags: [ + { + name: 'json', + description: 'Output as JSON (non-interactive)', + }, + ], + }, + { + name: '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/lifecycle-migrate.ts b/src/core/lifecycle-migrate.ts new file mode 100644 index 0000000000..b74649aaf0 --- /dev/null +++ b/src/core/lifecycle-migrate.ts @@ -0,0 +1,366 @@ +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'; +import { formatLocalDate } from '../utils/date.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 = formatLocalDate(); + + 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 = formatLocalDate(); + + // 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; + // A change already in its destination still needs stamping: under + // --to archive a proposed change is flat in both layouts, and skipping + // the stamp would leave its `status` key behind for a later forward + // migration to read as authoritative. + if (move.from !== move.to) { + 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 (error) { + // Absent is genuinely empty; unreadable is not. This result gates the + // removal of changes/archive/, so treating EACCES as "nothing there" + // would delete a directory whose contents were never enumerated. + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { + return []; + } + throw error; + } + } + + 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/test/core/lifecycle-sharding.test.ts b/test/core/lifecycle-sharding.test.ts new file mode 100644 index 0000000000..f53bdc0d4c --- /dev/null +++ b/test/core/lifecycle-sharding.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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('propagates an unreadable shard instead of reporting it empty', async () => { + const changes = path.join(tempDir, 'changes'); + const month = path.join(changes, '2026', '03'); + await fs.mkdir(path.join(month, '15-hidden-by-eacces'), { recursive: true }); + + // Injected rather than produced with chmod: permissions do not constrain + // root and do not exist on Windows, and the property under test is how the + // walk reacts to EACCES, not how the OS produces one. An unreadable month + // shard hides shipped changes as effectively as an unreadable root. + const realReaddir = fs.readdir.bind(fs); + const spy = vi + .spyOn(fs, 'readdir') + .mockImplementation((async (dir: string, opts: unknown) => { + if (String(dir) === month) { + const denied: NodeJS.ErrnoException = new Error('EACCES: permission denied'); + denied.code = 'EACCES'; + throw denied; + } + return (realReaddir as (d: string, o: unknown) => Promise)(dir, opts); + }) as unknown as typeof fs.readdir); + + try { + await expect(discoverChanges(changes)).rejects.toThrow(/EACCES/); + } finally { + spy.mockRestore(); + } + }); + + it.skipIf(process.platform === 'win32')( + 'treats a compatibility symlink and its target as one change', + async () => { + const changes = path.join(tempDir, 'changes'); + const sharded = path.join(changes, '2026', '03', '15-aliased'); + await fs.mkdir(sharded, { recursive: true }); + // The shape a project would leave behind after sharding, so old paths + // keep working. It is one change, and calling it ambiguous would fail a + // tree that is fine. + await fs.symlink(sharded, path.join(changes, 'aliased')); + + expect(await resolveChangeDir(changes, 'aliased')).toBe(sharded); + } + ); + + it('refuses an id claimed by both a flat and a sharded directory', async () => { + const changes = path.join(tempDir, 'changes'); + await fs.mkdir(path.join(changes, 'dupe'), { recursive: true }); + await fs.mkdir(path.join(changes, '2026', '03', '15-dupe'), { recursive: true }); + + // The flat directory must not win silently: list shows both, so resolving + // to one of them would act on a different change than the listing names. + await expect(resolveChangeDir(changes, 'dupe')).rejects.toThrow(/ambiguous/); + }); + + 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('refuses to create a change whose id could never be resolved', 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' + ); + + // Both pass the kebab grammar, and both name directories the layout owns. + await expect(createChange(tempDir, 'archive')).rejects.toThrow(/reserved/); + await expect(createChange(tempDir, '2026')).rejects.toThrow(/reserved/); + }); + + 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('strips status from a proposed change that does not move', async () => { + await new MigrateCommand().execute(tempDir, {}); + await new MigrateCommand().execute(tempDir, { to: 'archive' }); + + // batch-upload is flat under both layouts, so its reverse move is a no-op. + // The stamp is a separate obligation: a surviving status key would be read + // as authoritative by a later forward migration. + const meta = await fs.readFile( + path.join(tempDir, 'openspec', 'changes', 'batch-upload', '.openspec.yaml'), + 'utf-8' + ); + expect(meta).not.toContain('status:'); + expect(meta).toContain('created: 2026-08-01'); + }); + + 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 27bf9dc2f83377c4b113164ad5dc2cb4cbd1bea5 Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Thu, 20 Aug 2026 00:43:15 +0300 Subject: [PATCH 4/4] docs(lifecycle): dogfood the change proposal and add a changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo tracks its own features as OpenSpec changes, so this one is tracked as one: proposal, design note, tasks, and three capability specs covering the mode, the layout discovery, and the migration. The design note records the reasoning that is not visible in the diff — why the state set is closed at two, why folded-ness is decided by regeneration rather than bookkeeping, why archive and status refuse each other, and why the layout decision should defer to #1367 if that lands. Co-Authored-By: Claude Opus 5 --- .changeset/add-lifecycle-status-mode.md | 5 + .../add-lifecycle-status-mode/.openspec.yaml | 2 + .../add-lifecycle-status-mode/design.md | 83 +++++++++++++ .../add-lifecycle-status-mode/proposal.md | 52 ++++++++ .../specs/change-layout-discovery/spec.md | 45 +++++++ .../specs/lifecycle-migration/spec.md | 63 ++++++++++ .../specs/lifecycle-status-mode/spec.md | 117 ++++++++++++++++++ .../add-lifecycle-status-mode/tasks.md | 71 +++++++++++ 8 files changed, 438 insertions(+) create mode 100644 .changeset/add-lifecycle-status-mode.md create mode 100644 openspec/changes/add-lifecycle-status-mode/.openspec.yaml create mode 100644 openspec/changes/add-lifecycle-status-mode/design.md create mode 100644 openspec/changes/add-lifecycle-status-mode/proposal.md create mode 100644 openspec/changes/add-lifecycle-status-mode/specs/change-layout-discovery/spec.md create mode 100644 openspec/changes/add-lifecycle-status-mode/specs/lifecycle-migration/spec.md create mode 100644 openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md create mode 100644 openspec/changes/add-lifecycle-status-mode/tasks.md diff --git a/.changeset/add-lifecycle-status-mode.md b/.changeset/add-lifecycle-status-mode.md new file mode 100644 index 0000000000..92fe24d04b --- /dev/null +++ b/.changeset/add-lifecycle-status-mode.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add an opt-in experimental `lifecycle: status` mode, in which a change's lifecycle state is a field in its metadata rather than its position in the filesystem. Under `lifecycle: status` a change carries `status: proposed | shipped` in `.openspec.yaml` and never moves: `openspec sync` folds every shipped change's deltas into `openspec/specs/` idempotently, `openspec sync --check` gates the `shipped ⇒ folded` predicate deterministically for pre-commit, pre-push and CI, and `openspec ship ` declares and folds in one diff. Changes are stored sharded by their immutable creation date (`changes/YYYY/MM/DD-/`), discovered by one shared implementation that reads both layouts, and `openspec migrate` converts a project between the two modes in either direction without touching spec text. `openspec list` gains a lifecycle column and `--status` filter, and `openspec archive` refuses under status mode so the two models stay disjoint. Projects that do not set `lifecycle` resolve to `archive` and are entirely unaffected. diff --git a/openspec/changes/add-lifecycle-status-mode/.openspec.yaml b/openspec/changes/add-lifecycle-status-mode/.openspec.yaml new file mode 100644 index 0000000000..149631464a --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/add-lifecycle-status-mode/design.md b/openspec/changes/add-lifecycle-status-mode/design.md new file mode 100644 index 0000000000..a72486a395 --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/design.md @@ -0,0 +1,83 @@ +## Context + +`archive` conflates a state transition with a text merge. The merge itself is fine; welding it to a directory move is what makes it hard to schedule. On a solo repo the two are indistinguishable. On a team with review, every possible moment to run `archive` is wrong somewhere: + +| Moment | Why it breaks | +|---|---| +| During the PR | Review feedback invalidates the fold; un-archive does not exist and re-archive is not a no-op | +| After merge | A bot commit to a protected branch, racing concurrent merges | +| At approval | GitLab has no approval event (`CI_MERGE_REQUEST_APPROVED` is pre-pipeline), and pushes reset approvals | + +## Goals / Non-Goals + +**Goals** + +- Make lifecycle state a first-class fact that merges trivially and can be edited to correct a mistake. +- Make the fold a standalone, idempotent operation that is safe to run late, twice, or never-yet. +- Make "is this repo consistent?" a pure function of the working tree, so one predicate gates pre-commit, pre-push and CI. +- Change nothing for projects that do not opt in. + +**Non-Goals** + +- Concurrent modification of the same requirement by two open changes (see #1669 and the parallel-merge plan). +- Replacing the archive workflow. This is an experiment with an exit; if it does not graduate, it is removed. +- Capability maturity tags. Those describe requirements, not changes. + +## Decisions + +### The state set is closed, and every state has machine consequences + +`status: proposed | shipped`. Two states, because a state with no attached consequence is a comment: + +- `shipped` means "these deltas belong in `specs/`" — what `sync` folds and what `--check` gates. +- `proposed` means "this change holds a live claim on the requirements it touches" — what overlap and drift tooling can reason over without inferring liveness from a directory path. + +An `applied` state was prototyped and dropped: "implementation done" is already recorded by `tasks.md` checkboxes, and a duplicate record drifts. Further states are possible later — `abandoned` would release the live claim — but each must earn its place with a consequence. + +### "Folded" is decided by regeneration, not bookkeeping + +A change is in sync when re-applying its delta to the current spec produces byte-identical output. No lockfile, no hash sidecar, no timestamp comparison — the check rebuilds and compares. + +This costs O(shipped history) per run rather than O(active changes), which is negligible for young histories and is the reason a `--changed` scope is named as future work rather than shipped here. In exchange the gate has no state of its own to corrupt, and — importantly — `--check` and the fold share one code path. A checker that reimplements the doer is how #1112 happened: `validate` passed what `archive` then refused. Here the only difference between checking and doing is whether the rebuilt bytes get written. + +### The gate is a tree predicate, not a timing condition + +`shipped ⇒ folded`. This is what makes the mode enforceable rather than merely conventional. A timing condition ("archive ran at the right moment") cannot be evaluated mid-PR, precisely when the invariant is supposed to be violated. A tree predicate can be evaluated on any tree by anyone: + +```sh +openspec sync --check # pre-commit · pre-push · CI — same command, same verdict +``` + +Hooks are advisory (`--no-verify` skips them), so CI remains the authority for the tree-level property. The one property that inverts this is atomicity: whether declaring and folding happened in the *same commit* is a history-level fact that CI, which sees only the head tree, is structurally blind to. `ship` makes the atomic path the default one, and a pre-push sweep over the pushed range can enforce it where a team cares. + +### `archive` refuses rather than coexists + +Under `lifecycle: status`, `openspec archive` throws and names the alternative. Two models that can both claim a change is finished would let `specs/` disagree with itself. The refusal is what keeps `specs/` = shipped reality true in both modes, which is also what makes migration between them a pure relayout: neither mode's `specs/` content differs. + +### Layout shards by creation date, which is immutable + +If nothing ever moves, `changes/` accumulates. The layout shards by a date **assigned at birth**: `changes/2026/03/15-add-oauth/`. Creation date is chosen precisely because it can never change — sharding by *shipped* date would smuggle the move back in, which is the thing this design removes. The day prefix keeps the full date in the path and `ls` chronological, carrying the same information today's `archive/YYYY-MM-DD-/` carries, relocated from the contested end of the lifecycle to the fixed one. + +Discovery reads both layouts by rule: `YYYY` and `MM` directories are shards to walk into, anything else is a change. That keeps flat projects working untouched and makes the layout a storage detail rather than a new contract. + +This is the decision most likely to be superseded. [#1367](https://github.com/Fission-AI/OpenSpec/pull/1367) proposes user-chosen *domains* under `changes/`, discovered by a leaf marker (`.openspec.yaml`/`proposal.md` present) rather than a naming convention. That is a better mechanism, and domains carry meaning a calendar cannot. If it lands, this sharding should be dropped in favor of it, and discovery here should be replaced by that walk — the mode above does not depend on which one wins, only on nothing moving. Noted here rather than resolved because it is upstream's call, not ours. + +### Migration is bidirectional, because an experiment must be leaveable + +`openspec migrate` converts in both directions, and neither direction touches spec text: archive-mode `specs/` is folded shipped reality, which is exactly what status-mode maintains. Reversal is therefore a pure relayout, covered by a round-trip test. + +The reverse direction refuses while any shipped change has unfolded deltas, because the archive layout asserts a fold that must actually exist. It reuses the gate itself rather than reimplementing its verdict — the same anti-drift reasoning as `--check` sharing the fold's code path. + +Two hazards the forward direction has to handle, both consequences of bare change ids: a legacy name reused across archive eras would shard into two directories no bare id can address (refused up front, with the collisions named), and an interrupted run leaves shards that a naive re-run would try to move into themselves (skipped, so the migration resumes). + +## Risks / Trade-offs + +- **`ls` stops being the answer to "what's active."** Once state is data, the filesystem is no longer the UI for state; `openspec list --status proposed` is. This is the honest cost of the whole design and is why the mode is opt-in. +- **The fold diff relocates, it does not disappear.** It lands wherever `sync` ran instead of in the archive commit. Deterministic output makes it reviewable the way a lockfile is: regenerate and compare. +- **Editing a delta after it was folded** re-merges over an earlier fold, which needs base snapshots to do correctly. This window pre-exists; making fold-anytime first-class means it sees more traffic. `sync --check` detects the state and fails closed rather than corrupting `specs/`. `sync` is a natural recording point for the parallel-merge plan's base snapshots when those arrive. + +## Migration + +`openspec migrate` converts a legacy project: archived changes become `status: shipped` sharded by the date their archive folder recorded, in-flight changes become `status: proposed` sharded by their `created` date, the now-empty `archive/` directory is removed, and the config line is written last so an interrupted run is resumable. Nothing is deleted, and `sync --check` verifies the result by regeneration — the engine's own folds re-apply byte-identically, so the gate is green immediately after migrating. + +`openspec migrate --to archive` converts back: shipped changes return to `changes/archive/-/`, proposed changes return to flat `changes//`, the `status` key is stripped (under archive mode, location is the state), and empty shard directories are pruned. One caveat worth stating: a change shipped under status mode carries its *creation* date into an archive folder name where convention reads an *archival* date. That is the only information the round trip cannot preserve, because archive mode never recorded the other one. diff --git a/openspec/changes/add-lifecycle-status-mode/proposal.md b/openspec/changes/add-lifecycle-status-mode/proposal.md new file mode 100644 index 0000000000..d900bb10a5 --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/proposal.md @@ -0,0 +1,52 @@ +## Why + +`archive` does two unrelated jobs in one command: a **state transition** (declaring a change shipped) and a **text merge** (folding deltas into `specs/`). Encoding the transition as a directory move welds the merge to a single moment in the PR lifecycle — and on a team with review, that moment does not exist. Review feedback forces un-archive → edit → re-archive; archiving after merge means a bot commit to a protected branch; and GitLab has no approval event to hang it on. + +The team-workflow docs offer both conventions and say "pick one and be consistent" — a choice of costs, not an answer. + +This change adds an opt-in experimental mode where a change's lifecycle state is a **field in its metadata** rather than its position in the filesystem, so the merge becomes a standalone idempotent command that can run at any time and be checked deterministically in CI. + +## What Changes + +- `openspec/config.yaml` accepts `lifecycle: archive | status`. `archive` is the default and current behavior; nothing changes for existing projects. +- Under `lifecycle: status`, a change's `.openspec.yaml` carries `status: proposed | shipped`. New changes are born `proposed`. +- `openspec sync` folds every `shipped` change's deltas into `specs/`, idempotently. It is the text-merge half of archive, decoupled from any move. +- `openspec sync --check` exits 1 if any `shipped` change has unfolded deltas — a deterministic, model-free gate that runs identically at pre-commit, pre-push and in CI. +- `openspec ship ` sets `status: shipped` and folds in one working-tree diff, restoring archive's declare-and-fold atomicity as a convenience rather than a mandate. +- `openspec list` shows the lifecycle state and accepts `--status ` to filter. +- `openspec archive` refuses to run under `lifecycle: status` and points at the status workflow, so the two models can never both claim a change. + +- Under `lifecycle: status`, changes are stored sharded by their **creation date** — `changes/YYYY/MM/DD-/` — a fact fixed at birth, so location never encodes lifecycle state and nothing ever has to move. Discovery reads both layouts. +- `openspec migrate` converts a project between the two modes in **either direction**, moving only bookkeeping. Neither direction touches spec text. + +## Capabilities + +### New Capabilities + +- `lifecycle-status-mode`: the experimental `lifecycle: status` mode — the config flag, the `status` metadata field, the `sync`/`sync --check`/`ship` commands, the `list` surface, and the `archive` refusal that keeps the two models disjoint. +- `change-layout-discovery`: enumerating and resolving changes across both the flat layout and the creation-date sharded layout, including the ambiguity and containment rules that bare change ids require. +- `lifecycle-migration`: bidirectional conversion between the two modes, its refusal conditions, and its resumability. + +### Modified Capabilities + +_None._ The mode is opt-in and inert under the default `lifecycle: archive`: `sync` reports that the project uses archive mode and exits 0, `ship` refuses and points at `openspec archive`, `list` renders no lifecycle column when no change declares a status, `archive` is untouched, and discovery keeps returning exactly what it returned before for a flat tree. Existing capability specs describe archive-mode behavior, which this change does not alter. + +## Impact + +- `src/core/project-config.ts` — the `lifecycle` config field and its resolver +- `src/core/change-metadata/schema.ts` — the optional `status` field +- `src/core/sync.ts` — new `SyncCommand` and `ShipCommand` +- `src/core/archive.ts` — refusal guard under status mode +- `src/core/list.ts` — lifecycle column and `--status` filter +- `src/utils/change-utils.ts` — new changes are born `proposed` under status mode +- `src/cli/index.ts`, `src/core/completions/command-registry.ts` — command surface and completions +- `src/core/specs-apply.ts` — the generated skeleton's Purpose line no longer says "by archiving", since a fold can now happen without one +- `src/core/change-discovery.ts` — new: layout-agnostic enumeration and id resolution +- `src/core/lifecycle-migrate.ts` — new: bidirectional migration +- `src/commands/change.ts`, `src/commands/validate.ts`, `src/commands/workflow/*`, `src/core/view.ts`, `src/utils/item-discovery.ts`, `src/core/planning-home.ts` — every surface that enumerates or resolves a change now goes through the shared discovery + +## Out of scope + +- **Concurrent modification of the same requirement** by two open changes. This changes *when* the merge may run, not *how* it merges; it composes with the parallel-merge plan and with #1669. +- **Deriving shipped-ness from git.** Git proves a change folder landed on a branch, not that the change was implemented. Status stays an explicit declaration; git facts can cross-check it, not replace it. +- **Capability maturity tags** (`experimental`, `beta`, `deprecated`). Those describe requirements in `specs/`, not changes, and belong there as user-defined semantic labels. Different axis, different proposal. diff --git a/openspec/changes/add-lifecycle-status-mode/specs/change-layout-discovery/spec.md b/openspec/changes/add-lifecycle-status-mode/specs/change-layout-discovery/spec.md new file mode 100644 index 0000000000..5d6cf4f94d --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/specs/change-layout-discovery/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Changes are discovered across both layouts + +Change enumeration SHALL find changes in the flat layout (`changes//`) and in the creation-date sharded layout (`changes/YYYY/MM/DD-/`) from one shared implementation. A four-digit directory SHALL be treated as a year shard and a two-digit directory beneath it as a month shard; any other directory SHALL be treated as a change. A change discovered under a shard SHALL be identified by its directory name with the `DD-` prefix removed. The `archive/` directory and hidden directories SHALL be excluded, as they are today. + +#### Scenario: Mixed tree +- **WHEN** `openspec/changes/` contains `flat-change/`, `2026/03/15-old-change/`, and `archive/2026-01-01-buried/` +- **THEN** enumeration returns exactly `flat-change` and `old-change` + +#### Scenario: Every surface agrees +- **WHEN** a project uses the sharded layout +- **THEN** `openspec list`, `openspec show`, `openspec validate`, `openspec status`, `openspec instructions`, shell completions, and the dashboard view all resolve its changes, and none of them reports a shard directory as a change + +### Requirement: A bare change id resolves unambiguously or not at all + +Resolving a change id SHALL find its directory in either layout. When two shard dates carry the same id, resolution SHALL fail with an error naming both locations rather than choosing one. An id that enumeration could never produce — one containing a path separator or null byte, a dot segment, a hidden name, the reserved `archive` name, or a bare year — SHALL resolve to nothing, so that no id can address a directory outside the change namespace. + +#### Scenario: Ambiguous id is refused +- **WHEN** a user names a change id that exists under two different shard dates +- **THEN** the command fails with an error naming both directories + +#### Scenario: Shard and reserved directories are not changes +- **WHEN** a user names `2026` or `archive` as a change id +- **THEN** resolution finds no change, rather than returning the shard or archive directory + +#### Scenario: Traversing ids are refused +- **WHEN** a user names a change id containing `..` or a path separator +- **THEN** resolution finds no change, and no path outside `openspec/changes/` is read + +### Requirement: New changes are created in the layout their mode implies + +Under `lifecycle: status`, `openspec new change` SHALL create the change at `changes/YYYY/MM/DD-/` using the creation date, and SHALL report the path it actually created. It SHALL NOT create the `changes/archive/` directory, which the mode does not use. Under `lifecycle: archive`, creation SHALL remain flat and unchanged. + +#### Scenario: Sharded creation under status mode +- **WHEN** a user runs `openspec new change add-oauth` in a status-mode project on 2026-08-17 +- **THEN** the change is created at `openspec/changes/2026/08/17-add-oauth/` and the reported path matches + +#### Scenario: The abolished directory is not recreated +- **WHEN** a user runs `openspec new change add-oauth` in a status-mode project +- **THEN** no `openspec/changes/archive/` directory is created + +#### Scenario: A name that could never be resolved is refused at creation +- **WHEN** a user runs `openspec new change archive` or `openspec new change 2026` +- **THEN** the command fails naming the id as reserved, because creation and resolution share one notion of which ids can address a change diff --git a/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-migration/spec.md b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-migration/spec.md new file mode 100644 index 0000000000..cd534c1fb3 --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-migration/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Migration converts a project into status mode without deleting anything + +`openspec migrate` SHALL convert a `lifecycle: archive` project to `lifecycle: status`. Each archived change SHALL become a change with `status: shipped`, sharded by the date its archive folder name recorded; each active change SHALL become `status: proposed`, sharded by its recorded creation date or today's date when it has none. The emptied `archive/` directory SHALL be removed, and the `lifecycle` config line SHALL be written only after every move succeeds. No change directory and no spec file SHALL be deleted. + +#### Scenario: Both eras migrate +- **WHEN** a user runs `openspec migrate` in a project with archived and in-flight changes +- **THEN** archived changes become sharded and `shipped`, in-flight changes become sharded and `proposed`, `changes/archive/` is gone, and `openspec/config.yaml` declares `lifecycle: status` + +#### Scenario: The gate is green immediately after migrating +- **WHEN** a user runs `openspec sync --check` directly after a successful migration +- **THEN** the command exits zero, because each migrated change's delta re-applies to the already-folded spec as a no-op + +#### Scenario: Dry run writes nothing +- **WHEN** a user runs `openspec migrate --dry-run` +- **THEN** the planned moves are printed, and no directory is moved and no config file is modified + +### Requirement: Migration refuses rather than creating unaddressable changes + +Because commands address changes by bare id, `openspec migrate` SHALL refuse before moving anything when the resulting layout would contain two changes with the same id — the case produced by a legacy name reused across archive eras, which the archive date prefix permits. The refusal SHALL name every colliding id and its source directories. Two planned moves resolving to the same destination SHALL be refused on the same grounds. + +#### Scenario: Reused legacy name is refused up front +- **WHEN** a user runs `openspec migrate` in a project containing both `changes/archive/2026-05-12-add-auth/` and an active `changes/add-auth/` +- **THEN** the command fails naming `add-auth` and both directories, and no directory has been moved + +#### Scenario: Dry run reports the same refusal +- **WHEN** a user runs `openspec migrate --dry-run` on a project with a colliding id +- **THEN** the command fails with the same error, rather than printing a plan that could not be applied + +### Requirement: An interrupted migration can be re-run + +`openspec migrate` SHALL be resumable: a re-run after an interrupted migration SHALL skip the shard directories a partial run already created rather than treating them as changes to move, and SHALL complete the remaining work. A migration run against an already-migrated project SHALL report that there is nothing to do. + +#### Scenario: Resuming after interruption +- **WHEN** a user re-runs `openspec migrate` on a project where some changes were already moved into shard directories but the config line was never written +- **THEN** the migration completes, the already-sharded changes stay where they are, and the config declares `lifecycle: status` + +#### Scenario: Already migrated +- **WHEN** a user runs `openspec migrate` on a project already resolving to `lifecycle: status` +- **THEN** the command reports that the project is already on that mode and changes nothing + +### Requirement: Migration is reversible + +`openspec migrate --to archive` SHALL convert a status-mode project back. Shipped changes SHALL return to `changes/archive/-/`, proposed changes SHALL return to flat `changes//`, the `status` key SHALL be removed because location is the state under archive mode, emptied shard directories SHALL be pruned, and the `lifecycle` config line SHALL be removed. No spec text SHALL be modified in either direction. + +The reverse direction SHALL refuse while any shipped change has unfolded deltas, since the archive layout asserts a fold that must already exist, and SHALL determine that using the same check `openspec sync --check` performs. + +#### Scenario: Round trip restores the legacy layout +- **WHEN** a user runs `openspec migrate` and then `openspec migrate --to archive` +- **THEN** shipped changes are back under `changes/archive/` with their dates, active changes are flat, no `status` key remains, no shard directories remain, and the config no longer declares a lifecycle + +#### Scenario: Reversal refuses on an unfolded shipped change +- **WHEN** a user runs `openspec migrate --to archive` in a project where a shipped change has unfolded deltas +- **THEN** the command fails telling the user to run `openspec sync` first, and nothing is moved + +### Requirement: Migration preserves metadata it does not understand + +When stamping a change's `.openspec.yaml`, migration SHALL preserve the file's existing keys, key order, and comments, editing only the fields it owns. Legacy metadata predates the current contract, and a migration that silently drops fields it does not recognize destroys history. + +#### Scenario: Comments and ordering survive +- **WHEN** a change's `.openspec.yaml` carries a comment and keys in a particular order before migration +- **THEN** the migrated file retains that comment and ordering, with only the lifecycle fields added or removed diff --git a/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md new file mode 100644 index 0000000000..3fb4037667 --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/specs/lifecycle-status-mode/spec.md @@ -0,0 +1,117 @@ +## ADDED Requirements + +### Requirement: Projects select a lifecycle mode in config + +`openspec/config.yaml` SHALL accept a `lifecycle` field with the values `archive` or `status`. When the field is absent, unreadable, or carries an unrecognized value, the project SHALL resolve to `archive`, which is the existing behavior. No project acquires status-mode behavior without declaring it. + +#### Scenario: No lifecycle field declared +- **WHEN** a project's `openspec/config.yaml` has no `lifecycle` field +- **THEN** the project resolves to `lifecycle: archive` and every command behaves exactly as before + +#### Scenario: Status mode declared +- **WHEN** a project's `openspec/config.yaml` contains `lifecycle: status` +- **THEN** the project resolves to `lifecycle: status` + +#### Scenario: Unrecognized value falls back rather than failing +- **WHEN** a project's `openspec/config.yaml` contains `lifecycle: bogus` +- **THEN** the project resolves to `lifecycle: archive` rather than raising a fatal error + +### Requirement: A change records its lifecycle state as metadata + +A change's `.openspec.yaml` SHALL accept an optional `status` field with the values `proposed` or `shipped`. Under `lifecycle: status`, a newly created change SHALL be written with `status: proposed` so that no change in that mode has an ambiguous state. Under `lifecycle: archive`, change creation SHALL NOT write a status field. + +#### Scenario: New change under status mode is born proposed +- **WHEN** a user runs `openspec new change add-auth` in a project resolving to `lifecycle: status` +- **THEN** the created `.openspec.yaml` contains `status: proposed` + +#### Scenario: New change under archive mode carries no status +- **WHEN** a user runs `openspec new change add-auth` in a project resolving to `lifecycle: archive` +- **THEN** the created `.openspec.yaml` contains no `status` field + +### Requirement: Sync folds shipped changes into the main specs + +`openspec sync` SHALL fold the spec deltas of every change declaring `status: shipped` into `openspec/specs/`, and SHALL leave the deltas of changes in any other state out of `openspec/specs/`. A change SHALL be considered folded when re-applying its delta to the current spec produces byte-identical output, so that a repeated run writes nothing. + +#### Scenario: Shipped change is folded +- **WHEN** a user runs `openspec sync` in a status-mode project containing a change with `status: shipped` whose delta is not yet in the main spec +- **THEN** the delta is applied to the main spec + +#### Scenario: Proposed change is not folded +- **WHEN** a user runs `openspec sync` in a status-mode project whose only change declares `status: proposed` +- **THEN** the main spec is not created or modified + +#### Scenario: Repeated sync is a no-op +- **WHEN** a user runs `openspec sync` twice in succession +- **THEN** the second run leaves every main spec byte-identical to the first run's output + +#### Scenario: Naming a change that is not shipped +- **WHEN** a user runs `openspec sync ` naming a change whose status is not `shipped` +- **THEN** the command fails with a message stating that only shipped changes fold into `specs/` + +### Requirement: Sync check gates the shipped-implies-folded predicate + +`openspec sync --check` SHALL report whether every `shipped` change's deltas are folded, without writing to `openspec/specs/`, and SHALL cause a non-zero exit when any shipped change has unfolded deltas. A change whose metadata cannot be read SHALL be reported as a conflict rather than skipped, so that the gate fails closed. The check SHALL use the same fold implementation the write path uses. + +#### Scenario: Shipped but unfolded fails the gate +- **WHEN** a user runs `openspec sync --check` in a status-mode project containing a shipped change whose delta is not folded +- **THEN** the command names the change and the affected capability, does not modify any spec, and exits non-zero + +#### Scenario: Fully folded tree passes the gate +- **WHEN** a user runs `openspec sync --check` in a status-mode project where every shipped change is folded +- **THEN** the command exits zero + +#### Scenario: Unreadable metadata fails closed +- **WHEN** a user runs `openspec sync --check` in a status-mode project containing a change whose `.openspec.yaml` cannot be parsed +- **THEN** that change is reported as a conflict and the command exits non-zero + +### Requirement: Ship declares and folds in one diff + +`openspec ship ` SHALL set the named change's status to `shipped` and then fold its deltas, so that the working-tree diff which declares a change shipped is the same diff that satisfies the shipped-implies-folded predicate. Shipping an already-shipped change SHALL be a no-op. + +#### Scenario: Ship flips status and folds +- **WHEN** a user runs `openspec ship add-auth` in a status-mode project where `add-auth` is proposed +- **THEN** the change's `.openspec.yaml` records `status: shipped` and its delta is applied to the main spec + +#### Scenario: Re-shipping changes nothing +- **WHEN** a user runs `openspec ship add-auth` on a change that is already shipped and folded +- **THEN** no spec file is modified + +### Requirement: List surfaces and filters lifecycle state + +`openspec list` SHALL display the lifecycle state of each change that declares one, and SHALL accept `--status ` to show only changes in that state. An unrecognized `--status` value SHALL be rejected with a message naming the valid states, rather than silently matching nothing. + +#### Scenario: Filtering by state +- **WHEN** a user runs `openspec list --status shipped` in a project containing both shipped and proposed changes +- **THEN** only the shipped changes are listed + +#### Scenario: Unknown state is rejected +- **WHEN** a user runs `openspec list --status bogus` +- **THEN** the command fails with a message naming the valid lifecycle states + +### Requirement: Archive and status modes stay disjoint + +Neither mode's commands SHALL act on a project that has selected the other. `openspec archive` SHALL refuse to run in a project resolving to `lifecycle: status`, and `openspec ship` SHALL refuse to run in a project resolving to `lifecycle: archive`; both messages SHALL name the resolved mode and point at the other mode's workflow. `openspec sync` SHALL instead report that there is nothing to gate under `lifecycle: archive` and exit zero, so that a repository-wide gate invocation is harmless in a project that has not opted in. + +#### Scenario: Archive refuses under status mode +- **WHEN** a user runs `openspec archive add-auth` in a status-mode project +- **THEN** the command fails with a message naming `lifecycle: status` and pointing at the status workflow, and no files are moved or modified + +#### Scenario: Ship refuses under archive mode +- **WHEN** a user runs `openspec ship add-auth` in a project resolving to `lifecycle: archive` +- **THEN** the command fails with a message naming `lifecycle: archive` and pointing at `openspec archive`, and no status field is written + +#### Scenario: Sync is a harmless no-op under archive mode +- **WHEN** a user runs `openspec sync --check` in a project resolving to `lifecycle: archive` +- **THEN** the command reports that the project uses archive mode, modifies nothing, and exits zero + +### Requirement: The gate fails closed when it cannot read the tree + +`openspec sync` SHALL treat an absent `openspec/changes/` directory as "no changes" and exit zero, but SHALL propagate any other error encountered while enumerating changes rather than reporting an empty result. A tree the gate cannot read SHALL NOT be reported as a passing tree. + +#### Scenario: Missing changes directory is not an error +- **WHEN** a user runs `openspec sync --check` in a status-mode project that has no `openspec/changes/` directory +- **THEN** the command reports no shipped changes to sync and exits zero + +#### Scenario: Unreadable changes directory fails rather than passing +- **WHEN** a user runs `openspec sync --check` in a status-mode project whose `openspec/changes/` path cannot be enumerated +- **THEN** the command fails rather than reporting a clean tree diff --git a/openspec/changes/add-lifecycle-status-mode/tasks.md b/openspec/changes/add-lifecycle-status-mode/tasks.md new file mode 100644 index 0000000000..f4c58abbe8 --- /dev/null +++ b/openspec/changes/add-lifecycle-status-mode/tasks.md @@ -0,0 +1,71 @@ +## 1. Configuration + +- [x] 1.1 Add the `lifecycle` field to the project config schema with `archive | status` values and `archive` as the default +- [x] 1.2 Add `resolveLifecycle(projectRoot)` and make an unreadable or invalid value fall back to the default rather than throw + +## 2. Metadata + +- [x] 2.1 Add the optional `status: proposed | shipped` field to the change metadata schema +- [x] 2.2 Create new changes with `status: proposed` under `lifecycle: status`, and unchanged under `lifecycle: archive` + +## 3. Sync + +- [x] 3.1 Implement `SyncCommand`: discover `shipped` changes, rebuild each affected spec, write only where the rebuild differs +- [x] 3.2 Decide "folded" by byte-identical regeneration so `--check` and the fold share one code path +- [x] 3.3 Implement `--check`: report without writing, and return a report whose `clean` flag drives the exit code at the CLI edge +- [x] 3.4 Report unreadable metadata as a conflict rather than skipping it, so the gate fails closed +- [x] 3.5 Report nothing to gate and exit 0 under `lifecycle: archive` + +## 4. Ship + +- [x] 4.1 Implement `ShipCommand`: set `status: shipped`, then delegate to `SyncCommand` so both halves land in one diff +- [x] 4.2 Make a re-ship a no-op +- [x] 4.3 Refuse under `lifecycle: archive` and point at `openspec archive` + +## 5. Surfaces + +- [x] 5.1 Show the lifecycle state in `openspec list` and add `--status ` filtering +- [x] 5.2 Reject an unknown `--status` value instead of printing an empty list +- [x] 5.3 Refuse `openspec archive` under `lifecycle: status` and point at the status workflow +- [x] 5.4 Register `sync`, `ship` and `list --status` in the completion command registry +- [x] 5.5 Reword the generated spec skeleton's Purpose line, which claimed the spec was created by archiving + +## 6. Layout + +- [x] 6.1 Enumerate changes across both the flat and creation-date sharded layouts from one shared discovery +- [x] 6.2 Resolve a bare change id in either layout, refusing ids discovery could never produce (separators, dot segments, shard and archive directory names) +- [x] 6.3 Refuse an ambiguous id carried by two shard dates rather than guessing +- [x] 6.4 Create sharded changes under `lifecycle: status`, and skip the `changes/archive/` scaffold the mode abolishes +- [x] 6.5 Route every enumerating and resolving surface — `show`, `validate`, `status`, `instructions`, completions, view — through the shared discovery +- [x] 6.6 Derive a change's name from a sharded path without mistaking the year shard for the change + +## 7. Migration + +- [x] 7.1 Convert archived changes to `status: shipped` and in-flight changes to `status: proposed`, sharded by date, deleting nothing +- [x] 7.2 Write the config line last so an interrupted run is resumable, and skip shard directories left by a partial run +- [x] 7.3 Refuse up front when legacy name reuse would produce an ambiguous bare id, naming the collisions +- [x] 7.4 Implement `--to archive`: shipped to `archive/-/`, proposed to flat, `status` stripped, empty shards pruned +- [x] 7.5 Refuse the reverse direction while a shipped change has unfolded deltas, reusing the gate's verdict rather than reimplementing it +- [x] 7.6 Support `--dry-run` for both directions +- [x] 7.7 Preserve comments and key order when stamping metadata + +## 8. Tests + +- [x] 8.1 Gate is green under `lifecycle: archive` regardless of any status field +- [x] 8.2 Gate fails on a shipped change whose delta is not folded, naming the capability +- [x] 8.3 Proposed changes are not gated and their deltas stay out of `specs/` +- [x] 8.4 Fold then re-check is green, and a second fold is a byte-identical no-op +- [x] 8.5 A named non-shipped change refuses to fold +- [x] 8.6 `ship` flips and folds in one step; re-ship is a no-op; refuses under archive mode +- [x] 8.7 Unreadable metadata produces the same conflict entry whether swept or named +- [x] 8.8 `archive` refuses under `lifecycle: status`, in text and JSON modes +- [x] 8.9 `list` rejects an unknown `--status` value +- [x] 8.10 Discovery finds flat, sharded and mixed trees, strips the day prefix, and skips `archive/` +- [x] 8.11 Ambiguous and hostile ids resolve to a refusal or null rather than a wrong directory +- [x] 8.12 Migration stamps both eras, flips the config, and leaves the gate green +- [x] 8.13 Migration refuses ambiguous legacy name reuse, and resumes after interruption +- [x] 8.14 Round trip restores the legacy layout, with no `status` key surviving + +## 9. Release + +- [x] 9.1 Add a changeset describing the new experimental mode