Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { ListCommand } from '../core/list.js';
import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js';
import { SyncCommand, ShipCommand } from '../core/sync.js';
import { MigrateCommand } from '../core/lifecycle-migrate.js';
import { ViewCommand } from '../core/view.js';
import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js';
import { registerSpecCommand } from '../commands/spec.js';
Expand Down Expand Up @@ -489,6 +490,26 @@ program
}
});

program
.command('migrate')
.description(
'Migrate this project between lifecycle modes (default: to `lifecycle: status`). Both directions move only bookkeeping; nothing is deleted and no spec text changes'
)
.option('--to <mode>', 'Target lifecycle mode: "status" (default) or "archive"', 'status')
.option('--dry-run', 'Print the migration plan without writing anything')
.action(async (options?: { to?: string; dryRun?: boolean }) => {
try {
const to = options?.to ?? 'status';
if (to !== 'status' && to !== 'archive') {
throw new Error(`Unknown lifecycle mode '${to}' (expected 'status' or 'archive')`);
}
await new MigrateCommand().execute('.', { to, dryRun: options?.dryRun });
} catch (error) {
failWithError(error);
process.exit(1);
}
});

registerSpecCommand(program);
registerConfigCommand(program);
registerSchemaCommand(program);
Expand Down
38 changes: 27 additions & 11 deletions src/commands/change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -79,10 +80,13 @@ export class ChangeCommand {
}
}

const changeDir = path.join(changesPath, changeName);
// Resolve in either layout; the flat fallback keeps not-found errors
// pathed and stays behind the traversal guard.
const resolved = await resolveChangeDir(changesPath, changeName);
const changeDir = resolved ?? path.join(changesPath, changeName);
const proposalPath = path.join(changeDir, 'proposal.md');

if (!isChangeDirectoryName(changesPath, changeDir)) {
if (resolved === null && !isChangeDirectoryName(changesPath, changeDir)) {
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
}

Expand Down Expand Up @@ -146,23 +150,30 @@ export class ChangeCommand {
*/
async list(options?: { json?: boolean; long?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');

// Same directory-based resolution as `openspec list`, the command this
// deprecated alias points users at. Every output path below already
// tolerates a change whose proposal.md is missing or unreadable.
const changes = await getActiveChangeIds();
const discovered = await discoverChanges(changesPath).catch(() => []);
const changes = discovered.map((change) => change.id);
const dirs = new Map(discovered.map((change) => [change.id, change.dir]));

if (options?.json) {
const changeDetails = await Promise.all(
changes.map(async (changeName) => {
const changeDir = path.join(changesPath, changeName);
const changeDir = dirs.get(changeName) ?? path.join(changesPath, changeName);
const proposalPath = path.join(changeDir, 'proposal.md');

// Resolve task progress through the shared tracked-tasks helper so
// this deprecated noun-form list cannot re-fork the resolution
// (#1202). Tasks are independent of the proposal: a change can carry
// tasks before, or without, a proposal.md.
const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
// tasks before, or without, a proposal.md. Sharded changes pass
// their relative path; the helper joins changesPath with it.
const taskStatus = await getTaskProgressForChange(
changesPath,
path.relative(changesPath, changeDir),
process.cwd()
);

// No proposal yet is an ordinary state (scaffolded change, or a
// schema with no proposal artifact), so name the change rather than
Expand Down Expand Up @@ -206,9 +217,13 @@ export class ChangeCommand {

// Long format: id: title and minimal counts
for (const changeName of sorted) {
const changeDir = path.join(changesPath, changeName);
const changeDir = dirs.get(changeName) ?? path.join(changesPath, changeName);
const proposalPath = path.join(changeDir, 'proposal.md');
const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
const { total, completed } = await getTaskProgressForChange(
changesPath,
path.relative(changesPath, changeDir),
process.cwd()
);
const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
if (await isDefinitelyMissing(proposalPath)) {
console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`);
Expand Down Expand Up @@ -254,8 +269,9 @@ export class ChangeCommand {
}
}

const changeDir = path.join(changesPath, changeName);
if (!isChangeDirectoryName(changesPath, changeDir)) {
const resolved = await resolveChangeDir(changesPath, changeName);
const changeDir = resolved ?? path.join(changesPath, changeName);
if (resolved === null && !isChangeDirectoryName(changesPath, changeDir)) {
throw new Error(`Change "${changeName}" not found at ${changeDir}`);
}
try {
Expand Down
7 changes: 5 additions & 2 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -215,7 +216,8 @@ export class ValidateCommand {
private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> {
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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/commands/workflow/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
type ArtifactInstructions,
} from '../../core/artifact-graph/index.js';
import {
getChangeDir,
resolvePlanningChangeDir,
resolveCurrentPlanningHomeSync,
type PlanningHome,
} from '../../core/planning-home.js';
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand Down
8 changes: 4 additions & 4 deletions src/commands/workflow/new-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);
Expand Down
24 changes: 8 additions & 16 deletions src/commands/workflow/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string[]> {
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);
}

/**
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/commands/workflow/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -99,7 +99,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {

// 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(
Expand Down
Loading