@fission-ai/openspec 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/index.js +11 -2
  2. package/dist/commands/change.d.ts +24 -0
  3. package/dist/commands/change.js +223 -0
  4. package/dist/commands/schema.d.ts +5 -0
  5. package/dist/commands/schema.js +201 -35
  6. package/dist/commands/show.js +1 -1
  7. package/dist/commands/workflow/index.d.ts +1 -1
  8. package/dist/commands/workflow/index.js +1 -1
  9. package/dist/commands/workflow/status.d.ts +2 -0
  10. package/dist/commands/workflow/status.js +76 -12
  11. package/dist/core/artifact-graph/resolver.js +15 -9
  12. package/dist/core/available-tools.js +16 -1
  13. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  14. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  15. package/dist/core/completions/command-registry.js +15 -0
  16. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  17. package/dist/core/completions/generators/fish-generator.js +79 -47
  18. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  19. package/dist/core/completions/templates/fish-templates.js +107 -10
  20. package/dist/core/completions/types.d.ts +4 -0
  21. package/dist/core/config.js +6 -1
  22. package/dist/core/init.js +50 -31
  23. package/dist/core/legacy-cleanup.js +6 -0
  24. package/dist/core/migration.js +42 -12
  25. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  26. package/dist/core/parsers/requirement-blocks.js +2 -0
  27. package/dist/core/shared/tool-detection.js +9 -3
  28. package/dist/core/shared-skill-target.d.ts +13 -0
  29. package/dist/core/shared-skill-target.js +43 -1
  30. package/dist/core/specs-apply.js +16 -8
  31. package/dist/core/templates/workflows/explore.js +79 -73
  32. package/dist/core/update.js +24 -22
  33. package/dist/core/validation/constants.d.ts +3 -0
  34. package/dist/core/validation/constants.js +12 -0
  35. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  36. package/dist/core/validation/purpose-placeholder.js +117 -0
  37. package/dist/core/validation/validator.js +16 -1
  38. package/dist/utils/requirement-diff.d.ts +42 -0
  39. package/dist/utils/requirement-diff.js +61 -0
  40. package/package.json +2 -1
package/dist/cli/index.js CHANGED
@@ -24,7 +24,7 @@ import { registerStoreCommand } from '../commands/store.js';
24
24
  import { registerDoctorCommand } from '../commands/doctor.js';
25
25
  import { registerContextCommand } from '../commands/context.js';
26
26
  import { registerWorksetCommand } from '../commands/workset.js';
27
- import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
27
+ import { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
28
28
  import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
29
29
  import { maybeShowCompletionTip } from '../core/completion-tip.js';
30
30
  import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
@@ -366,6 +366,7 @@ changeCmd
366
366
  .option('--json', 'Output as JSON')
367
367
  .option('--deltas-only', 'Show only deltas (JSON only)')
368
368
  .option('--requirements-only', 'Alias for --deltas-only (deprecated)')
369
+ .option('--diff', 'Show per-requirement diffs for delta specs')
369
370
  .option('--no-interactive', 'Disable interactive prompts')
370
371
  .action(async (changeName, options) => {
371
372
  try {
@@ -475,6 +476,7 @@ program
475
476
  // change-only flags
476
477
  .option('--deltas-only', 'Show only deltas (JSON only, change)')
477
478
  .option('--requirements-only', 'Alias for --deltas-only (deprecated, change)')
479
+ .option('--diff', 'Show per-requirement diffs for delta specs (change)')
478
480
  // spec-only flags
479
481
  .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
480
482
  .option('--no-scenarios', 'JSON only: Exclude scenario content')
@@ -577,6 +579,7 @@ program
577
579
  .command('status')
578
580
  .description('Display artifact completion status for a change')
579
581
  .option('--change <id>', 'Change name to show status for')
582
+ .option('--all', 'Show status for all active changes')
580
583
  .option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
581
584
  .option('--json', 'Output as JSON')
582
585
  .option('--store <id>', STORE_OPTION_DESCRIPTION)
@@ -586,7 +589,13 @@ program
586
589
  await statusCommand(options);
587
590
  }
588
591
  catch (error) {
589
- failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
592
+ failWithError(error, {
593
+ enabled: options.json,
594
+ // The batch null-shape; the single-change failure shape is
595
+ // pre-existing contract and stays payload-free.
596
+ payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
597
+ fallbackCode: 'change_error',
598
+ });
590
599
  process.exit(1);
591
600
  }
592
601
  });
@@ -4,19 +4,43 @@ export declare class ChangeCommand {
4
4
  private rootPath?;
5
5
  constructor(rootPath?: string);
6
6
  private getChangesPath;
7
+ private getSpecsPath;
7
8
  /**
8
9
  * Show a change proposal.
9
10
  * - Text mode: raw markdown passthrough (no filters)
10
11
  * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
11
12
  * Note: --requirements-only is deprecated alias for --deltas-only
13
+ * - --diff: per-requirement diffs of the delta specs against the main specs,
14
+ * appended in text mode and attached to MODIFIED deltas in JSON mode
12
15
  */
13
16
  show(changeName?: string, options?: {
14
17
  json?: boolean;
15
18
  requirementsOnly?: boolean;
16
19
  deltasOnly?: boolean;
20
+ diff?: boolean;
17
21
  noInteractive?: boolean;
18
22
  rootOutput?: RootOutput;
19
23
  }): Promise<void>;
24
+ /**
25
+ * Read every delta spec under the change and pair each requirement with its
26
+ * counterpart in the main spec. Text mode and JSON mode both render from this
27
+ * one pass, so the two surfaces cannot drift apart.
28
+ */
29
+ private collectSpecDiffs;
30
+ /**
31
+ * Attach `diff` (or `warning`) to every MODIFIED delta in the JSON payload.
32
+ * Mutates the deltas array in place.
33
+ *
34
+ * The parsed Delta objects carry the requirement body in `description`, not
35
+ * the header name, so they are matched to parsed blocks by capability and
36
+ * source order: ChangeParser emits one Delta per MODIFIED block in that order.
37
+ */
38
+ private enrichDeltasWithDiffs;
39
+ /**
40
+ * Text mode: per-requirement diffs of the delta specs against the main specs.
41
+ */
42
+ private showSpecDiffs;
43
+ private printDiffText;
20
44
  /**
21
45
  * List active changes.
22
46
  * - Text default: IDs only; --long prints minimal details (title, counts)
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import chalk from 'chalk';
3
4
  import { JsonConverter } from '../core/converters/json-converter.js';
4
5
  import { Validator } from '../core/validation/validator.js';
5
6
  import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
@@ -8,6 +9,9 @@ import { isInteractive } from '../utils/interactive.js';
8
9
  import { getActiveChangeIds } from '../utils/item-discovery.js';
9
10
  import { getTaskProgressForChange } from '../utils/task-progress.js';
10
11
  import { FileSystemUtils } from '../utils/file-system.js';
12
+ import { discoverSpecFiles } from '../utils/spec-discovery.js';
13
+ import { foldRequirementName, parseDeltaSpec, } from '../core/parsers/requirement-blocks.js';
14
+ import { extractRequirementBlock, diffRequirementBlock, buildRenameMap, } from '../utils/requirement-diff.js';
11
15
  /**
12
16
  * True only when `target` is definitively absent. An EACCES or I/O failure
13
17
  * means existence cannot be determined, so callers fall through to their
@@ -39,11 +43,18 @@ export class ChangeCommand {
39
43
  getChangesPath() {
40
44
  return path.join(this.rootPath ?? process.cwd(), 'openspec', 'changes');
41
45
  }
46
+ // Main specs resolve against the same root as changes, so `--diff` reads the
47
+ // selected store's specs rather than whatever sits under the cwd.
48
+ getSpecsPath() {
49
+ return path.join(this.rootPath ?? process.cwd(), 'openspec', 'specs');
50
+ }
42
51
  /**
43
52
  * Show a change proposal.
44
53
  * - Text mode: raw markdown passthrough (no filters)
45
54
  * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
46
55
  * Note: --requirements-only is deprecated alias for --deltas-only
56
+ * - --diff: per-requirement diffs of the delta specs against the main specs,
57
+ * appended in text mode and attached to MODIFIED deltas in JSON mode
47
58
  */
48
59
  async show(changeName, options) {
49
60
  const changesPath = this.getChangesPath();
@@ -109,6 +120,9 @@ export class ChangeCommand {
109
120
  const title = this.extractTitle(contentForTitle, changeName);
110
121
  const id = parsed.name;
111
122
  const deltas = parsed.deltas || [];
123
+ if (options.diff) {
124
+ await this.enrichDeltasWithDiffs(deltas, changeName, changesPath);
125
+ }
112
126
  const output = {
113
127
  id,
114
128
  title,
@@ -122,6 +136,215 @@ export class ChangeCommand {
122
136
  FileSystemUtils.assertPathWithin(changeDir, proposalPath);
123
137
  const content = await fs.readFile(proposalPath, 'utf-8');
124
138
  console.log(content);
139
+ if (options?.diff) {
140
+ await this.showSpecDiffs(changeName, changesPath);
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Read every delta spec under the change and pair each requirement with its
146
+ * counterpart in the main spec. Text mode and JSON mode both render from this
147
+ * one pass, so the two surfaces cannot drift apart.
148
+ */
149
+ async collectSpecDiffs(changeName, changesPath) {
150
+ const specsDir = path.join(changesPath, changeName, 'specs');
151
+ const mainSpecsDir = this.getSpecsPath();
152
+ // Same discovery ChangeParser uses, so a nested capability (specs/<area>/<id>)
153
+ // is diffed rather than silently skipped, and the ids here match the `spec`
154
+ // field of the JSON deltas.
155
+ const discovered = await discoverSpecFiles(specsDir);
156
+ const capabilities = discovered.map(spec => spec.id);
157
+ const results = [];
158
+ for (const { id: capability, specFile: deltaSpecPath } of discovered) {
159
+ const deltaContent = await fs.readFile(deltaSpecPath, 'utf-8');
160
+ const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
161
+ let mainContent = null;
162
+ try {
163
+ FileSystemUtils.assertPathWithin(mainSpecsDir, mainSpecPath);
164
+ mainContent = await fs.readFile(mainSpecPath, 'utf-8');
165
+ }
166
+ catch (error) {
167
+ if (error?.code !== 'ENOENT')
168
+ throw error;
169
+ // No main spec on disk. For ADDED requirements that is the ordinary new
170
+ // capability case; MODIFIED requirements are handled as a mismatch below.
171
+ }
172
+ const plan = parseDeltaSpec(deltaContent);
173
+ const renameMap = buildRenameMap(plan.renamed);
174
+ for (const block of plan.added) {
175
+ results.push({ capability, operation: 'ADDED', requirementName: block.name, raw: block.raw });
176
+ }
177
+ // Prefer the authored REMOVED block so its Reason/Migration text reaches
178
+ // the reader; the bullet-list form carries a name and nothing else.
179
+ const removedBlocks = new Map(plan.removedBlocks.map(block => [foldRequirementName(block.name), block.raw]));
180
+ for (const name of plan.removed) {
181
+ const raw = removedBlocks.get(foldRequirementName(name));
182
+ results.push({
183
+ capability,
184
+ operation: 'REMOVED',
185
+ requirementName: name,
186
+ raw: raw ?? `### Requirement: ${name}`,
187
+ });
188
+ }
189
+ for (const rename of plan.renamed) {
190
+ results.push({ capability, operation: 'RENAMED', requirementName: rename.to, raw: '', rename });
191
+ }
192
+ for (const block of plan.modified) {
193
+ const entry = {
194
+ capability,
195
+ operation: 'MODIFIED',
196
+ requirementName: block.name,
197
+ raw: block.raw,
198
+ };
199
+ // A requirement renamed and modified in the same delta still lives in
200
+ // the main spec under its old name, so look it up there.
201
+ const oldName = renameMap.get(foldRequirementName(block.name));
202
+ const lookupName = oldName ?? block.name;
203
+ const match = mainContent ? extractRequirementBlock(mainContent, lookupName) : null;
204
+ if (match) {
205
+ entry.diff = diffRequirementBlock(match.raw, block.raw, `${capability}/${block.name}`);
206
+ if (!match.exact) {
207
+ // Archive matches requirement names exactly, so a header that
208
+ // differs only in case or spacing will not merge. Show the diff the
209
+ // author meant, and name the mismatch while it is still cheap to fix.
210
+ entry.warning =
211
+ `Header differs from the main spec's "${match.name}" only in case or spacing; ` +
212
+ `archive matches names exactly, so reconcile them before archiving`;
213
+ }
214
+ }
215
+ else if (mainContent) {
216
+ entry.warning = `No matching main requirement found for "${lookupName}" in ${capability}`;
217
+ }
218
+ else {
219
+ // A MODIFIED requirement names a block that should already exist, so a
220
+ // missing main spec is an authoring error, not a new capability.
221
+ // Rendering it as all-additions would hide what archive will reject.
222
+ entry.warning =
223
+ `No main spec at openspec/specs/${capability}/spec.md, ` +
224
+ `so MODIFIED requirement "${block.name}" has nothing to diff against`;
225
+ }
226
+ results.push(entry);
227
+ }
228
+ }
229
+ return { capabilities, results };
230
+ }
231
+ /**
232
+ * Attach `diff` (or `warning`) to every MODIFIED delta in the JSON payload.
233
+ * Mutates the deltas array in place.
234
+ *
235
+ * The parsed Delta objects carry the requirement body in `description`, not
236
+ * the header name, so they are matched to parsed blocks by capability and
237
+ * source order: ChangeParser emits one Delta per MODIFIED block in that order.
238
+ */
239
+ async enrichDeltasWithDiffs(deltas, changeName, changesPath) {
240
+ const modifiedDeltasBySpec = new Map();
241
+ for (const delta of deltas) {
242
+ if (!delta.spec || delta.operation !== 'MODIFIED')
243
+ continue;
244
+ const list = modifiedDeltasBySpec.get(delta.spec) ?? [];
245
+ list.push(delta);
246
+ modifiedDeltasBySpec.set(delta.spec, list);
247
+ }
248
+ if (modifiedDeltasBySpec.size === 0)
249
+ return;
250
+ const { results } = await this.collectSpecDiffs(changeName, changesPath);
251
+ const modifiedEntriesBySpec = new Map();
252
+ for (const entry of results) {
253
+ if (entry.operation !== 'MODIFIED')
254
+ continue;
255
+ const list = modifiedEntriesBySpec.get(entry.capability) ?? [];
256
+ list.push(entry);
257
+ modifiedEntriesBySpec.set(entry.capability, list);
258
+ }
259
+ for (const [capability, modifiedDeltas] of modifiedDeltasBySpec) {
260
+ const entries = modifiedEntriesBySpec.get(capability) ?? [];
261
+ for (let i = 0; i < modifiedDeltas.length && i < entries.length; i++) {
262
+ const entry = entries[i];
263
+ if (entry.diff !== undefined) {
264
+ modifiedDeltas[i].diff = entry.diff;
265
+ }
266
+ if (entry.warning !== undefined) {
267
+ modifiedDeltas[i].warning = entry.warning;
268
+ }
269
+ }
270
+ }
271
+ }
272
+ /**
273
+ * Text mode: per-requirement diffs of the delta specs against the main specs.
274
+ */
275
+ async showSpecDiffs(changeName, changesPath) {
276
+ const { capabilities, results } = await this.collectSpecDiffs(changeName, changesPath);
277
+ console.log();
278
+ if (capabilities.length === 0 || results.length === 0) {
279
+ // Not an error: a change can be proposal-only. Saying so beats printing a
280
+ // heading with nothing under it.
281
+ console.log(`No delta specs to diff for change "${changeName}".`);
282
+ return;
283
+ }
284
+ console.log(chalk.bold('Specifications Changed (diffs)'));
285
+ console.log();
286
+ this.printDiffText(results);
287
+ }
288
+ printDiffText(results) {
289
+ let currentCap = '';
290
+ for (const r of results) {
291
+ if (r.capability !== currentCap) {
292
+ if (currentCap)
293
+ console.log();
294
+ currentCap = r.capability;
295
+ console.log(chalk.bold.underline(currentCap));
296
+ console.log();
297
+ }
298
+ switch (r.operation) {
299
+ case 'ADDED':
300
+ console.log(chalk.green.bold(` ADDED: ${r.requirementName}`));
301
+ for (const line of r.raw.split('\n')) {
302
+ console.log(chalk.green(` ${line}`));
303
+ }
304
+ console.log();
305
+ break;
306
+ case 'REMOVED':
307
+ console.log(chalk.red.bold(` REMOVED: ${r.requirementName}`));
308
+ for (const line of r.raw.split('\n')) {
309
+ console.log(chalk.red(` ${line}`));
310
+ }
311
+ console.log();
312
+ break;
313
+ case 'RENAMED':
314
+ console.log(chalk.cyan.bold(` RENAMED: ${r.rename?.from} → ${r.rename?.to}`));
315
+ console.log();
316
+ break;
317
+ case 'MODIFIED':
318
+ console.log(chalk.yellow.bold(` MODIFIED: ${r.requirementName}`));
319
+ if (r.warning) {
320
+ console.log(chalk.yellow(` ⚠ ${r.warning}`));
321
+ }
322
+ // A near-miss header carries both: the warning about the mismatch and
323
+ // the diff against the block it almost matched.
324
+ if (r.diff === undefined) {
325
+ for (const line of r.raw.split('\n')) {
326
+ console.log(` ${line}`);
327
+ }
328
+ }
329
+ else if (r.diff === '') {
330
+ console.log(chalk.dim(' (no textual changes)'));
331
+ }
332
+ else {
333
+ for (const line of r.diff.split('\n')) {
334
+ if (line.startsWith('+')) {
335
+ console.log(chalk.green(` ${line}`));
336
+ }
337
+ else if (line.startsWith('-')) {
338
+ console.log(chalk.red(` ${line}`));
339
+ }
340
+ else {
341
+ console.log(` ${line}`);
342
+ }
343
+ }
344
+ }
345
+ console.log();
346
+ break;
347
+ }
125
348
  }
126
349
  }
127
350
  /**
@@ -1,4 +1,9 @@
1
1
  import { Command } from 'commander';
2
+ import * as fs from 'node:fs';
3
+ /** @internal File-operation seam for transactional failure tests. */
4
+ export declare const schemaInitFileOperations: {
5
+ renameSync: typeof fs.renameSync;
6
+ };
2
7
  /**
3
8
  * Register the schema command and all its subcommands.
4
9
  */
@@ -2,9 +2,10 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import ora from 'ora';
5
- import { stringify as stringifyYaml, parseDocument } from 'yaml';
5
+ import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
6
6
  import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
7
7
  import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
8
+ import { resolveConfigFilePath } from '../core/project-config.js';
8
9
  import { FileSystemUtils } from '../utils/file-system.js';
9
10
  /**
10
11
  * Check all three locations for a schema and return which ones exist.
@@ -289,6 +290,65 @@ function fingerprintDir(dir) {
289
290
  walk(dir, '');
290
291
  return hash.digest('hex');
291
292
  }
293
+ /** @internal File-operation seam for transactional failure tests. */
294
+ export const schemaInitFileOperations = {
295
+ renameSync: fs.renameSync,
296
+ };
297
+ async function prepareDefaultConfigUpdate(projectRoot, schemaName) {
298
+ const configPath = resolveConfigFilePath(projectRoot) ??
299
+ path.join(projectRoot, 'openspec', 'config.yaml');
300
+ FileSystemUtils.assertProjectArtifactPath(projectRoot, configPath);
301
+ if (fs.existsSync(configPath)) {
302
+ const stats = fs.lstatSync(configPath);
303
+ if (stats.isSymbolicLink()) {
304
+ throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must be a regular file, not a symbolic link`);
305
+ }
306
+ if (!stats.isFile()) {
307
+ throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must be a regular file`);
308
+ }
309
+ if (!(await FileSystemUtils.canWriteFile(configPath)) ||
310
+ !(await FileSystemUtils.canWriteFile(path.dirname(configPath)))) {
311
+ throw new Error(`Cannot set the default schema: ${path.basename(configPath)} is not writable`);
312
+ }
313
+ const originalContent = fs.readFileSync(configPath);
314
+ const config = parseDocument(originalContent.toString('utf-8'));
315
+ if (config.errors.length > 0) {
316
+ throw new Error(`Cannot set the default schema: ${path.basename(configPath)} is invalid YAML`);
317
+ }
318
+ if (config.contents !== null && !isMap(config.contents)) {
319
+ throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must contain a YAML object`);
320
+ }
321
+ config.set('schema', schemaName);
322
+ config.delete('defaultSchema');
323
+ return {
324
+ path: configPath,
325
+ content: Buffer.from(config.toString()),
326
+ originalContent,
327
+ originalMode: stats.mode,
328
+ };
329
+ }
330
+ if (!(await FileSystemUtils.canWriteFile(configPath))) {
331
+ throw new Error(`Cannot set the default schema: ${path.dirname(configPath)} is not writable`);
332
+ }
333
+ return {
334
+ path: configPath,
335
+ content: Buffer.from(stringifyYaml({ schema: schemaName })),
336
+ originalContent: null,
337
+ originalMode: null,
338
+ };
339
+ }
340
+ function configMatchesPreparedState(prepared) {
341
+ if (prepared.originalContent === null) {
342
+ return !fs.existsSync(prepared.path);
343
+ }
344
+ if (!fs.existsSync(prepared.path))
345
+ return false;
346
+ const stats = fs.lstatSync(prepared.path);
347
+ return (stats.isFile() &&
348
+ !stats.isSymbolicLink() &&
349
+ stats.mode === prepared.originalMode &&
350
+ fs.readFileSync(prepared.path).equals(prepared.originalContent));
351
+ }
292
352
  /**
293
353
  * Default artifacts with descriptions for schema init.
294
354
  */
@@ -952,46 +1012,152 @@ export function registerSchemaCommand(program) {
952
1012
  tracks: 'tasks.md',
953
1013
  };
954
1014
  }
955
- // Replace only after all inputs have been collected and validated
956
- if (schemaExists) {
957
- if (spinner)
958
- spinner.start(`Removing existing schema '${name}'...`);
959
- fs.rmSync(schemaDir, { recursive: true });
960
- }
961
- // Create schema directory
1015
+ // Parse and serialize the config before staging any schema files. This
1016
+ // makes malformed, non-object, linked, and read-only configs fail before
1017
+ // an existing schema can be moved or a new one can appear.
1018
+ const preparedConfig = options?.default
1019
+ ? await prepareDefaultConfigUpdate(projectRoot, name)
1020
+ : null;
1021
+ const schemasDir = getProjectSchemasDir(projectRoot);
1022
+ FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
1023
+ const authorizedSchemaFingerprint = schemaExists
1024
+ ? fingerprintDir(schemaDir)
1025
+ : null;
962
1026
  if (spinner)
963
1027
  spinner.start(`Creating schema '${name}'...`);
964
- fs.mkdirSync(schemaDir, { recursive: true });
965
- fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema));
966
- // Create template files in templates/ subdirectory (standard location)
967
- const templatesDir = path.join(schemaDir, 'templates');
968
- for (const artifact of selectedArtifacts) {
969
- const templatePath = path.join(templatesDir, artifact.template);
970
- const templateDir = path.dirname(templatePath);
971
- if (!fs.existsSync(templateDir)) {
972
- fs.mkdirSync(templateDir, { recursive: true });
1028
+ fs.mkdirSync(schemasDir, { recursive: true });
1029
+ const schemaStagingDir = fs.mkdtempSync(path.join(schemasDir, '.init-staging-'));
1030
+ let configStagingDir = null;
1031
+ let stagedConfigPath = null;
1032
+ try {
1033
+ fs.writeFileSync(path.join(schemaStagingDir, 'schema.yaml'), stringifyYaml(schema));
1034
+ const templatesDir = path.join(schemaStagingDir, 'templates');
1035
+ for (const artifact of selectedArtifacts) {
1036
+ const templatePath = path.join(templatesDir, artifact.template);
1037
+ fs.mkdirSync(path.dirname(templatePath), { recursive: true });
1038
+ fs.writeFileSync(templatePath, createDefaultTemplate(artifact.id));
1039
+ }
1040
+ const validation = validateSchema(schemaStagingDir);
1041
+ if (!validation.valid) {
1042
+ throw new Error(`Generated schema failed validation: ${validation.issues
1043
+ .map((issue) => issue.message)
1044
+ .join('; ')}`);
1045
+ }
1046
+ if (preparedConfig) {
1047
+ const configDir = path.dirname(preparedConfig.path);
1048
+ configStagingDir = fs.mkdtempSync(path.join(configDir, '.schema-init-config-'));
1049
+ stagedConfigPath = path.join(configStagingDir, path.basename(preparedConfig.path));
1050
+ fs.writeFileSync(stagedConfigPath, preparedConfig.content);
1051
+ if (preparedConfig.originalMode !== null) {
1052
+ fs.chmodSync(stagedConfigPath, preparedConfig.originalMode);
1053
+ }
1054
+ }
1055
+ // Re-resolve both destinations immediately before the first move so
1056
+ // a parent symlink swap during staging cannot redirect the commit.
1057
+ FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
1058
+ if (preparedConfig) {
1059
+ FileSystemUtils.assertProjectArtifactPath(projectRoot, preparedConfig.path);
1060
+ }
1061
+ const currentSchemaFingerprint = fs.existsSync(schemaDir)
1062
+ ? fingerprintDir(schemaDir)
1063
+ : null;
1064
+ if (currentSchemaFingerprint !== authorizedSchemaFingerprint) {
1065
+ throw new Error(`Schema '${name}' changed on disk while initialization was being prepared. ` +
1066
+ 'Aborted to preserve those concurrent changes.');
1067
+ }
1068
+ if (preparedConfig && !configMatchesPreparedState(preparedConfig)) {
1069
+ throw new Error(`${path.basename(preparedConfig.path)} changed on disk while initialization was being prepared. ` +
1070
+ 'Aborted to preserve those concurrent changes.');
1071
+ }
1072
+ const token = `${process.pid}-${Date.now()}`;
1073
+ const schemaBackup = `${schemaDir}.init-backup-${token}`;
1074
+ const configBackup = preparedConfig
1075
+ ? `${preparedConfig.path}.init-backup-${token}`
1076
+ : null;
1077
+ let schemaBackedUp = false;
1078
+ let configBackedUp = false;
1079
+ let schemaInstalled = false;
1080
+ let configInstalled = false;
1081
+ try {
1082
+ if (schemaExists) {
1083
+ schemaInitFileOperations.renameSync(schemaDir, schemaBackup);
1084
+ schemaBackedUp = true;
1085
+ }
1086
+ if (preparedConfig && preparedConfig.originalContent !== null) {
1087
+ schemaInitFileOperations.renameSync(preparedConfig.path, configBackup);
1088
+ configBackedUp = true;
1089
+ }
1090
+ schemaInitFileOperations.renameSync(schemaStagingDir, schemaDir);
1091
+ schemaInstalled = true;
1092
+ if (preparedConfig && stagedConfigPath) {
1093
+ schemaInitFileOperations.renameSync(stagedConfigPath, preparedConfig.path);
1094
+ configInstalled = true;
1095
+ }
1096
+ }
1097
+ catch (installError) {
1098
+ const rollbackErrors = [];
1099
+ try {
1100
+ if (configInstalled && preparedConfig) {
1101
+ fs.rmSync(preparedConfig.path, { force: true });
1102
+ }
1103
+ if (configBackedUp && preparedConfig && configBackup) {
1104
+ schemaInitFileOperations.renameSync(configBackup, preparedConfig.path);
1105
+ }
1106
+ }
1107
+ catch (rollbackError) {
1108
+ rollbackErrors.push(`config: ${rollbackError.message}`);
1109
+ }
1110
+ try {
1111
+ if (schemaInstalled) {
1112
+ fs.rmSync(schemaDir, { recursive: true, force: true });
1113
+ }
1114
+ if (schemaBackedUp) {
1115
+ schemaInitFileOperations.renameSync(schemaBackup, schemaDir);
1116
+ }
1117
+ }
1118
+ catch (rollbackError) {
1119
+ rollbackErrors.push(`schema: ${rollbackError.message}`);
1120
+ }
1121
+ if (rollbackErrors.length > 0) {
1122
+ throw new Error(`Schema initialization failed and rollback was incomplete (${rollbackErrors.join(', ')}). ` +
1123
+ `Recovery backups may remain beside ${schemaDir} and ${preparedConfig?.path ?? 'the config file'}.`, { cause: installError });
1124
+ }
1125
+ throw installError;
1126
+ }
1127
+ // The transaction is committed. Cleanup cannot turn success into a
1128
+ // false failure, so leave a recoverable backup and warn if removal is
1129
+ // blocked instead of reporting that initialization failed.
1130
+ for (const backup of [
1131
+ schemaBackedUp ? schemaBackup : null,
1132
+ configBackedUp ? configBackup : null,
1133
+ ]) {
1134
+ if (!backup)
1135
+ continue;
1136
+ try {
1137
+ fs.rmSync(backup, { recursive: true, force: true });
1138
+ }
1139
+ catch (cleanupError) {
1140
+ console.error(`Warning: initialization succeeded, but the backup at ${backup} could not be removed: ${cleanupError.message}`);
1141
+ }
973
1142
  }
974
- // Create default template content
975
- const templateContent = createDefaultTemplate(artifact.id);
976
- fs.writeFileSync(templatePath, templateContent);
977
1143
  }
978
- // Update config if --default
979
- if (options?.default) {
980
- const configPath = path.join(projectRoot, 'openspec', 'config.yaml');
981
- if (fs.existsSync(configPath)) {
982
- const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml');
983
- const configContent = fs.readFileSync(configPath, 'utf-8');
984
- const config = parseYaml(configContent) || {};
985
- config.defaultSchema = name;
986
- fs.writeFileSync(configPath, stringifyYaml2(config));
1144
+ catch (error) {
1145
+ try {
1146
+ fs.rmSync(schemaStagingDir, { recursive: true, force: true });
987
1147
  }
988
- else {
989
- // Create config file
990
- const configDir = path.dirname(configPath);
991
- if (!fs.existsSync(configDir)) {
992
- fs.mkdirSync(configDir, { recursive: true });
1148
+ catch {
1149
+ // Best-effort cleanup must not hide the operation's real error.
1150
+ }
1151
+ throw error;
1152
+ }
1153
+ finally {
1154
+ if (configStagingDir) {
1155
+ try {
1156
+ fs.rmSync(configStagingDir, { recursive: true, force: true });
1157
+ }
1158
+ catch {
1159
+ // Best-effort cleanup. A committed config has already moved out.
993
1160
  }
994
- fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name }));
995
1161
  }
996
1162
  }
997
1163
  if (spinner)
@@ -4,7 +4,7 @@ import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot
4
4
  import { ChangeCommand } from './change.js';
5
5
  import { SpecCommand } from './spec.js';
6
6
  import { nearestMatches } from '../utils/match.js';
7
- const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']);
7
+ const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly', 'diff']);
8
8
  const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
9
9
  export class ShowCommand {
10
10
  async execute(itemName, options = {}) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
5
5
  */
6
- export { statusCommand } from './status.js';
6
+ export { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD } from './status.js';
7
7
  export type { StatusOptions } from './status.js';
8
8
  export { instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, } from './instructions.js';
9
9
  export type { InstructionsOptions } from './instructions.js';
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
5
5
  */
6
- export { statusCommand } from './status.js';
6
+ export { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD } from './status.js';
7
7
  export { instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, } from './instructions.js';
8
8
  export { templatesCommand } from './templates.js';
9
9
  export { schemasCommand } from './schemas.js';