Skip to content

Commit 93e27a7

Browse files
authored
fix empty store registration (#1328)
1 parent 8e9e457 commit 93e27a7

13 files changed

Lines changed: 438 additions & 58 deletions

docs/agent-contract.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis
9797
`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`.
9898

9999
### OpenSpec-root health (error, no fix)
100-
`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.
100+
`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.
101101

102102
### Store registry/identity/state
103103
`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).
104104

105105
### Store setup/register/remove
106-
`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`.
106+
`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`.
107107

108108
### Store git
109109
`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).

docs/cli.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@ openspec store setup team-context --path ~/openspec/team-context --no-init-git -
215215

216216
### `openspec store register`
217217

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

220225
```bash
221226
openspec store register [path] [options]

docs/stores-beta/user-guide.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,14 @@ tells you which case you're in.
308308
- **No sync, ever — by design.** OpenSpec never clones, pulls, or pushes.
309309
A stale checkout shows stale specs until *you* pull; references are
310310
indexed live from whatever is on disk.
311+
- **Empty planning folders can be absent.** A new store may not have
312+
`openspec/changes/`, `openspec/specs/`, or `openspec/changes/archive/` in Git
313+
yet. That is accepted during the beta; those folders appear once normal
314+
commands create files for them.
315+
- **Pointer repos stay pointers.** A config-only repo whose
316+
`openspec/config.yaml` declares `store: <id>` is treated as externalized
317+
planning, not as a store checkout to register. Remove the `store:` line first
318+
if you intentionally want to convert that repo into a local store root.
311319
- **Some commands stay where they are.** `view`, `templates`, `schemas`,
312320
and the deprecated noun forms (`openspec change show`, ...) act on the
313321
current directory only — no `--store`.

src/core/archive.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,24 @@ import {
1919
type SpecUpdate,
2020
} from './specs-apply.js';
2121

22+
function isMissingPathError(error: unknown): boolean {
23+
return (
24+
typeof error === 'object' &&
25+
error !== null &&
26+
'code' in error &&
27+
(error as NodeJS.ErrnoException).code === 'ENOENT'
28+
);
29+
}
30+
2231
async function listActiveChangeNames(changesDir: string): Promise<string[]> {
2332
try {
2433
const entries = await fs.readdir(changesDir, { withFileTypes: true });
2534
return entries
2635
.filter((entry) => entry.isDirectory() && entry.name !== 'archive')
2736
.map((entry) => entry.name)
2837
.sort();
29-
} catch {
38+
} catch (error) {
39+
if (!isMissingPathError(error)) throw error;
3040
return [];
3141
}
3242
}
@@ -192,13 +202,6 @@ export class ArchiveCommand {
192202
const archiveDir = root.archiveDir;
193203
const mainSpecsDir = root.specsDir;
194204

195-
// Check if changes directory exists
196-
try {
197-
await fs.access(changesDir);
198-
} catch {
199-
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
200-
}
201-
202205
// Get change name interactively if not provided
203206
if (!changeName) {
204207
if (json) {
@@ -523,12 +526,7 @@ export class ArchiveCommand {
523526

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

533531
if (changeDirs.length === 0) {
534532
console.log('No active changes found.');

src/core/list.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
33
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
4-
import { readFileSync } from 'fs';
4+
import { readFileSync, type Dirent } from 'fs';
55
import { join } from 'path';
66
import { MarkdownParser } from './parsers/markdown-parser.js';
77
import type { RootOutput } from './root-selection.js';
@@ -19,6 +19,24 @@ interface ListOptions {
1919
root?: RootOutput;
2020
}
2121

22+
function isMissingPathError(error: unknown): boolean {
23+
return (
24+
typeof error === 'object' &&
25+
error !== null &&
26+
'code' in error &&
27+
(error as NodeJS.ErrnoException).code === 'ENOENT'
28+
);
29+
}
30+
31+
async function readChangeDirectoryEntries(changesDir: string): Promise<Dirent[]> {
32+
try {
33+
return await fs.readdir(changesDir, { withFileTypes: true });
34+
} catch (error) {
35+
if (isMissingPathError(error)) return [];
36+
throw error;
37+
}
38+
}
39+
2240
/**
2341
* Get the most recent modification time of any file in a directory (recursive).
2442
* Falls back to the directory's own mtime if no files are found.
@@ -83,15 +101,8 @@ export class ListCommand {
83101
if (mode === 'changes') {
84102
const changesDir = path.join(targetPath, 'openspec', 'changes');
85103

86-
// Check if changes directory exists
87-
try {
88-
await fs.access(changesDir);
89-
} catch {
90-
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
91-
}
92-
93104
// Get all directories in changes (excluding archive)
94-
const entries = await fs.readdir(changesDir, { withFileTypes: true });
105+
const entries = await readChangeDirectoryEntries(changesDir);
95106
const changeDirs = entries
96107
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
97108
.map(entry => entry.name);
@@ -207,4 +218,4 @@ export class ListCommand {
207218
console.log(`${padding}${padded} requirements ${spec.requirementCount}`);
208219
}
209220
}
210-
}
221+
}

src/core/openspec-root.ts

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive';
1717
export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven';
1818
export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep';
1919

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

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

102+
type OptionalPlanningDirectoryKey = 'specs' | 'changes' | 'archive';
103+
104+
async function inspectOptionalPlanningDirectory(
105+
inspection: OpenSpecRootInspection,
106+
storeRoot: string,
107+
key: OptionalPlanningDirectoryKey,
108+
relativePath: string,
109+
notDirectoryCode: string,
110+
target: string
111+
): Promise<PathKind> {
112+
const kind = await pathKind(path.join(storeRoot, relativePath));
113+
inspection[key] = { present: kind === 'directory' };
114+
if (kind === 'directory' || kind === 'missing') return kind;
115+
116+
inspection.diagnostics.push(missingDirectoryDiagnostic(
117+
notDirectoryCode,
118+
`${relativePath}/ exists but is not a directory.`,
119+
target
120+
));
121+
return kind;
122+
}
123+
102124
export async function inspectOpenSpecRoot(storeRoot: string): Promise<OpenSpecRootInspection> {
103125
const rootKind = await pathKind(storeRoot);
104126
const inspection = unresolvedInspection();
@@ -166,28 +188,39 @@ export async function inspectOpenSpecRoot(storeRoot: string): Promise<OpenSpecRo
166188
}
167189
}
168190

169-
for (const [key, relativePath, code, message, target] of [
170-
['specs', OPENSPEC_SPECS_DIR, 'openspec_specs_missing', 'Missing openspec/specs/.', 'openspec.specs'],
171-
['changes', OPENSPEC_CHANGES_DIR, 'openspec_changes_missing', 'Missing openspec/changes/.', 'openspec.changes'],
172-
['archive', OPENSPEC_ARCHIVE_DIR, 'openspec_archive_missing', 'Missing openspec/changes/archive/.', 'openspec.archive'],
173-
] as const) {
174-
const kind = await pathKind(path.join(storeRoot, relativePath));
175-
inspection[key] = { present: kind === 'directory' };
176-
if (kind === 'directory') continue;
177-
178-
inspection.diagnostics.push(missingDirectoryDiagnostic(
179-
kind === 'missing' ? code : code.replace('_missing', '_not_directory'),
180-
kind === 'missing' ? message : `${relativePath}/ exists but is not a directory.`,
181-
target
182-
));
191+
await inspectOptionalPlanningDirectory(
192+
inspection,
193+
storeRoot,
194+
'specs',
195+
OPENSPEC_SPECS_DIR,
196+
'openspec_specs_not_directory',
197+
'openspec.specs'
198+
);
199+
const changesKind = await inspectOptionalPlanningDirectory(
200+
inspection,
201+
storeRoot,
202+
'changes',
203+
OPENSPEC_CHANGES_DIR,
204+
'openspec_changes_not_directory',
205+
'openspec.changes'
206+
);
207+
if (changesKind === 'directory') {
208+
await inspectOptionalPlanningDirectory(
209+
inspection,
210+
storeRoot,
211+
'archive',
212+
OPENSPEC_ARCHIVE_DIR,
213+
'openspec_archive_not_directory',
214+
'openspec.archive'
215+
);
216+
} else {
217+
inspection.archive = { present: false };
183218
}
184219

185220
inspection.healthy =
186221
inspection.present === true &&
187222
inspection.config.present === true &&
188-
inspection.specs.present === true &&
189-
inspection.changes.present === true &&
190-
inspection.archive.present === true;
223+
inspection.diagnostics.length === 0;
191224

192225
return inspection;
193226
}

src/core/store/operations.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import * as path from 'node:path';
55
import { promisify } from 'node:util';
66

77
import { FileSystemUtils } from '../../utils/file-system.js';
8+
import {
9+
classifyOpenSpecDir,
10+
storePointerProblem,
11+
} from '../project-config.js';
812
import {
913
ANCHORED_OPENSPEC_DIRS,
1014
DIRECTORY_ANCHOR_FILE_NAME,
@@ -220,6 +224,33 @@ function alreadyRegisteredDiagnostic(id: string): StoreDiagnostic {
220224
);
221225
}
222226

227+
function assertNotConfigOnlyPointerRoot(storeRoot: string): void {
228+
const { hasPlanningShape, pointer } = classifyOpenSpecDir(storeRoot);
229+
if (hasPlanningShape || pointer.filePath === null) return;
230+
231+
if (pointer.malformed) {
232+
throw new StoreError(
233+
`The store declaration in ${pointer.filePath} is invalid (${storePointerProblem(pointer.malformed)}).`,
234+
'invalid_store_pointer',
235+
{
236+
target: 'store.pointer',
237+
fix: `Fix or remove the store: line in ${pointer.filePath} before registering this path as a store.`,
238+
}
239+
);
240+
}
241+
242+
if (pointer.value !== undefined) {
243+
throw new StoreError(
244+
`This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}); it is not itself a store root.`,
245+
'store_root_pointer_declared',
246+
{
247+
target: 'store.pointer',
248+
fix: 'Register the checkout for the declared store, or remove the store: line first to convert this repo into a local store root.',
249+
}
250+
);
251+
}
252+
}
253+
223254
function createdPath(relativePath: string, absolutePath: string, kind: CreatedPathLedgerEntry['kind']): CreatedPathLedgerEntry {
224255
return {
225256
relativePath,
@@ -459,6 +490,7 @@ async function prepareSetupPlan(
459490
let backend: StoreGitBackendConfig | undefined;
460491

461492
if (kind === 'directory') {
493+
assertNotConfigOnlyPointerRoot(storeRoot);
462494
metadata = await readStoreMetadataForOperation(storeRoot);
463495

464496
if (metadata) {
@@ -730,6 +762,7 @@ export async function registerExistingStore(
730762
);
731763
}
732764

765+
assertNotConfigOnlyPointerRoot(storeRoot);
733766
const openspecRoot = await inspectOpenSpecRoot(storeRoot);
734767
if (!openspecRoot.healthy) {
735768
const problems =

0 commit comments

Comments
 (0)