@fission-ai/openspec 1.14.0 → 1.14.2

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 (58) hide show
  1. package/README.md +15 -31
  2. package/dist/cli/commands/config.d.ts +8 -0
  3. package/dist/cli/commands/config.js +88 -0
  4. package/dist/cli/commands/context.d.ts +3 -0
  5. package/dist/cli/commands/context.js +20 -0
  6. package/dist/cli/commands/doctor.d.ts +3 -0
  7. package/dist/cli/commands/doctor.js +18 -0
  8. package/dist/cli/commands/schema.d.ts +6 -0
  9. package/dist/cli/commands/schema.js +57 -0
  10. package/dist/cli/commands/spec.d.ts +3 -0
  11. package/dist/cli/commands/spec.js +42 -0
  12. package/dist/cli/commands/store.d.ts +3 -0
  13. package/dist/cli/commands/store.js +130 -0
  14. package/dist/cli/commands/workset.d.ts +3 -0
  15. package/dist/cli/commands/workset.js +96 -0
  16. package/dist/cli/index.d.ts +5 -0
  17. package/dist/cli/index.js +86 -43
  18. package/dist/commands/completion.js +4 -1
  19. package/dist/commands/config.d.ts +16 -7
  20. package/dist/commands/config.js +342 -395
  21. package/dist/commands/context.d.ts +8 -2
  22. package/dist/commands/context.js +22 -37
  23. package/dist/commands/doctor.d.ts +6 -7
  24. package/dist/commands/doctor.js +14 -27
  25. package/dist/commands/schema.d.ts +19 -5
  26. package/dist/commands/schema.js +662 -702
  27. package/dist/commands/spec.d.ts +13 -4
  28. package/dist/commands/spec.js +91 -120
  29. package/dist/commands/store.d.ts +27 -2
  30. package/dist/commands/store.js +1 -120
  31. package/dist/commands/workflow/default-schema.d.ts +2 -0
  32. package/dist/commands/workflow/default-schema.js +4 -0
  33. package/dist/commands/workflow/instructions.js +1 -1
  34. package/dist/commands/workflow/shared.d.ts +1 -1
  35. package/dist/commands/workflow/shared.js +1 -1
  36. package/dist/commands/workset.d.ts +22 -2
  37. package/dist/commands/workset.js +1 -88
  38. package/dist/core/archive.js +32 -12
  39. package/dist/core/artifact-graph/outputs.js +39 -1
  40. package/dist/core/completion-tip.js +3 -1
  41. package/dist/core/completions/completion-provider.js +4 -2
  42. package/dist/core/config-schema.d.ts +1 -1
  43. package/dist/core/parsers/requirement-blocks.d.ts +11 -0
  44. package/dist/core/parsers/requirement-blocks.js +57 -0
  45. package/dist/core/specs-apply.js +5 -1
  46. package/dist/core/templates/workflows/apply-change.js +4 -3
  47. package/dist/core/templates/workflows/continue-change.js +17 -0
  48. package/dist/core/templates/workflows/explore.js +8 -4
  49. package/dist/core/templates/workflows/update-change.js +4 -4
  50. package/dist/core/templates/workflows/verify-change.js +26 -20
  51. package/dist/core/validation/validator.js +23 -2
  52. package/dist/core/view.js +2 -26
  53. package/dist/utils/change-metadata.d.ts +17 -0
  54. package/dist/utils/change-metadata.js +27 -2
  55. package/dist/utils/file-system.d.ts +7 -0
  56. package/dist/utils/file-system.js +25 -0
  57. package/package.json +1 -1
  58. package/schemas/spec-driven/schema.yaml +28 -4
@@ -384,829 +384,789 @@ const DEFAULT_ARTIFACTS = [
384
384
  template: 'tasks.md',
385
385
  },
386
386
  ];
387
- /**
388
- * Register the schema command and all its subcommands.
389
- */
390
- export function registerSchemaCommand(program) {
391
- const schemaCmd = program
392
- .command('schema')
393
- .description('Manage workflow schemas [experimental]');
394
- // Experimental warning
395
- schemaCmd.hook('preAction', () => {
396
- console.error('Note: Schema commands are experimental and may change.');
397
- });
398
- // schema which
399
- schemaCmd
400
- .command('which [name]')
401
- .description('Show where a schema resolves from')
402
- .option('--json', 'Output as JSON')
403
- .option('--all', 'List all schemas with their resolution sources')
404
- .action(async (name, options) => {
405
- try {
406
- const projectRoot = process.cwd();
407
- if (options?.all) {
408
- // List all schemas
409
- const schemas = getAllSchemasWithResolution(projectRoot);
410
- if (options?.json) {
411
- console.log(JSON.stringify(schemas, null, 2));
387
+ export async function schemaWhichCommand(name, options) {
388
+ try {
389
+ const projectRoot = process.cwd();
390
+ if (options?.all) {
391
+ // List all schemas
392
+ const schemas = getAllSchemasWithResolution(projectRoot);
393
+ if (options?.json) {
394
+ console.log(JSON.stringify(schemas, null, 2));
395
+ }
396
+ else {
397
+ if (schemas.length === 0) {
398
+ console.log('No schemas found.');
399
+ return;
412
400
  }
413
- else {
414
- if (schemas.length === 0) {
415
- console.log('No schemas found.');
416
- return;
417
- }
418
- // Group by source
419
- const bySource = {
420
- project: schemas.filter((s) => s.source === 'project'),
421
- user: schemas.filter((s) => s.source === 'user'),
422
- package: schemas.filter((s) => s.source === 'package'),
423
- };
424
- if (bySource.project.length > 0) {
425
- console.log('\nProject schemas:');
426
- for (const schema of bySource.project) {
427
- const shadowInfo = schema.shadows.length > 0
428
- ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`
429
- : '';
430
- console.log(` ${schema.name}${shadowInfo}`);
431
- }
432
- }
433
- if (bySource.user.length > 0) {
434
- console.log('\nUser schemas:');
435
- for (const schema of bySource.user) {
436
- const shadowInfo = schema.shadows.length > 0
437
- ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`
438
- : '';
439
- console.log(` ${schema.name}${shadowInfo}`);
440
- }
441
- }
442
- if (bySource.package.length > 0) {
443
- console.log('\nPackage schemas:');
444
- for (const schema of bySource.package) {
445
- console.log(` ${schema.name}`);
446
- }
401
+ // Group by source
402
+ const bySource = {
403
+ project: schemas.filter((s) => s.source === 'project'),
404
+ user: schemas.filter((s) => s.source === 'user'),
405
+ package: schemas.filter((s) => s.source === 'package'),
406
+ };
407
+ if (bySource.project.length > 0) {
408
+ console.log('\nProject schemas:');
409
+ for (const schema of bySource.project) {
410
+ const shadowInfo = schema.shadows.length > 0
411
+ ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`
412
+ : '';
413
+ console.log(` ${schema.name}${shadowInfo}`);
447
414
  }
448
415
  }
449
- return;
450
- }
451
- if (!name) {
452
- console.error('Error: Schema name is required (or use --all to list all schemas)');
453
- process.exitCode = 1;
454
- return;
455
- }
456
- const resolution = getSchemaResolution(name, projectRoot);
457
- if (!resolution) {
458
- const available = listSchemas(projectRoot);
459
- if (options?.json) {
460
- console.log(JSON.stringify({
461
- error: `Schema '${name}' not found`,
462
- available,
463
- }, null, 2));
416
+ if (bySource.user.length > 0) {
417
+ console.log('\nUser schemas:');
418
+ for (const schema of bySource.user) {
419
+ const shadowInfo = schema.shadows.length > 0
420
+ ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`
421
+ : '';
422
+ console.log(` ${schema.name}${shadowInfo}`);
423
+ }
464
424
  }
465
- else {
466
- console.error(`Error: Schema '${name}' not found`);
467
- console.error(`Available schemas: ${available.join(', ')}`);
425
+ if (bySource.package.length > 0) {
426
+ console.log('\nPackage schemas:');
427
+ for (const schema of bySource.package) {
428
+ console.log(` ${schema.name}`);
429
+ }
468
430
  }
469
- process.exitCode = 1;
470
- return;
471
431
  }
432
+ return;
433
+ }
434
+ if (!name) {
435
+ console.error('Error: Schema name is required (or use --all to list all schemas)');
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+ const resolution = getSchemaResolution(name, projectRoot);
440
+ if (!resolution) {
441
+ const available = listSchemas(projectRoot);
472
442
  if (options?.json) {
473
- console.log(JSON.stringify(resolution, null, 2));
443
+ console.log(JSON.stringify({
444
+ error: `Schema '${name}' not found`,
445
+ available,
446
+ }, null, 2));
474
447
  }
475
448
  else {
476
- console.log(`Schema: ${resolution.name}`);
477
- console.log(`Source: ${resolution.source}`);
478
- console.log(`Path: ${resolution.path}`);
479
- if (resolution.shadows.length > 0) {
480
- console.log('\nShadows:');
481
- for (const shadow of resolution.shadows) {
482
- console.log(` ${shadow.source}: ${shadow.path}`);
483
- }
484
- }
449
+ console.error(`Error: Schema '${name}' not found`);
450
+ console.error(`Available schemas: ${available.join(', ')}`);
485
451
  }
486
- }
487
- catch (error) {
488
- console.error(`Error: ${error.message}`);
489
452
  process.exitCode = 1;
453
+ return;
490
454
  }
491
- });
492
- // schema validate
493
- schemaCmd
494
- .command('validate [name]')
495
- .description('Validate a schema structure and templates')
496
- .option('--json', 'Output as JSON')
497
- .option('--verbose', 'Show detailed validation steps')
498
- .action(async (name, options) => {
499
- try {
500
- const projectRoot = process.cwd();
501
- if (!name) {
502
- // Validate all project schemas
503
- const projectSchemasDir = getProjectSchemasDir(projectRoot);
504
- if (!fs.existsSync(projectSchemasDir)) {
505
- if (options?.json) {
506
- console.log(JSON.stringify({
507
- valid: true,
508
- message: 'No project schemas directory found',
509
- schemas: [],
510
- }, null, 2));
511
- }
512
- else {
513
- console.log('No project schemas directory found.');
514
- }
515
- return;
516
- }
517
- const entries = fs.readdirSync(projectSchemasDir, { withFileTypes: true });
518
- const schemaResults = [];
519
- let anyInvalid = false;
520
- for (const entry of entries) {
521
- if (!isSchemaDir(projectSchemasDir, entry))
522
- continue;
523
- const schemaDir = path.join(projectSchemasDir, entry.name);
524
- const schemaPath = path.join(schemaDir, 'schema.yaml');
525
- if (!fs.existsSync(schemaPath))
526
- continue;
527
- if (options?.verbose && !options?.json) {
528
- console.log(`\nValidating ${entry.name}...`);
529
- }
530
- const result = validateSchema(schemaDir, options?.verbose && !options?.json);
531
- schemaResults.push({
532
- name: entry.name,
533
- path: schemaDir,
534
- valid: result.valid,
535
- issues: result.issues,
536
- });
537
- if (!result.valid) {
538
- anyInvalid = true;
539
- }
455
+ if (options?.json) {
456
+ console.log(JSON.stringify(resolution, null, 2));
457
+ }
458
+ else {
459
+ console.log(`Schema: ${resolution.name}`);
460
+ console.log(`Source: ${resolution.source}`);
461
+ console.log(`Path: ${resolution.path}`);
462
+ if (resolution.shadows.length > 0) {
463
+ console.log('\nShadows:');
464
+ for (const shadow of resolution.shadows) {
465
+ console.log(` ${shadow.source}: ${shadow.path}`);
540
466
  }
467
+ }
468
+ }
469
+ }
470
+ catch (error) {
471
+ console.error(`Error: ${error.message}`);
472
+ process.exitCode = 1;
473
+ }
474
+ }
475
+ export async function schemaValidateCommand(name, options) {
476
+ try {
477
+ const projectRoot = process.cwd();
478
+ if (!name) {
479
+ // Validate all project schemas
480
+ const projectSchemasDir = getProjectSchemasDir(projectRoot);
481
+ if (!fs.existsSync(projectSchemasDir)) {
541
482
  if (options?.json) {
542
483
  console.log(JSON.stringify({
543
- valid: !anyInvalid,
544
- schemas: schemaResults,
484
+ valid: true,
485
+ message: 'No project schemas directory found',
486
+ schemas: [],
545
487
  }, null, 2));
546
488
  }
547
489
  else {
548
- if (schemaResults.length === 0) {
549
- console.log('No schemas found in project.');
550
- return;
551
- }
552
- console.log('\nValidation Results:');
553
- for (const result of schemaResults) {
554
- const status = result.valid ? '✓' : '✗';
555
- console.log(` ${status} ${result.name}`);
556
- for (const issue of result.issues) {
557
- console.log(` ${issue.level}: ${issue.message}`);
558
- }
559
- }
560
- }
561
- if (anyInvalid) {
562
- process.exitCode = 1;
490
+ console.log('No project schemas directory found.');
563
491
  }
564
492
  return;
565
493
  }
566
- // Validate specific schema
567
- const schemaDir = getSchemaDir(name, projectRoot);
568
- if (!schemaDir) {
569
- const available = listSchemas(projectRoot);
570
- if (options?.json) {
571
- console.log(JSON.stringify({
572
- valid: false,
573
- error: `Schema '${name}' not found`,
574
- available,
575
- }, null, 2));
494
+ const entries = fs.readdirSync(projectSchemasDir, { withFileTypes: true });
495
+ const schemaResults = [];
496
+ let anyInvalid = false;
497
+ for (const entry of entries) {
498
+ if (!isSchemaDir(projectSchemasDir, entry))
499
+ continue;
500
+ const schemaDir = path.join(projectSchemasDir, entry.name);
501
+ const schemaPath = path.join(schemaDir, 'schema.yaml');
502
+ if (!fs.existsSync(schemaPath))
503
+ continue;
504
+ if (options?.verbose && !options?.json) {
505
+ console.log(`\nValidating ${entry.name}...`);
576
506
  }
577
- else {
578
- console.error(`Error: Schema '${name}' not found`);
579
- console.error(`Available schemas: ${available.join(', ')}`);
507
+ const result = validateSchema(schemaDir, options?.verbose && !options?.json);
508
+ schemaResults.push({
509
+ name: entry.name,
510
+ path: schemaDir,
511
+ valid: result.valid,
512
+ issues: result.issues,
513
+ });
514
+ if (!result.valid) {
515
+ anyInvalid = true;
580
516
  }
581
- process.exitCode = 1;
582
- return;
583
517
  }
584
- if (options?.verbose && !options?.json) {
585
- console.log(`Validating ${name}...`);
586
- }
587
- const result = validateSchema(schemaDir, options?.verbose && !options?.json);
588
518
  if (options?.json) {
589
519
  console.log(JSON.stringify({
590
- name,
591
- path: schemaDir,
592
- valid: result.valid,
593
- issues: result.issues,
520
+ valid: !anyInvalid,
521
+ schemas: schemaResults,
594
522
  }, null, 2));
595
523
  }
596
524
  else {
597
- if (result.valid) {
598
- console.log(`✓ Schema '${name}' is valid`);
599
- for (const issue of result.issues) {
600
- console.log(` ${issue.level}: ${issue.message}`);
601
- }
525
+ if (schemaResults.length === 0) {
526
+ console.log('No schemas found in project.');
527
+ return;
602
528
  }
603
- else {
604
- console.log(`✗ Schema '${name}' has errors:`);
529
+ console.log('\nValidation Results:');
530
+ for (const result of schemaResults) {
531
+ const status = result.valid ? '✓' : '✗';
532
+ console.log(` ${status} ${result.name}`);
605
533
  for (const issue of result.issues) {
606
- console.log(` ${issue.level}: ${issue.message}`);
534
+ console.log(` ${issue.level}: ${issue.message}`);
607
535
  }
608
536
  }
609
537
  }
610
- if (!result.valid) {
538
+ if (anyInvalid) {
611
539
  process.exitCode = 1;
612
540
  }
541
+ return;
613
542
  }
614
- catch (error) {
543
+ // Validate specific schema
544
+ const schemaDir = getSchemaDir(name, projectRoot);
545
+ if (!schemaDir) {
546
+ const available = listSchemas(projectRoot);
615
547
  if (options?.json) {
616
548
  console.log(JSON.stringify({
617
549
  valid: false,
618
- error: error.message,
550
+ error: `Schema '${name}' not found`,
551
+ available,
619
552
  }, null, 2));
620
553
  }
621
554
  else {
622
- console.error(`Error: ${error.message}`);
555
+ console.error(`Error: Schema '${name}' not found`);
556
+ console.error(`Available schemas: ${available.join(', ')}`);
623
557
  }
624
558
  process.exitCode = 1;
559
+ return;
625
560
  }
626
- });
627
- // schema fork
628
- schemaCmd
629
- .command('fork <source> [name]')
630
- .description('Copy an existing schema to project for customization')
631
- .option('--json', 'Output as JSON')
632
- .option('--force', 'Overwrite existing destination')
633
- .action(async (source, name, options) => {
634
- const spinner = options?.json ? null : ora();
635
- try {
636
- const projectRoot = process.cwd();
637
- const destinationName = name || `${source}-custom`;
638
- // Validate destination name
639
- if (!isValidSchemaName(destinationName)) {
640
- if (options?.json) {
641
- console.log(JSON.stringify({
642
- forked: false,
643
- error: `Invalid schema name '${destinationName}'. Use kebab-case (e.g., my-workflow)`,
644
- }, null, 2));
645
- }
646
- else {
647
- console.error(`Error: Invalid schema name '${destinationName}'`);
648
- console.error('Schema names must be kebab-case (e.g., my-workflow)');
561
+ if (options?.verbose && !options?.json) {
562
+ console.log(`Validating ${name}...`);
563
+ }
564
+ const result = validateSchema(schemaDir, options?.verbose && !options?.json);
565
+ if (options?.json) {
566
+ console.log(JSON.stringify({
567
+ name,
568
+ path: schemaDir,
569
+ valid: result.valid,
570
+ issues: result.issues,
571
+ }, null, 2));
572
+ }
573
+ else {
574
+ if (result.valid) {
575
+ console.log(`✓ Schema '${name}' is valid`);
576
+ for (const issue of result.issues) {
577
+ console.log(` ${issue.level}: ${issue.message}`);
649
578
  }
650
- process.exitCode = 1;
651
- return;
652
579
  }
653
- // Find source schema
654
- const sourceDir = getSchemaDir(source, projectRoot);
655
- if (!sourceDir) {
656
- const available = listSchemas(projectRoot);
657
- if (options?.json) {
658
- console.log(JSON.stringify({
659
- forked: false,
660
- error: `Schema '${source}' not found`,
661
- available,
662
- }, null, 2));
663
- }
664
- else {
665
- console.error(`Error: Schema '${source}' not found`);
666
- console.error(`Available schemas: ${available.join(', ')}`);
580
+ else {
581
+ console.log(`✗ Schema '${name}' has errors:`);
582
+ for (const issue of result.issues) {
583
+ console.log(` ${issue.level}: ${issue.message}`);
667
584
  }
668
- process.exitCode = 1;
669
- return;
670
585
  }
671
- // Determine source location
672
- const sourceResolution = getSchemaResolution(source, projectRoot);
673
- const sourceLocation = sourceResolution?.source || 'package';
674
- // Validate the complete source before a forced fork removes anything.
675
- const trustedSourceDir = fs.realpathSync(sourceDir);
676
- assertSchemaTreeCanBeCopied(trustedSourceDir);
677
- // Validate the source's schema content up front too, so a structurally
678
- // invalid source is rejected before the --force path can remove an
679
- // existing destination. This keeps `fork --force` atomic — an unusable
680
- // source never destroys a valid destination — matching `schema init`,
681
- // which likewise validates before it overwrites.
682
- parseSchema(fs.readFileSync(path.join(trustedSourceDir, 'schema.yaml'), 'utf-8'));
683
- // Check destination
684
- const schemasDir = getProjectSchemasDir(projectRoot);
685
- const destinationDir = path.join(schemasDir, destinationName);
686
- // Reject a self-fork. Forking a schema onto itself with --force would
687
- // otherwise remove the source at the replacement step below and then
688
- // fail the copy, destroying the only copy of the schema. Resolve both
689
- // sides to their real paths (realpathSync follows symlinks; path.resolve
690
- // is a fallback only for a destination that does not exist yet) so a
691
- // symlink or a `.`/`..` spelling of the same directory is still caught.
692
- const resolvedDestination = fs.existsSync(destinationDir)
693
- ? fs.realpathSync(destinationDir)
694
- : path.resolve(destinationDir);
695
- if (resolvedDestination === trustedSourceDir) {
696
- throw new Error(`Cannot fork schema '${source}' onto itself; choose a different destination name`);
586
+ }
587
+ if (!result.valid) {
588
+ process.exitCode = 1;
589
+ }
590
+ }
591
+ catch (error) {
592
+ if (options?.json) {
593
+ console.log(JSON.stringify({
594
+ valid: false,
595
+ error: error.message,
596
+ }, null, 2));
597
+ }
598
+ else {
599
+ console.error(`Error: ${error.message}`);
600
+ }
601
+ process.exitCode = 1;
602
+ }
603
+ }
604
+ export async function schemaForkCommand(source, name, options) {
605
+ const spinner = options?.json ? null : ora();
606
+ try {
607
+ const projectRoot = process.cwd();
608
+ const destinationName = name || `${source}-custom`;
609
+ // Validate destination name
610
+ if (!isValidSchemaName(destinationName)) {
611
+ if (options?.json) {
612
+ console.log(JSON.stringify({
613
+ forked: false,
614
+ error: `Invalid schema name '${destinationName}'. Use kebab-case (e.g., my-workflow)`,
615
+ }, null, 2));
616
+ }
617
+ else {
618
+ console.error(`Error: Invalid schema name '${destinationName}'`);
619
+ console.error('Schema names must be kebab-case (e.g., my-workflow)');
697
620
  }
698
- const destinationExists = fs.existsSync(destinationDir);
699
- if (destinationExists && !options?.force) {
700
- if (options?.json) {
701
- console.log(JSON.stringify({
702
- forked: false,
703
- error: `Schema '${destinationName}' already exists`,
704
- suggestion: 'Use --force to overwrite',
705
- }, null, 2));
706
- }
707
- else {
708
- console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
709
- console.error('Use --force to overwrite');
710
- }
711
- process.exitCode = 1;
712
- return;
621
+ process.exitCode = 1;
622
+ return;
623
+ }
624
+ // Find source schema
625
+ const sourceDir = getSchemaDir(source, projectRoot);
626
+ if (!sourceDir) {
627
+ const available = listSchemas(projectRoot);
628
+ if (options?.json) {
629
+ console.log(JSON.stringify({
630
+ forked: false,
631
+ error: `Schema '${source}' not found`,
632
+ available,
633
+ }, null, 2));
713
634
  }
714
- // Fingerprint the destination the user authorized us to overwrite, BEFORE
715
- // we spend time staging. Staging can take a while, and a concurrent
716
- // process may edit the destination in that window; the fingerprint lets
717
- // us detect such a change and abort rather than clobber it.
718
- const authorizedDestinationFingerprint = destinationExists
719
- ? fingerprintDir(destinationDir)
720
- : null;
721
- // Stage the complete fork in a temporary sibling directory first, then
722
- // swap it into place. This keeps `fork --force` atomic: an existing
723
- // destination is only removed once the new fork has been fully copied,
724
- // name-updated, and (via the up-front parseSchema above) validated. Any
725
- // failure while staging leaves both the source and the existing
726
- // destination exactly as they were.
727
- if (spinner)
728
- spinner.start(`Forking '${source}' to '${destinationName}'...`);
729
- fs.mkdirSync(schemasDir, { recursive: true });
730
- const stagingDir = fs.mkdtempSync(path.join(schemasDir, '.fork-staging-'));
635
+ else {
636
+ console.error(`Error: Schema '${source}' not found`);
637
+ console.error(`Available schemas: ${available.join(', ')}`);
638
+ }
639
+ process.exitCode = 1;
640
+ return;
641
+ }
642
+ // Determine source location
643
+ const sourceResolution = getSchemaResolution(source, projectRoot);
644
+ const sourceLocation = sourceResolution?.source || 'package';
645
+ // Validate the complete source before a forced fork removes anything.
646
+ const trustedSourceDir = fs.realpathSync(sourceDir);
647
+ assertSchemaTreeCanBeCopied(trustedSourceDir);
648
+ // Validate the source's schema content up front too, so a structurally
649
+ // invalid source is rejected before the --force path can remove an
650
+ // existing destination. This keeps `fork --force` atomic — an unusable
651
+ // source never destroys a valid destination — matching `schema init`,
652
+ // which likewise validates before it overwrites.
653
+ parseSchema(fs.readFileSync(path.join(trustedSourceDir, 'schema.yaml'), 'utf-8'));
654
+ // Check destination
655
+ const schemasDir = getProjectSchemasDir(projectRoot);
656
+ const destinationDir = path.join(schemasDir, destinationName);
657
+ // Reject a self-fork. Forking a schema onto itself with --force would
658
+ // otherwise remove the source at the replacement step below and then
659
+ // fail the copy, destroying the only copy of the schema. Resolve both
660
+ // sides to their real paths (realpathSync follows symlinks; path.resolve
661
+ // is a fallback only for a destination that does not exist yet) so a
662
+ // symlink or a `.`/`..` spelling of the same directory is still caught.
663
+ const resolvedDestination = fs.existsSync(destinationDir)
664
+ ? fs.realpathSync(destinationDir)
665
+ : path.resolve(destinationDir);
666
+ if (resolvedDestination === trustedSourceDir) {
667
+ throw new Error(`Cannot fork schema '${source}' onto itself; choose a different destination name`);
668
+ }
669
+ const destinationExists = fs.existsSync(destinationDir);
670
+ if (destinationExists && !options?.force) {
671
+ if (options?.json) {
672
+ console.log(JSON.stringify({
673
+ forked: false,
674
+ error: `Schema '${destinationName}' already exists`,
675
+ suggestion: 'Use --force to overwrite',
676
+ }, null, 2));
677
+ }
678
+ else {
679
+ console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
680
+ console.error('Use --force to overwrite');
681
+ }
682
+ process.exitCode = 1;
683
+ return;
684
+ }
685
+ // Fingerprint the destination the user authorized us to overwrite, BEFORE
686
+ // we spend time staging. Staging can take a while, and a concurrent
687
+ // process may edit the destination in that window; the fingerprint lets
688
+ // us detect such a change and abort rather than clobber it.
689
+ const authorizedDestinationFingerprint = destinationExists
690
+ ? fingerprintDir(destinationDir)
691
+ : null;
692
+ // Stage the complete fork in a temporary sibling directory first, then
693
+ // swap it into place. This keeps `fork --force` atomic: an existing
694
+ // destination is only removed once the new fork has been fully copied,
695
+ // name-updated, and (via the up-front parseSchema above) validated. Any
696
+ // failure while staging leaves both the source and the existing
697
+ // destination exactly as they were.
698
+ if (spinner)
699
+ spinner.start(`Forking '${source}' to '${destinationName}'...`);
700
+ fs.mkdirSync(schemasDir, { recursive: true });
701
+ const stagingDir = fs.mkdtempSync(path.join(schemasDir, '.fork-staging-'));
702
+ try {
703
+ copyDirRecursive(trustedSourceDir, stagingDir);
704
+ // Update name in the staged schema.yaml via yaml's Document API
705
+ // instead of re-serializing the parsed object, so block scalars,
706
+ // comments, and key order in the source schema.yaml survive the fork.
707
+ const stagedSchemaPath = path.join(stagingDir, 'schema.yaml');
708
+ const schemaContent = fs.readFileSync(stagedSchemaPath, 'utf-8');
709
+ const doc = parseDocument(schemaContent);
710
+ doc.set('name', destinationName);
711
+ fs.writeFileSync(stagedSchemaPath, doc.toString());
712
+ // Authoritative gate: validate the COMPLETED staged schema — the exact
713
+ // bytes we are about to install — not just the source at the pre-check.
714
+ // The source files copyDirRecursive reads can change mid-copy, so a
715
+ // source that was valid up front can still produce an invalid staged
716
+ // fork. Validating here, before ANY destructive step, guarantees we
717
+ // never install an invalid fork or delete a valid destination for one.
731
718
  try {
732
- copyDirRecursive(trustedSourceDir, stagingDir);
733
- // Update name in the staged schema.yaml via yaml's Document API
734
- // instead of re-serializing the parsed object, so block scalars,
735
- // comments, and key order in the source schema.yaml survive the fork.
736
- const stagedSchemaPath = path.join(stagingDir, 'schema.yaml');
737
- const schemaContent = fs.readFileSync(stagedSchemaPath, 'utf-8');
738
- const doc = parseDocument(schemaContent);
739
- doc.set('name', destinationName);
740
- fs.writeFileSync(stagedSchemaPath, doc.toString());
741
- // Authoritative gate: validate the COMPLETED staged schema — the exact
742
- // bytes we are about to install — not just the source at the pre-check.
743
- // The source files copyDirRecursive reads can change mid-copy, so a
744
- // source that was valid up front can still produce an invalid staged
745
- // fork. Validating here, before ANY destructive step, guarantees we
746
- // never install an invalid fork or delete a valid destination for one.
747
- try {
748
- parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8'));
719
+ parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8'));
720
+ }
721
+ catch (validationError) {
722
+ throw new Error(`The staged fork of '${source}' is not a valid schema (the source may have changed during copy); ` +
723
+ `aborted, '${destinationName}' was not modified.`, { cause: validationError });
724
+ }
725
+ // Swap the staged fork into place. When a destination already exists,
726
+ // move it aside to a sibling backup FIRST, then install the staged
727
+ // fork; only once the install succeeds is the backup discarded. If the
728
+ // install rename itself fails (e.g. a Windows lock), the backup is
729
+ // moved back so the user's original destination is never lost.
730
+ if (destinationExists) {
731
+ if (spinner)
732
+ spinner.text = `Replacing existing schema '${destinationName}'...`;
733
+ // Revalidate immediately before the destructive move: if the
734
+ // destination changed on disk while we were staging (or was removed),
735
+ // its fingerprint no longer matches what the user authorized. Abort
736
+ // WITHOUT touching it, so the concurrent changes are preserved. The
737
+ // outer catch cleans up staging.
738
+ const currentFingerprint = fs.existsSync(destinationDir)
739
+ ? fingerprintDir(destinationDir)
740
+ : null;
741
+ if (currentFingerprint !== authorizedDestinationFingerprint) {
742
+ throw new Error(`Schema '${destinationName}' at ${destinationDir} changed on disk while the fork was being prepared. ` +
743
+ `Aborted to preserve those concurrent changes; nothing was overwritten. Re-run the fork to overwrite the current contents.`);
749
744
  }
750
- catch (validationError) {
751
- throw new Error(`The staged fork of '${source}' is not a valid schema (the source may have changed during copy); ` +
752
- `aborted, '${destinationName}' was not modified.`, { cause: validationError });
745
+ const backupDir = `${destinationDir}.fork-backup-${process.pid}-${Date.now()}`;
746
+ fs.renameSync(destinationDir, backupDir);
747
+ try {
748
+ fs.renameSync(stagingDir, destinationDir);
753
749
  }
754
- // Swap the staged fork into place. When a destination already exists,
755
- // move it aside to a sibling backup FIRST, then install the staged
756
- // fork; only once the install succeeds is the backup discarded. If the
757
- // install rename itself fails (e.g. a Windows lock), the backup is
758
- // moved back so the user's original destination is never lost.
759
- if (destinationExists) {
760
- if (spinner)
761
- spinner.text = `Replacing existing schema '${destinationName}'...`;
762
- // Revalidate immediately before the destructive move: if the
763
- // destination changed on disk while we were staging (or was removed),
764
- // its fingerprint no longer matches what the user authorized. Abort
765
- // WITHOUT touching it, so the concurrent changes are preserved. The
766
- // outer catch cleans up staging.
767
- const currentFingerprint = fs.existsSync(destinationDir)
768
- ? fingerprintDir(destinationDir)
769
- : null;
770
- if (currentFingerprint !== authorizedDestinationFingerprint) {
771
- throw new Error(`Schema '${destinationName}' at ${destinationDir} changed on disk while the fork was being prepared. ` +
772
- `Aborted to preserve those concurrent changes; nothing was overwritten. Re-run the fork to overwrite the current contents.`);
773
- }
774
- const backupDir = `${destinationDir}.fork-backup-${process.pid}-${Date.now()}`;
775
- fs.renameSync(destinationDir, backupDir);
750
+ catch (installError) {
751
+ // Install failed after the original was moved aside. Try to move
752
+ // it back. If that restore ALSO fails, the original is stranded in
753
+ // the backup dir — surface an error naming both the backup and the
754
+ // destination so the user can recover manually, and attach the
755
+ // original install error as the cause. Never swallow this.
776
756
  try {
777
- fs.renameSync(stagingDir, destinationDir);
757
+ fs.renameSync(backupDir, destinationDir);
778
758
  }
779
- catch (installError) {
780
- // Install failed after the original was moved aside. Try to move
781
- // it back. If that restore ALSO fails, the original is stranded in
782
- // the backup dir — surface an error naming both the backup and the
783
- // destination so the user can recover manually, and attach the
784
- // original install error as the cause. Never swallow this.
785
- try {
786
- fs.renameSync(backupDir, destinationDir);
787
- }
788
- catch (restoreError) {
789
- throw new Error(`Failed to install the forked schema and could not restore the previous '${destinationName}'. ` +
790
- `Your previous schema is preserved at ${backupDir}; move it back to ${destinationDir} to restore. ` +
791
- `Restore error: ${restoreError.message}`, { cause: installError });
792
- }
793
- throw installError;
759
+ catch (restoreError) {
760
+ throw new Error(`Failed to install the forked schema and could not restore the previous '${destinationName}'. ` +
761
+ `Your previous schema is preserved at ${backupDir}; move it back to ${destinationDir} to restore. ` +
762
+ `Restore error: ${restoreError.message}`, { cause: installError });
794
763
  }
795
- // Revalidate before discarding the backup: only delete it if it is
796
- // still byte-for-byte the original destination we moved aside. If it
797
- // changed during the install window (a concurrent write to the
798
- // moved-aside directory), do NOT delete it — leave it in place and
799
- // surface where it is so nothing is lost.
800
- if (fingerprintDir(backupDir) === authorizedDestinationFingerprint) {
801
- fs.rmSync(backupDir, { recursive: true, force: true });
802
- }
803
- else {
804
- console.error(`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
805
- `its pre-fork copy is preserved at ${backupDir}.`);
806
- }
807
- }
808
- else {
809
- fs.renameSync(stagingDir, destinationDir);
764
+ throw installError;
810
765
  }
811
- }
812
- catch (error) {
813
- // Remove only the staging directory we created this run; the source
814
- // and any existing destination are left exactly as we found them.
815
- // Guard the cleanup in its own try/catch so a failed removal (e.g. a
816
- // locked file on Windows) can never mask the original error, then
817
- // rethrow so the real failure still drives the JSON/exit-code report.
818
- try {
819
- fs.rmSync(stagingDir, { recursive: true, force: true });
766
+ // Revalidate before discarding the backup: only delete it if it is
767
+ // still byte-for-byte the original destination we moved aside. If it
768
+ // changed during the install window (a concurrent write to the
769
+ // moved-aside directory), do NOT delete it — leave it in place and
770
+ // surface where it is so nothing is lost.
771
+ if (fingerprintDir(backupDir) === authorizedDestinationFingerprint) {
772
+ fs.rmSync(backupDir, { recursive: true, force: true });
820
773
  }
821
- catch {
822
- // Best-effort cleanup; the original error below is what matters.
774
+ else {
775
+ console.error(`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
776
+ `its pre-fork copy is preserved at ${backupDir}.`);
823
777
  }
824
- throw error;
825
- }
826
- if (spinner)
827
- spinner.succeed(`Forked '${source}' to '${destinationName}'`);
828
- if (options?.json) {
829
- console.log(JSON.stringify({
830
- forked: true,
831
- source,
832
- sourcePath: sourceDir,
833
- sourceLocation,
834
- destination: destinationName,
835
- destinationPath: destinationDir,
836
- }, null, 2));
837
778
  }
838
779
  else {
839
- console.log(`\nSource: ${sourceDir} (${sourceLocation})`);
840
- console.log(`Destination: ${destinationDir}`);
841
- console.log(`\nYou can now customize the schema at:`);
842
- console.log(` ${destinationDir}/schema.yaml`);
780
+ fs.renameSync(stagingDir, destinationDir);
843
781
  }
844
782
  }
845
783
  catch (error) {
846
- if (spinner)
847
- spinner.fail(`Fork failed`);
784
+ // Remove only the staging directory we created this run; the source
785
+ // and any existing destination are left exactly as we found them.
786
+ // Guard the cleanup in its own try/catch so a failed removal (e.g. a
787
+ // locked file on Windows) can never mask the original error, then
788
+ // rethrow so the real failure still drives the JSON/exit-code report.
789
+ try {
790
+ fs.rmSync(stagingDir, { recursive: true, force: true });
791
+ }
792
+ catch {
793
+ // Best-effort cleanup; the original error below is what matters.
794
+ }
795
+ throw error;
796
+ }
797
+ if (spinner)
798
+ spinner.succeed(`Forked '${source}' to '${destinationName}'`);
799
+ if (options?.json) {
800
+ console.log(JSON.stringify({
801
+ forked: true,
802
+ source,
803
+ sourcePath: sourceDir,
804
+ sourceLocation,
805
+ destination: destinationName,
806
+ destinationPath: destinationDir,
807
+ }, null, 2));
808
+ }
809
+ else {
810
+ console.log(`\nSource: ${sourceDir} (${sourceLocation})`);
811
+ console.log(`Destination: ${destinationDir}`);
812
+ console.log(`\nYou can now customize the schema at:`);
813
+ console.log(` ${destinationDir}/schema.yaml`);
814
+ }
815
+ }
816
+ catch (error) {
817
+ if (spinner)
818
+ spinner.fail(`Fork failed`);
819
+ if (options?.json) {
820
+ console.log(JSON.stringify({
821
+ forked: false,
822
+ error: error.message,
823
+ }, null, 2));
824
+ }
825
+ else {
826
+ console.error(`Error: ${error.message}`);
827
+ }
828
+ process.exitCode = 1;
829
+ }
830
+ }
831
+ export async function schemaInitCommand(name, options) {
832
+ const spinner = options?.json ? null : ora();
833
+ try {
834
+ const projectRoot = process.cwd();
835
+ // Validate name
836
+ if (!isValidSchemaName(name)) {
848
837
  if (options?.json) {
849
838
  console.log(JSON.stringify({
850
- forked: false,
851
- error: error.message,
839
+ created: false,
840
+ error: `Invalid schema name '${name}'. Use kebab-case (e.g., my-workflow)`,
852
841
  }, null, 2));
853
842
  }
854
843
  else {
855
- console.error(`Error: ${error.message}`);
844
+ console.error(`Error: Invalid schema name '${name}'`);
845
+ console.error('Schema names must be kebab-case (e.g., my-workflow)');
856
846
  }
857
847
  process.exitCode = 1;
848
+ return;
858
849
  }
859
- });
860
- // schema init
861
- schemaCmd
862
- .command('init <name>')
863
- .description('Create a new project-local schema')
864
- .option('--json', 'Output as JSON')
865
- .option('--description <text>', 'Schema description')
866
- .option('--artifacts <list>', 'Comma-separated artifact IDs (proposal,specs,design,tasks)')
867
- .option('--default', 'Set as project default schema')
868
- .option('--no-default', 'Do not prompt to set as default')
869
- .option('--force', 'Overwrite existing schema')
870
- .action(async (name, options) => {
871
- const spinner = options?.json ? null : ora();
872
- try {
873
- const projectRoot = process.cwd();
874
- // Validate name
875
- if (!isValidSchemaName(name)) {
850
+ const schemaDir = path.join(getProjectSchemasDir(projectRoot), name);
851
+ // Check overwrite permission without mutating the destination
852
+ const schemaExists = fs.existsSync(schemaDir);
853
+ if (schemaExists) {
854
+ if (!options?.force) {
876
855
  if (options?.json) {
877
856
  console.log(JSON.stringify({
878
857
  created: false,
879
- error: `Invalid schema name '${name}'. Use kebab-case (e.g., my-workflow)`,
858
+ error: `Schema '${name}' already exists`,
859
+ suggestion: 'Use --force to overwrite or "openspec schema fork" to copy',
880
860
  }, null, 2));
881
861
  }
882
862
  else {
883
- console.error(`Error: Invalid schema name '${name}'`);
884
- console.error('Schema names must be kebab-case (e.g., my-workflow)');
863
+ console.error(`Error: Schema '${name}' already exists at ${schemaDir}`);
864
+ console.error('Use --force to overwrite or "openspec schema fork" to copy');
885
865
  }
886
866
  process.exitCode = 1;
887
867
  return;
888
868
  }
889
- const schemaDir = path.join(getProjectSchemasDir(projectRoot), name);
890
- // Check overwrite permission without mutating the destination
891
- const schemaExists = fs.existsSync(schemaDir);
892
- if (schemaExists) {
893
- if (!options?.force) {
894
- if (options?.json) {
895
- console.log(JSON.stringify({
896
- created: false,
897
- error: `Schema '${name}' already exists`,
898
- suggestion: 'Use --force to overwrite or "openspec schema fork" to copy',
899
- }, null, 2));
900
- }
901
- else {
902
- console.error(`Error: Schema '${name}' already exists at ${schemaDir}`);
903
- console.error('Use --force to overwrite or "openspec schema fork" to copy');
904
- }
905
- process.exitCode = 1;
906
- return;
907
- }
908
- }
909
- // Determine artifacts and description
910
- let description;
911
- let selectedArtifactIds;
912
- // Check if we have explicit flags (non-interactive mode)
913
- const hasExplicitOptions = options?.description !== undefined || options?.artifacts !== undefined;
914
- const isInteractive = !options?.json && !hasExplicitOptions && process.stdout.isTTY;
915
- if (isInteractive) {
916
- // Interactive mode
917
- const { input, checkbox, confirm } = await import('@inquirer/prompts');
918
- description = await input({
919
- message: 'Schema description:',
920
- default: `Custom workflow schema for ${name}`,
921
- });
922
- const artifactChoices = DEFAULT_ARTIFACTS.map((a) => ({
923
- name: a.id,
924
- value: a.id,
925
- checked: true,
926
- }));
927
- selectedArtifactIds = await checkbox({
928
- message: 'Select artifacts to include:',
929
- theme: {
930
- icon: {
931
- checked: '[x]',
932
- unchecked: '[ ]',
933
- },
869
+ }
870
+ // Determine artifacts and description
871
+ let description;
872
+ let selectedArtifactIds;
873
+ // Check if we have explicit flags (non-interactive mode)
874
+ const hasExplicitOptions = options?.description !== undefined || options?.artifacts !== undefined;
875
+ const isInteractive = !options?.json && !hasExplicitOptions && process.stdout.isTTY;
876
+ if (isInteractive) {
877
+ // Interactive mode
878
+ const { input, checkbox, confirm } = await import('@inquirer/prompts');
879
+ description = await input({
880
+ message: 'Schema description:',
881
+ default: `Custom workflow schema for ${name}`,
882
+ });
883
+ const artifactChoices = DEFAULT_ARTIFACTS.map((a) => ({
884
+ name: a.id,
885
+ value: a.id,
886
+ checked: true,
887
+ }));
888
+ selectedArtifactIds = await checkbox({
889
+ message: 'Select artifacts to include:',
890
+ theme: {
891
+ icon: {
892
+ checked: '[x]',
893
+ unchecked: '[ ]',
934
894
  },
935
- choices: artifactChoices,
895
+ },
896
+ choices: artifactChoices,
897
+ });
898
+ if (selectedArtifactIds.length === 0) {
899
+ console.error('Error: At least one artifact must be selected');
900
+ process.exitCode = 1;
901
+ return;
902
+ }
903
+ // Ask about setting as default (unless --no-default was passed)
904
+ if (options?.default === undefined) {
905
+ const setAsDefault = await confirm({
906
+ message: 'Set as project default schema?',
907
+ default: false,
936
908
  });
937
- if (selectedArtifactIds.length === 0) {
938
- console.error('Error: At least one artifact must be selected');
939
- process.exitCode = 1;
940
- return;
941
- }
942
- // Ask about setting as default (unless --no-default was passed)
943
- if (options?.default === undefined) {
944
- const setAsDefault = await confirm({
945
- message: 'Set as project default schema?',
946
- default: false,
947
- });
948
- if (setAsDefault) {
949
- options = { ...options, default: true };
950
- }
909
+ if (setAsDefault) {
910
+ options = { ...options, default: true };
951
911
  }
952
912
  }
953
- else {
954
- // Non-interactive mode
955
- description = options?.description || `Custom workflow schema for ${name}`;
956
- if (options?.artifacts) {
957
- selectedArtifactIds = options.artifacts.split(',').map((a) => a.trim());
958
- // Validate artifact IDs
959
- const validIds = DEFAULT_ARTIFACTS.map((a) => a.id);
960
- for (const id of selectedArtifactIds) {
961
- if (!validIds.includes(id)) {
962
- if (options?.json) {
963
- console.log(JSON.stringify({
964
- created: false,
965
- error: `Unknown artifact '${id}'`,
966
- valid: validIds,
967
- }, null, 2));
968
- }
969
- else {
970
- console.error(`Error: Unknown artifact '${id}'`);
971
- console.error(`Valid artifacts: ${validIds.join(', ')}`);
972
- }
973
- process.exitCode = 1;
974
- return;
913
+ }
914
+ else {
915
+ // Non-interactive mode
916
+ description = options?.description || `Custom workflow schema for ${name}`;
917
+ if (options?.artifacts) {
918
+ selectedArtifactIds = options.artifacts.split(',').map((a) => a.trim());
919
+ // Validate artifact IDs
920
+ const validIds = DEFAULT_ARTIFACTS.map((a) => a.id);
921
+ for (const id of selectedArtifactIds) {
922
+ if (!validIds.includes(id)) {
923
+ if (options?.json) {
924
+ console.log(JSON.stringify({
925
+ created: false,
926
+ error: `Unknown artifact '${id}'`,
927
+ valid: validIds,
928
+ }, null, 2));
975
929
  }
930
+ else {
931
+ console.error(`Error: Unknown artifact '${id}'`);
932
+ console.error(`Valid artifacts: ${validIds.join(', ')}`);
933
+ }
934
+ process.exitCode = 1;
935
+ return;
976
936
  }
977
937
  }
978
- else {
979
- // Default to all artifacts
980
- selectedArtifactIds = DEFAULT_ARTIFACTS.map((a) => a.id);
981
- }
982
938
  }
983
- // Build artifacts array with proper dependencies
984
- const selectedArtifacts = selectedArtifactIds.map((id) => {
985
- const template = DEFAULT_ARTIFACTS.find((a) => a.id === id);
986
- const artifact = {
987
- id: template.id,
988
- generates: template.generates,
989
- description: template.description,
990
- template: template.template,
991
- requires: [],
992
- };
993
- // Set up dependencies based on typical workflow
994
- if (id === 'specs' && selectedArtifactIds.includes('proposal')) {
995
- artifact.requires = ['proposal'];
996
- }
997
- else if (id === 'design' && selectedArtifactIds.includes('specs')) {
998
- artifact.requires = ['specs'];
999
- }
1000
- else if (id === 'tasks') {
1001
- const requires = [];
1002
- if (selectedArtifactIds.includes('design'))
1003
- requires.push('design');
1004
- else if (selectedArtifactIds.includes('specs'))
1005
- requires.push('specs');
1006
- artifact.requires = requires;
1007
- }
1008
- return artifact;
1009
- });
1010
- // Create schema.yaml
1011
- const schema = {
1012
- name,
1013
- version: 1,
1014
- description,
1015
- artifacts: selectedArtifacts,
939
+ else {
940
+ // Default to all artifacts
941
+ selectedArtifactIds = DEFAULT_ARTIFACTS.map((a) => a.id);
942
+ }
943
+ }
944
+ // Build artifacts array with proper dependencies
945
+ const selectedArtifacts = selectedArtifactIds.map((id) => {
946
+ const template = DEFAULT_ARTIFACTS.find((a) => a.id === id);
947
+ const artifact = {
948
+ id: template.id,
949
+ generates: template.generates,
950
+ description: template.description,
951
+ template: template.template,
952
+ requires: [],
1016
953
  };
1017
- // Add apply phase if tasks is included
1018
- if (selectedArtifactIds.includes('tasks')) {
1019
- schema.apply = {
1020
- requires: ['tasks'],
1021
- tracks: 'tasks.md',
1022
- };
954
+ // Set up dependencies based on typical workflow
955
+ if (id === 'specs' && selectedArtifactIds.includes('proposal')) {
956
+ artifact.requires = ['proposal'];
1023
957
  }
1024
- // Parse and serialize the config before staging any schema files. This
1025
- // makes malformed, non-object, linked, and read-only configs fail before
1026
- // an existing schema can be moved or a new one can appear.
1027
- const preparedConfig = options?.default
1028
- ? await prepareDefaultConfigUpdate(projectRoot, name)
1029
- : null;
1030
- const schemasDir = getProjectSchemasDir(projectRoot);
958
+ else if (id === 'design' && selectedArtifactIds.includes('specs')) {
959
+ artifact.requires = ['specs'];
960
+ }
961
+ else if (id === 'tasks') {
962
+ const requires = [];
963
+ if (selectedArtifactIds.includes('design'))
964
+ requires.push('design');
965
+ else if (selectedArtifactIds.includes('specs'))
966
+ requires.push('specs');
967
+ artifact.requires = requires;
968
+ }
969
+ return artifact;
970
+ });
971
+ // Create schema.yaml
972
+ const schema = {
973
+ name,
974
+ version: 1,
975
+ description,
976
+ artifacts: selectedArtifacts,
977
+ };
978
+ // Add apply phase if tasks is included
979
+ if (selectedArtifactIds.includes('tasks')) {
980
+ schema.apply = {
981
+ requires: ['tasks'],
982
+ tracks: 'tasks.md',
983
+ };
984
+ }
985
+ // Parse and serialize the config before staging any schema files. This
986
+ // makes malformed, non-object, linked, and read-only configs fail before
987
+ // an existing schema can be moved or a new one can appear.
988
+ const preparedConfig = options?.default
989
+ ? await prepareDefaultConfigUpdate(projectRoot, name)
990
+ : null;
991
+ const schemasDir = getProjectSchemasDir(projectRoot);
992
+ FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
993
+ const authorizedSchemaFingerprint = schemaExists
994
+ ? fingerprintDir(schemaDir)
995
+ : null;
996
+ if (spinner)
997
+ spinner.start(`Creating schema '${name}'...`);
998
+ fs.mkdirSync(schemasDir, { recursive: true });
999
+ const schemaStagingDir = fs.mkdtempSync(path.join(schemasDir, '.init-staging-'));
1000
+ let configStagingDir = null;
1001
+ let stagedConfigPath = null;
1002
+ try {
1003
+ fs.writeFileSync(path.join(schemaStagingDir, 'schema.yaml'), stringifyYaml(schema));
1004
+ const templatesDir = path.join(schemaStagingDir, 'templates');
1005
+ for (const artifact of selectedArtifacts) {
1006
+ const templatePath = path.join(templatesDir, artifact.template);
1007
+ fs.mkdirSync(path.dirname(templatePath), { recursive: true });
1008
+ fs.writeFileSync(templatePath, createDefaultTemplate(artifact.id));
1009
+ }
1010
+ const validation = validateSchema(schemaStagingDir);
1011
+ if (!validation.valid) {
1012
+ throw new Error(`Generated schema failed validation: ${validation.issues
1013
+ .map((issue) => issue.message)
1014
+ .join('; ')}`);
1015
+ }
1016
+ if (preparedConfig) {
1017
+ const configDir = path.dirname(preparedConfig.path);
1018
+ configStagingDir = fs.mkdtempSync(path.join(configDir, '.schema-init-config-'));
1019
+ stagedConfigPath = path.join(configStagingDir, path.basename(preparedConfig.path));
1020
+ fs.writeFileSync(stagedConfigPath, preparedConfig.content);
1021
+ if (preparedConfig.originalMode !== null) {
1022
+ fs.chmodSync(stagedConfigPath, preparedConfig.originalMode);
1023
+ }
1024
+ }
1025
+ // Re-resolve both destinations immediately before the first move so
1026
+ // a parent symlink swap during staging cannot redirect the commit.
1031
1027
  FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
1032
- const authorizedSchemaFingerprint = schemaExists
1028
+ if (preparedConfig) {
1029
+ FileSystemUtils.assertProjectArtifactPath(projectRoot, preparedConfig.path);
1030
+ }
1031
+ const currentSchemaFingerprint = fs.existsSync(schemaDir)
1033
1032
  ? fingerprintDir(schemaDir)
1034
1033
  : null;
1035
- if (spinner)
1036
- spinner.start(`Creating schema '${name}'...`);
1037
- fs.mkdirSync(schemasDir, { recursive: true });
1038
- const schemaStagingDir = fs.mkdtempSync(path.join(schemasDir, '.init-staging-'));
1039
- let configStagingDir = null;
1040
- let stagedConfigPath = null;
1034
+ if (currentSchemaFingerprint !== authorizedSchemaFingerprint) {
1035
+ throw new Error(`Schema '${name}' changed on disk while initialization was being prepared. ` +
1036
+ 'Aborted to preserve those concurrent changes.');
1037
+ }
1038
+ if (preparedConfig && !configMatchesPreparedState(preparedConfig)) {
1039
+ throw new Error(`${path.basename(preparedConfig.path)} changed on disk while initialization was being prepared. ` +
1040
+ 'Aborted to preserve those concurrent changes.');
1041
+ }
1042
+ const token = `${process.pid}-${Date.now()}`;
1043
+ const schemaBackup = `${schemaDir}.init-backup-${token}`;
1044
+ const configBackup = preparedConfig
1045
+ ? `${preparedConfig.path}.init-backup-${token}`
1046
+ : null;
1047
+ let schemaBackedUp = false;
1048
+ let configBackedUp = false;
1049
+ let schemaInstalled = false;
1050
+ let configInstalled = false;
1041
1051
  try {
1042
- fs.writeFileSync(path.join(schemaStagingDir, 'schema.yaml'), stringifyYaml(schema));
1043
- const templatesDir = path.join(schemaStagingDir, 'templates');
1044
- for (const artifact of selectedArtifacts) {
1045
- const templatePath = path.join(templatesDir, artifact.template);
1046
- fs.mkdirSync(path.dirname(templatePath), { recursive: true });
1047
- fs.writeFileSync(templatePath, createDefaultTemplate(artifact.id));
1048
- }
1049
- const validation = validateSchema(schemaStagingDir);
1050
- if (!validation.valid) {
1051
- throw new Error(`Generated schema failed validation: ${validation.issues
1052
- .map((issue) => issue.message)
1053
- .join('; ')}`);
1054
- }
1055
- if (preparedConfig) {
1056
- const configDir = path.dirname(preparedConfig.path);
1057
- configStagingDir = fs.mkdtempSync(path.join(configDir, '.schema-init-config-'));
1058
- stagedConfigPath = path.join(configStagingDir, path.basename(preparedConfig.path));
1059
- fs.writeFileSync(stagedConfigPath, preparedConfig.content);
1060
- if (preparedConfig.originalMode !== null) {
1061
- fs.chmodSync(stagedConfigPath, preparedConfig.originalMode);
1062
- }
1052
+ if (schemaExists) {
1053
+ schemaInitFileOperations.renameSync(schemaDir, schemaBackup);
1054
+ schemaBackedUp = true;
1063
1055
  }
1064
- // Re-resolve both destinations immediately before the first move so
1065
- // a parent symlink swap during staging cannot redirect the commit.
1066
- FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
1067
- if (preparedConfig) {
1068
- FileSystemUtils.assertProjectArtifactPath(projectRoot, preparedConfig.path);
1056
+ if (preparedConfig && preparedConfig.originalContent !== null) {
1057
+ schemaInitFileOperations.renameSync(preparedConfig.path, configBackup);
1058
+ configBackedUp = true;
1069
1059
  }
1070
- const currentSchemaFingerprint = fs.existsSync(schemaDir)
1071
- ? fingerprintDir(schemaDir)
1072
- : null;
1073
- if (currentSchemaFingerprint !== authorizedSchemaFingerprint) {
1074
- throw new Error(`Schema '${name}' changed on disk while initialization was being prepared. ` +
1075
- 'Aborted to preserve those concurrent changes.');
1076
- }
1077
- if (preparedConfig && !configMatchesPreparedState(preparedConfig)) {
1078
- throw new Error(`${path.basename(preparedConfig.path)} changed on disk while initialization was being prepared. ` +
1079
- 'Aborted to preserve those concurrent changes.');
1060
+ schemaInitFileOperations.renameSync(schemaStagingDir, schemaDir);
1061
+ schemaInstalled = true;
1062
+ if (preparedConfig && stagedConfigPath) {
1063
+ schemaInitFileOperations.renameSync(stagedConfigPath, preparedConfig.path);
1064
+ configInstalled = true;
1080
1065
  }
1081
- const token = `${process.pid}-${Date.now()}`;
1082
- const schemaBackup = `${schemaDir}.init-backup-${token}`;
1083
- const configBackup = preparedConfig
1084
- ? `${preparedConfig.path}.init-backup-${token}`
1085
- : null;
1086
- let schemaBackedUp = false;
1087
- let configBackedUp = false;
1088
- let schemaInstalled = false;
1089
- let configInstalled = false;
1066
+ }
1067
+ catch (installError) {
1068
+ const rollbackErrors = [];
1090
1069
  try {
1091
- if (schemaExists) {
1092
- schemaInitFileOperations.renameSync(schemaDir, schemaBackup);
1093
- schemaBackedUp = true;
1094
- }
1095
- if (preparedConfig && preparedConfig.originalContent !== null) {
1096
- schemaInitFileOperations.renameSync(preparedConfig.path, configBackup);
1097
- configBackedUp = true;
1070
+ if (configInstalled && preparedConfig) {
1071
+ fs.rmSync(preparedConfig.path, { force: true });
1098
1072
  }
1099
- schemaInitFileOperations.renameSync(schemaStagingDir, schemaDir);
1100
- schemaInstalled = true;
1101
- if (preparedConfig && stagedConfigPath) {
1102
- schemaInitFileOperations.renameSync(stagedConfigPath, preparedConfig.path);
1103
- configInstalled = true;
1073
+ if (configBackedUp && preparedConfig && configBackup) {
1074
+ schemaInitFileOperations.renameSync(configBackup, preparedConfig.path);
1104
1075
  }
1105
1076
  }
1106
- catch (installError) {
1107
- const rollbackErrors = [];
1108
- try {
1109
- if (configInstalled && preparedConfig) {
1110
- fs.rmSync(preparedConfig.path, { force: true });
1111
- }
1112
- if (configBackedUp && preparedConfig && configBackup) {
1113
- schemaInitFileOperations.renameSync(configBackup, preparedConfig.path);
1114
- }
1115
- }
1116
- catch (rollbackError) {
1117
- rollbackErrors.push(`config: ${rollbackError.message}`);
1118
- }
1119
- try {
1120
- if (schemaInstalled) {
1121
- fs.rmSync(schemaDir, { recursive: true, force: true });
1122
- }
1123
- if (schemaBackedUp) {
1124
- schemaInitFileOperations.renameSync(schemaBackup, schemaDir);
1125
- }
1126
- }
1127
- catch (rollbackError) {
1128
- rollbackErrors.push(`schema: ${rollbackError.message}`);
1129
- }
1130
- if (rollbackErrors.length > 0) {
1131
- throw new Error(`Schema initialization failed and rollback was incomplete (${rollbackErrors.join(', ')}). ` +
1132
- `Recovery backups may remain beside ${schemaDir} and ${preparedConfig?.path ?? 'the config file'}.`, { cause: installError });
1133
- }
1134
- throw installError;
1077
+ catch (rollbackError) {
1078
+ rollbackErrors.push(`config: ${rollbackError.message}`);
1135
1079
  }
1136
- // The transaction is committed. Cleanup cannot turn success into a
1137
- // false failure, so leave a recoverable backup and warn if removal is
1138
- // blocked instead of reporting that initialization failed.
1139
- for (const backup of [
1140
- schemaBackedUp ? schemaBackup : null,
1141
- configBackedUp ? configBackup : null,
1142
- ]) {
1143
- if (!backup)
1144
- continue;
1145
- try {
1146
- fs.rmSync(backup, { recursive: true, force: true });
1080
+ try {
1081
+ if (schemaInstalled) {
1082
+ fs.rmSync(schemaDir, { recursive: true, force: true });
1147
1083
  }
1148
- catch (cleanupError) {
1149
- console.error(`Warning: initialization succeeded, but the backup at ${backup} could not be removed: ${cleanupError.message}`);
1084
+ if (schemaBackedUp) {
1085
+ schemaInitFileOperations.renameSync(schemaBackup, schemaDir);
1150
1086
  }
1151
1087
  }
1088
+ catch (rollbackError) {
1089
+ rollbackErrors.push(`schema: ${rollbackError.message}`);
1090
+ }
1091
+ if (rollbackErrors.length > 0) {
1092
+ throw new Error(`Schema initialization failed and rollback was incomplete (${rollbackErrors.join(', ')}). ` +
1093
+ `Recovery backups may remain beside ${schemaDir} and ${preparedConfig?.path ?? 'the config file'}.`, { cause: installError });
1094
+ }
1095
+ throw installError;
1152
1096
  }
1153
- catch (error) {
1097
+ // The transaction is committed. Cleanup cannot turn success into a
1098
+ // false failure, so leave a recoverable backup and warn if removal is
1099
+ // blocked instead of reporting that initialization failed.
1100
+ for (const backup of [
1101
+ schemaBackedUp ? schemaBackup : null,
1102
+ configBackedUp ? configBackup : null,
1103
+ ]) {
1104
+ if (!backup)
1105
+ continue;
1154
1106
  try {
1155
- fs.rmSync(schemaStagingDir, { recursive: true, force: true });
1107
+ fs.rmSync(backup, { recursive: true, force: true });
1156
1108
  }
1157
- catch {
1158
- // Best-effort cleanup must not hide the operation's real error.
1109
+ catch (cleanupError) {
1110
+ console.error(`Warning: initialization succeeded, but the backup at ${backup} could not be removed: ${cleanupError.message}`);
1159
1111
  }
1160
- throw error;
1161
1112
  }
1162
- finally {
1163
- if (configStagingDir) {
1164
- try {
1165
- fs.rmSync(configStagingDir, { recursive: true, force: true });
1166
- }
1167
- catch {
1168
- // Best-effort cleanup. A committed config has already moved out.
1169
- }
1170
- }
1113
+ }
1114
+ catch (error) {
1115
+ try {
1116
+ fs.rmSync(schemaStagingDir, { recursive: true, force: true });
1171
1117
  }
1172
- if (spinner)
1173
- spinner.succeed(`Created schema '${name}'`);
1174
- if (options?.json) {
1175
- console.log(JSON.stringify({
1176
- created: true,
1177
- path: schemaDir,
1178
- schema: name,
1179
- artifacts: selectedArtifactIds,
1180
- setAsDefault: options?.default || false,
1181
- }, null, 2));
1118
+ catch {
1119
+ // Best-effort cleanup must not hide the operation's real error.
1182
1120
  }
1183
- else {
1184
- console.log(`\nSchema created at: ${schemaDir}`);
1185
- console.log(`\nArtifacts: ${selectedArtifactIds.join(', ')}`);
1186
- if (options?.default) {
1187
- console.log(`\nSet as project default schema.`);
1121
+ throw error;
1122
+ }
1123
+ finally {
1124
+ if (configStagingDir) {
1125
+ try {
1126
+ fs.rmSync(configStagingDir, { recursive: true, force: true });
1127
+ }
1128
+ catch {
1129
+ // Best-effort cleanup. A committed config has already moved out.
1188
1130
  }
1189
- console.log(`\nNext steps:`);
1190
- console.log(` 1. Edit ${schemaDir}/schema.yaml to customize artifacts`);
1191
- console.log(` 2. Modify templates in the schema directory`);
1192
- console.log(` 3. Use with: openspec new --schema ${name}`);
1193
1131
  }
1194
1132
  }
1195
- catch (error) {
1196
- if (spinner)
1197
- spinner.fail(`Creation failed`);
1198
- if (options?.json) {
1199
- console.log(JSON.stringify({
1200
- created: false,
1201
- error: error.message,
1202
- }, null, 2));
1203
- }
1204
- else {
1205
- console.error(`Error: ${error.message}`);
1133
+ if (spinner)
1134
+ spinner.succeed(`Created schema '${name}'`);
1135
+ if (options?.json) {
1136
+ console.log(JSON.stringify({
1137
+ created: true,
1138
+ path: schemaDir,
1139
+ schema: name,
1140
+ artifacts: selectedArtifactIds,
1141
+ setAsDefault: options?.default || false,
1142
+ }, null, 2));
1143
+ }
1144
+ else {
1145
+ console.log(`\nSchema created at: ${schemaDir}`);
1146
+ console.log(`\nArtifacts: ${selectedArtifactIds.join(', ')}`);
1147
+ if (options?.default) {
1148
+ console.log(`\nSet as project default schema.`);
1206
1149
  }
1207
- process.exitCode = 1;
1150
+ console.log(`\nNext steps:`);
1151
+ console.log(` 1. Edit ${schemaDir}/schema.yaml to customize artifacts`);
1152
+ console.log(` 2. Modify templates in the schema directory`);
1153
+ console.log(` 3. Use with: openspec new --schema ${name}`);
1208
1154
  }
1209
- });
1155
+ }
1156
+ catch (error) {
1157
+ if (spinner)
1158
+ spinner.fail(`Creation failed`);
1159
+ if (options?.json) {
1160
+ console.log(JSON.stringify({
1161
+ created: false,
1162
+ error: error.message,
1163
+ }, null, 2));
1164
+ }
1165
+ else {
1166
+ console.error(`Error: ${error.message}`);
1167
+ }
1168
+ process.exitCode = 1;
1169
+ }
1210
1170
  }
1211
1171
  /**
1212
1172
  * Create default template content for an artifact.