Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis
`no_openspec_root`, `no_root_with_registered_stores`, `no_registered_stores`, `unknown_store`, `store_identity_mismatch`, `unhealthy_store_root`, `store_path_not_supported`, `invalid_store_pointer`, `initiative_option_removed`, `areas_option_removed`; pass-through: `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`.

### OpenSpec-root health (error, no fix)
`openspec_store_root_missing`, `openspec_root_missing`, `openspec_config_missing`, `openspec_specs_missing`, `openspec_changes_missing`, `openspec_archive_missing`, plus `_not_directory` variants of each.
`openspec_store_root_missing`, `openspec_store_root_not_directory`, `openspec_root_missing`, `openspec_root_not_directory`, `openspec_config_missing`, `openspec_config_not_file`, `openspec_specs_not_directory`, `openspec_changes_not_directory`, `openspec_archive_not_directory`. During the stores beta, `openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` may be absent in a healthy root; they are only health errors when present but not directories.

### Store registry/identity/state
`invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`, `store_registry_busy`, `store_not_found`, `no_store_registry`, `store_registry_changed`, `store_metadata_missing`, `store_metadata_id_mismatch`, `store_metadata_invalid`, `store_id_conflict`, `store_path_conflict`, `store_already_registered` (info).

### Store setup/register/remove
`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`.
`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_root_pointer_declared`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`.

### Store git
`store_git_init_failed`, `store_git_identity_missing`, `store_git_commit_failed`, `store_git_no_commits` (warning), `store_clone_fragile_directories` (warning), `store_remote_divergence` (info, doctor).
Expand Down
7 changes: 6 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@ openspec store setup team-context --path ~/openspec/team-context --no-init-git -

### `openspec store register`

Register an existing local store folder.
Register an existing local store folder. During the stores beta, a root may be
registered before any changes exist, specs have been applied, or changes have
been archived; in that case `openspec/changes/`, `openspec/specs/`, and
`openspec/changes/archive/` may be absent until normal commands create them.
A config-only repo that declares `store: <id>` remains a pointer to another
store and is not registered as a store root unless that pointer is removed.

```bash
openspec store register [path] [options]
Expand Down
8 changes: 8 additions & 0 deletions docs/stores-beta/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,14 @@ tells you which case you're in.
- **No sync, ever — by design.** OpenSpec never clones, pulls, or pushes.
A stale checkout shows stale specs until *you* pull; references are
indexed live from whatever is on disk.
- **Empty planning folders can be absent.** A new store may not have
`openspec/changes/`, `openspec/specs/`, or `openspec/changes/archive/` in Git
yet. That is accepted during the beta; those folders appear once normal
commands create files for them.
- **Pointer repos stay pointers.** A config-only repo whose
`openspec/config.yaml` declares `store: <id>` is treated as externalized
planning, not as a store checkout to register. Remove the `store:` line first
if you intentionally want to convert that repo into a local store root.
- **Some commands stay where they are.** `view`, `templates`, `schemas`,
and the deprecated noun forms (`openspec change show`, ...) act on the
current directory only — no `--store`.
Expand Down
26 changes: 12 additions & 14 deletions src/core/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@ import {
type SpecUpdate,
} from './specs-apply.js';

function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}

async function listActiveChangeNames(changesDir: string): Promise<string[]> {
try {
const entries = await fs.readdir(changesDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && entry.name !== 'archive')
.map((entry) => entry.name)
.sort();
} catch {
} catch (error) {
if (!isMissingPathError(error)) throw error;
return [];
}
}
Expand Down Expand Up @@ -192,13 +202,6 @@ export class ArchiveCommand {
const archiveDir = root.archiveDir;
const mainSpecsDir = root.specsDir;

// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}

// Get change name interactively if not provided
if (!changeName) {
if (json) {
Expand Down Expand Up @@ -523,12 +526,7 @@ export class ArchiveCommand {

private async selectChange(changesDir: string): Promise<string | null> {
const { select } = await import('@inquirer/prompts');
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name)
.sort();
const changeDirs = await listActiveChangeNames(changesDir);

if (changeDirs.length === 0) {
console.log('No active changes found.');
Expand Down
31 changes: 21 additions & 10 deletions src/core/list.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { readFileSync } from 'fs';
import { readFileSync, type Dirent } from 'fs';
import { join } from 'path';
import { MarkdownParser } from './parsers/markdown-parser.js';
import type { RootOutput } from './root-selection.js';
Expand All @@ -19,6 +19,24 @@ interface ListOptions {
root?: RootOutput;
}

function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}

async function readChangeDirectoryEntries(changesDir: string): Promise<Dirent[]> {
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.
Expand Down Expand Up @@ -83,15 +101,8 @@ export class ListCommand {
if (mode === 'changes') {
const changesDir = path.join(targetPath, 'openspec', 'changes');

// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}

// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const entries = await readChangeDirectoryEntries(changesDir);
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name);
Expand Down Expand Up @@ -207,4 +218,4 @@ export class ListCommand {
console.log(`${padding}${padded} requirements ${spec.requirementCount}`);
}
}
}
}
71 changes: 52 additions & 19 deletions src/core/openspec-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive';
export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven';
export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep';

// Git cannot track empty directories, so clones of a fresh store would lose
// these and fail root-health checks. Anchored at setup time.
// Git cannot track empty directories, so setup anchors otherwise-empty
// conventional store directories for teammates who clone the repo later.
export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR] as const;

type PathKind = 'missing' | 'directory' | 'file' | 'other';
Expand Down Expand Up @@ -99,6 +99,28 @@ function missingDirectoryDiagnostic(
return makeStoreDiagnostic('error', code, message, { target });
}

type OptionalPlanningDirectoryKey = 'specs' | 'changes' | 'archive';

async function inspectOptionalPlanningDirectory(
inspection: OpenSpecRootInspection,
storeRoot: string,
key: OptionalPlanningDirectoryKey,
relativePath: string,
notDirectoryCode: string,
target: string
): Promise<PathKind> {
const kind = await pathKind(path.join(storeRoot, relativePath));
inspection[key] = { present: kind === 'directory' };
if (kind === 'directory' || kind === 'missing') return kind;

inspection.diagnostics.push(missingDirectoryDiagnostic(
notDirectoryCode,
`${relativePath}/ exists but is not a directory.`,
target
));
return kind;
}

export async function inspectOpenSpecRoot(storeRoot: string): Promise<OpenSpecRootInspection> {
const rootKind = await pathKind(storeRoot);
const inspection = unresolvedInspection();
Expand Down Expand Up @@ -166,28 +188,39 @@ export async function inspectOpenSpecRoot(storeRoot: string): Promise<OpenSpecRo
}
}

for (const [key, relativePath, code, message, target] of [
['specs', OPENSPEC_SPECS_DIR, 'openspec_specs_missing', 'Missing openspec/specs/.', 'openspec.specs'],
['changes', OPENSPEC_CHANGES_DIR, 'openspec_changes_missing', 'Missing openspec/changes/.', 'openspec.changes'],
['archive', OPENSPEC_ARCHIVE_DIR, 'openspec_archive_missing', 'Missing openspec/changes/archive/.', 'openspec.archive'],
] as const) {
const kind = await pathKind(path.join(storeRoot, relativePath));
inspection[key] = { present: kind === 'directory' };
if (kind === 'directory') continue;

inspection.diagnostics.push(missingDirectoryDiagnostic(
kind === 'missing' ? code : code.replace('_missing', '_not_directory'),
kind === 'missing' ? message : `${relativePath}/ exists but is not a directory.`,
target
));
await inspectOptionalPlanningDirectory(
inspection,
storeRoot,
'specs',
OPENSPEC_SPECS_DIR,
'openspec_specs_not_directory',
'openspec.specs'
);
const changesKind = await inspectOptionalPlanningDirectory(
inspection,
storeRoot,
'changes',
OPENSPEC_CHANGES_DIR,
'openspec_changes_not_directory',
'openspec.changes'
);
if (changesKind === 'directory') {
await inspectOptionalPlanningDirectory(
inspection,
storeRoot,
'archive',
OPENSPEC_ARCHIVE_DIR,
'openspec_archive_not_directory',
'openspec.archive'
);
} else {
inspection.archive = { present: false };
}

inspection.healthy =
inspection.present === true &&
inspection.config.present === true &&
inspection.specs.present === true &&
inspection.changes.present === true &&
inspection.archive.present === true;
inspection.diagnostics.length === 0;

return inspection;
}
Expand Down
33 changes: 33 additions & 0 deletions src/core/store/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import * as path from 'node:path';
import { promisify } from 'node:util';

import { FileSystemUtils } from '../../utils/file-system.js';
import {
classifyOpenSpecDir,
storePointerProblem,
} from '../project-config.js';
import {
ANCHORED_OPENSPEC_DIRS,
DIRECTORY_ANCHOR_FILE_NAME,
Expand Down Expand Up @@ -220,6 +224,33 @@ function alreadyRegisteredDiagnostic(id: string): StoreDiagnostic {
);
}

function assertNotConfigOnlyPointerRoot(storeRoot: string): void {
const { hasPlanningShape, pointer } = classifyOpenSpecDir(storeRoot);
if (hasPlanningShape || pointer.filePath === null) return;

if (pointer.malformed) {
throw new StoreError(
`The store declaration in ${pointer.filePath} is invalid (${storePointerProblem(pointer.malformed)}).`,
'invalid_store_pointer',
{
target: 'store.pointer',
fix: `Fix or remove the store: line in ${pointer.filePath} before registering this path as a store.`,
}
);
}

if (pointer.value !== undefined) {
throw new StoreError(
`This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}); it is not itself a store root.`,
'store_root_pointer_declared',
{
target: 'store.pointer',
fix: 'Register the checkout for the declared store, or remove the store: line first to convert this repo into a local store root.',
}
);
}
}

function createdPath(relativePath: string, absolutePath: string, kind: CreatedPathLedgerEntry['kind']): CreatedPathLedgerEntry {
return {
relativePath,
Expand Down Expand Up @@ -459,6 +490,7 @@ async function prepareSetupPlan(
let backend: StoreGitBackendConfig | undefined;

if (kind === 'directory') {
assertNotConfigOnlyPointerRoot(storeRoot);
metadata = await readStoreMetadataForOperation(storeRoot);

if (metadata) {
Expand Down Expand Up @@ -730,6 +762,7 @@ export async function registerExistingStore(
);
}

assertNotConfigOnlyPointerRoot(storeRoot);
const openspecRoot = await inspectOpenSpecRoot(storeRoot);
if (!openspecRoot.healthy) {
const problems =
Expand Down
Loading
Loading