@fission-ai/openspec 1.11.0 → 1.13.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 (37) hide show
  1. package/README.md +3 -14
  2. package/dist/cli/index.js +1 -0
  3. package/dist/commands/change.js +6 -5
  4. package/dist/commands/validate.d.ts +57 -0
  5. package/dist/commands/validate.js +99 -13
  6. package/dist/commands/workflow/instructions.js +140 -5
  7. package/dist/commands/workflow/shared.d.ts +8 -0
  8. package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
  9. package/dist/core/command-generation/adapters/codeassistant.js +29 -0
  10. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  11. package/dist/core/command-generation/adapters/index.js +1 -0
  12. package/dist/core/command-generation/registry.js +2 -0
  13. package/dist/core/completions/command-registry.js +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/init.d.ts +1 -0
  16. package/dist/core/init.js +41 -34
  17. package/dist/core/onboarding-commands.d.ts +11 -1
  18. package/dist/core/onboarding-commands.js +26 -0
  19. package/dist/core/openspec-root.d.ts +1 -0
  20. package/dist/core/openspec-root.js +10 -2
  21. package/dist/core/parsers/requirement-blocks.js +77 -31
  22. package/dist/core/shared/ide-restart.d.ts +26 -0
  23. package/dist/core/shared/ide-restart.js +42 -0
  24. package/dist/core/shared/index.d.ts +1 -0
  25. package/dist/core/shared/index.js +1 -0
  26. package/dist/core/shared/tool-detection.js +28 -1
  27. package/dist/core/specs-apply.js +184 -8
  28. package/dist/core/templates/workflows/explore.js +45 -0
  29. package/dist/core/templates/workflows/ff-change.js +8 -0
  30. package/dist/core/templates/workflows/propose.js +40 -12
  31. package/dist/core/update.d.ts +22 -0
  32. package/dist/core/update.js +55 -17
  33. package/dist/core/validation/validator.d.ts +12 -3
  34. package/dist/core/validation/validator.js +74 -3
  35. package/dist/utils/command-references.js +7 -8
  36. package/package.json +3 -3
  37. package/schemas/spec-driven/schema.yaml +14 -2
@@ -13,12 +13,12 @@ import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
14
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
15
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
16
- import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
16
+ import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, formatIdeRestart, } from './shared/index.js';
17
17
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, omitToolLegacyArtifacts, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { getGlobalConfig } from './global-config.js';
20
20
  import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
21
- import { getOnboardingCommands } from './onboarding-commands.js';
21
+ import { formatOptionalWorkflowsNote, getOnboardingCommands } from './onboarding-commands.js';
22
22
  import { getAvailableTools } from './available-tools.js';
23
23
  import { WORKFLOW_TO_SKILL_DIR, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
24
24
  import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, findLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
@@ -134,8 +134,7 @@ export class UpdateCommand {
134
134
  await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
135
135
  // Still check for new tool directories and extra workflows
136
136
  this.detectNewTools(resolvedProjectPath, configuredTools);
137
- this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
138
- this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
137
+ this.displayProfileNotes(resolvedProjectPath, configuredTools, desiredWorkflows, profile, delivery);
139
138
  this.displaySetupNotes(configuredTools);
140
139
  return;
141
140
  }
@@ -316,9 +315,8 @@ export class UpdateCommand {
316
315
  await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
317
316
  // 13. Detect new tool directories not currently configured
318
317
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
319
- // 14. Display note about extra workflows not in profile
320
- this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
321
- this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
318
+ // 14. Display the profile notes
319
+ this.displayProfileNotes(resolvedProjectPath, configuredAndNewTools, desiredWorkflows, profile, delivery);
322
320
  this.displaySetupNotes(configuredAndNewTools);
323
321
  // 15. List affected tools
324
322
  if (updatedTools.length > 0) {
@@ -327,14 +325,9 @@ export class UpdateCommand {
327
325
  }
328
326
  console.log();
329
327
  const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
330
- const shouldRestartIde = affectedToolIds.some((toolId) => {
331
- const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
332
- return Boolean(tool?.requiresIdeRestart &&
333
- (shouldGenerateCommandsForTool(toolId, delivery) ||
334
- shouldGenerateSkillsForTool(toolId, delivery)));
335
- });
336
- if (shouldRestartIde) {
337
- console.log(chalk.dim('Restart your IDE for changes to take effect.'));
328
+ const restartHint = formatIdeRestart(affectedToolIds, delivery);
329
+ if (restartHint) {
330
+ console.log(chalk.dim(restartHint));
338
331
  }
339
332
  if (failedTools.length > 0) {
340
333
  throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
@@ -438,6 +431,18 @@ export class UpdateCommand {
438
431
  console.log(chalk.yellow(`Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`));
439
432
  }
440
433
  }
434
+ /**
435
+ * Prints the profile notes, in order, with one pointer at
436
+ * `openspec config profile` rather than three.
437
+ *
438
+ * Every note is evaluated: reading them as one short-circuited `||` chain
439
+ * would swallow whichever ran second.
440
+ */
441
+ displayProfileNotes(projectPath, configuredTools, desiredWorkflows, profile, delivery) {
442
+ const printedExtraNote = this.displayExtraWorkflowsNote(projectPath, configuredTools, desiredWorkflows ?? []);
443
+ const printedMissingCoreNote = this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
444
+ this.displayOptionalWorkflowsNote(configuredTools, desiredWorkflows, delivery, printedExtraNote || printedMissingCoreNote);
445
+ }
441
446
  /**
442
447
  * Displays a note about extra workflows installed that aren't in the current profile.
443
448
  */
@@ -447,7 +452,9 @@ export class UpdateCommand {
447
452
  const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));
448
453
  if (extraWorkflows.length > 0) {
449
454
  console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
455
+ return true;
450
456
  }
457
+ return false;
451
458
  }
452
459
  /**
453
460
  * Point out core workflows a custom profile is missing, so releases that
@@ -456,17 +463,48 @@ export class UpdateCommand {
456
463
  */
457
464
  displayMissingCoreWorkflowsNote(profile, workflows) {
458
465
  if (profile !== 'custom' || !workflows) {
459
- return;
466
+ return false;
460
467
  }
461
468
  const workflowSet = new Set(workflows);
462
469
  const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow));
463
470
  if (missing.length === 0) {
464
- return;
471
+ return false;
465
472
  }
466
473
  const label = missing.length === 1 ? 'workflow' : 'workflows';
467
474
  const pronoun = missing.length === 1 ? 'it' : 'them';
468
475
  console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
469
476
  console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
477
+ return true;
478
+ }
479
+ /**
480
+ * Fallback pointer to the workflows the profile leaves out.
481
+ *
482
+ * `update` already points at `openspec config profile` when files drift from
483
+ * the profile, and when a custom profile is missing core workflows. Neither
484
+ * fires for the default `core` profile, so the user `update` is most likely
485
+ * to be helping — the one who ran it because a command they read about never
486
+ * appeared — learns nothing (#1076). This covers that gap.
487
+ *
488
+ * Silent when another note already pointed at the same command, and when no
489
+ * configured tool can receive a workflow surface under the active delivery:
490
+ * adding workflows would write nothing there.
491
+ */
492
+ displayOptionalWorkflowsNote(configuredTools, workflows, delivery, alreadyPointedAtProfileConfig) {
493
+ if (alreadyPointedAtProfileConfig || !workflows) {
494
+ return;
495
+ }
496
+ const anyToolHasASurface = configuredTools.some((toolId) => shouldGenerateSkillsForTool(toolId, delivery) ||
497
+ shouldGenerateCommandsForTool(toolId, delivery));
498
+ if (!anyToolHasASurface) {
499
+ return;
500
+ }
501
+ const note = formatOptionalWorkflowsNote(workflows);
502
+ if (!note) {
503
+ return;
504
+ }
505
+ for (const line of note) {
506
+ console.log(chalk.dim(line));
507
+ }
470
508
  }
471
509
  /**
472
510
  * Removes skill directories for workflows when delivery changed to commands-only.
@@ -19,9 +19,10 @@ export declare class Validator {
19
19
  * - No duplicates within sections; no cross-section conflicts per spec
20
20
  *
21
21
  * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
22
- * against the current main specs for the scenario loss archive refuses to
23
- * apply (#1477). When `options.projectRoot` is given, the schema's tracked
24
- * task files are checked for ambiguous numbering (#1520). Omitting either
22
+ * against the current main specs for scenario loss (#1477), and merge
23
+ * conflicts are reported as INFO without changing the verdict (#1112).
24
+ * When `options.projectRoot` is given, the schema's tracked task files are
25
+ * checked for ambiguous numbering (#1520). Omitting either
25
26
  * option keeps existing library and archive callers behaving as before.
26
27
  */
27
28
  validateChangeDeltaSpecs(changeDir: string, options?: {
@@ -47,6 +48,14 @@ export declare class Validator {
47
48
  private applyChangeRules;
48
49
  private enrichTopLevelError;
49
50
  private extractNameFromPath;
51
+ /**
52
+ * Dry-run archive's merge builder without writing its result. Reusing the
53
+ * builder preserves its already-synced delta rules instead of duplicating them.
54
+ * INFO leaves the verdict unchanged: a missing target can be a typo or a
55
+ * requirement introduced by a sibling change that has not archived yet.
56
+ * This does not run archive's later merged-spec validation or retirement checks.
57
+ */
58
+ private findArchiveBlockers;
50
59
  private createReport;
51
60
  isValid(report: ValidationReport): boolean;
52
61
  private extractRequirementText;
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { SpecSchema, ChangeSchema } from '../schemas/index.js';
4
4
  import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
+ import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
6
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
7
8
  import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
8
9
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
@@ -112,9 +113,10 @@ export class Validator {
112
113
  * - No duplicates within sections; no cross-section conflicts per spec
113
114
  *
114
115
  * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
115
- * against the current main specs for the scenario loss archive refuses to
116
- * apply (#1477). When `options.projectRoot` is given, the schema's tracked
117
- * task files are checked for ambiguous numbering (#1520). Omitting either
116
+ * against the current main specs for scenario loss (#1477), and merge
117
+ * conflicts are reported as INFO without changing the verdict (#1112).
118
+ * When `options.projectRoot` is given, the schema's tracked task files are
119
+ * checked for ambiguous numbering (#1520). Omitting either
118
120
  * option keeps existing library and archive callers behaving as before.
119
121
  */
120
122
  async validateChangeDeltaSpecs(changeDir, options = {}) {
@@ -330,6 +332,20 @@ export class Validator {
330
332
  }
331
333
  }
332
334
  }
335
+ // Reuse archive's merge builder to report conflicts with the main specs.
336
+ // Keep structural errors and scenario loss in their existing diagnostics.
337
+ if (options.mainSpecsDir) {
338
+ issues.push(...(await this.findArchiveBlockers(changeDir, options.mainSpecsDir, [
339
+ ...issues.filter((issue) => issue.level === 'ERROR').map((issue) => issue.path),
340
+ // Collected in the loop above but not turned into issues until
341
+ // after this try block, so they are invisible to the filter. A
342
+ // delta with no parsed sections has nothing for the merge to
343
+ // apply, which it reports as a failure of its own - on top of the
344
+ // error that actually names the mistake.
345
+ ...missingHeaderSpecs,
346
+ ...emptySectionSpecs.map((spec) => spec.path),
347
+ ])));
348
+ }
333
349
  }
334
350
  catch (error) {
335
351
  // A missing specs dir (or a stray `specs` file) means no deltas;
@@ -671,6 +687,61 @@ export class Validator {
671
687
  const dotIndex = fileName.lastIndexOf('.');
672
688
  return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
673
689
  }
690
+ /**
691
+ * Dry-run archive's merge builder without writing its result. Reusing the
692
+ * builder preserves its already-synced delta rules instead of duplicating them.
693
+ * INFO leaves the verdict unchanged: a missing target can be a typo or a
694
+ * requirement introduced by a sibling change that has not archived yet.
695
+ * This does not run archive's later merged-spec validation or retirement checks.
696
+ */
697
+ async findArchiveBlockers(changeDir, mainSpecsDir, alreadyReportedPaths) {
698
+ const alreadyReported = new Set(alreadyReportedPaths);
699
+ // Only ever reaches a generated skeleton's placeholder Purpose, which this
700
+ // dry run discards.
701
+ const changeName = path.basename(changeDir);
702
+ const issues = [];
703
+ let updates;
704
+ try {
705
+ updates = await findSpecUpdates(changeDir, mainSpecsDir);
706
+ }
707
+ catch (error) {
708
+ // An incomplete advisory check must not discard the validation report.
709
+ // Source discovery already ran above; archive retains its own path guards.
710
+ return [{
711
+ level: 'INFO',
712
+ path: 'specs',
713
+ message: `Could not check archive merge conflicts: ${error instanceof Error ? error.message : String(error)}`,
714
+ }];
715
+ }
716
+ for (const update of updates) {
717
+ // discoverSpecFiles builds both this id and the entryPath the checks
718
+ // above report under, from the same walk.
719
+ const entryPath = FileSystemUtils.toPosixPath(`${update.id}/spec.md`);
720
+ // A delta those checks already rejected would be reported twice, the
721
+ // second time in archive's wording rather than the wording that names
722
+ // the actual mistake.
723
+ if (alreadyReported.has(entryPath))
724
+ continue;
725
+ try {
726
+ await buildUpdatedSpec(update, changeName, { silent: true });
727
+ }
728
+ catch (error) {
729
+ // Only the thrown preconditions, which carry no errno. A filesystem
730
+ // error says nothing about whether the delta applies, and `validate
731
+ // --all` reads six changes at once, so a transient EMFILE would report
732
+ // a collision that is not there - the same reason the scenario-loss
733
+ // check above reads only the codes that mean the file is unusable.
734
+ if (error?.code !== undefined)
735
+ continue;
736
+ issues.push({
737
+ level: 'INFO',
738
+ path: entryPath,
739
+ message: `Archive would refuse this delta: ${error instanceof Error ? error.message : String(error)}`,
740
+ });
741
+ }
742
+ }
743
+ return issues;
744
+ }
674
745
  createReport(issues) {
675
746
  const errors = issues.filter(i => i.level === 'ERROR').length;
676
747
  const warnings = issues.filter(i => i.level === 'WARNING').length;
@@ -60,15 +60,14 @@ const SKILL_INVOCATION_PREFIX = {
60
60
  codex: '$',
61
61
  };
62
62
  /**
63
- * Tools that have no slash-command surface at all: skills are matched
64
- * automatically or invoked by natural-language prompts, never by typing a
65
- * `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
66
- * skills, and any `/openspec-*` form would be a dead command (see
67
- * docs/supported-tools.md). References for these tools are spelled as prose
68
- * ("the openspec-propose skill") so generated content never tells the user to
69
- * type a command their CLI does not register.
63
+ * Tools with no documented slash invocation for skills: use automatic
64
+ * matching or natural-language prompts instead. SourceCraft Code Assistant
65
+ * supports separate command files, but its skills use description matching.
66
+ * Rovo Dev's `/skills` only manages skills (see docs/supported-tools.md).
67
+ * Skill references for these tools are spelled as prose ("the openspec-propose
68
+ * skill") so skills-only delivery does not advertise unregistered commands.
70
69
  */
71
- const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev']);
70
+ const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev', 'codeassistant']);
72
71
  /**
73
72
  * Whether a tool references skills by natural language rather than a slash
74
73
  * command (see NATURAL_LANGUAGE_SKILL_TOOLS).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -41,8 +41,8 @@
41
41
  "node": ">=20.19.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@changesets/changelog-github": "^0.7.0",
45
- "@changesets/cli": "^2.31.1",
44
+ "@changesets/changelog-github": "^1.0.0",
45
+ "@changesets/cli": "^3.0.1",
46
46
  "@types/node": "^20.19.43",
47
47
  "@vitest/ui": "^3.2.6",
48
48
  "eslint": "^10.5.0",
@@ -18,7 +18,19 @@ artifacts:
18
18
  - **Impact**: Affected code, APIs, dependencies, or systems.
19
19
 
20
20
  IMPORTANT: The Capabilities section is critical. It creates the contract between
21
- proposal and specs phases. Research existing specs before filling this in.
21
+ proposal and specs phases. Research existing specs before filling this in:
22
+ run `openspec list --specs` for the project's capability inventory, then
23
+ `openspec show "<spec-id>" --type spec --json --no-scenarios` for any that
24
+ look related - that returns a capability's purpose and requirement texts
25
+ without pulling whole spec files into context. Append `--store "<id>"` to
26
+ both commands only for a registered standalone store, and keep `--type
27
+ spec`: a change and a spec sharing a name is otherwise an ambiguous-item
28
+ error. `openspec list` without `--specs` lists in-flight changes, not
29
+ specs - it never shows what the project already covers. Reuse an existing
30
+ capability's exact path instead of introducing a near-duplicate name.
31
+ The filtered read is only an overview. Before deciding what is already
32
+ covered or what should change, read each relevant spec in full, including
33
+ scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
22
34
  Each capability listed here will need a corresponding spec file.
23
35
 
24
36
  Every change must either declare at least one capability (new or
@@ -63,7 +75,7 @@ artifacts:
63
75
  `<capability-path>` is the spec directory relative to `specs/` (for example,
64
76
  `user-auth` or `identity/user-auth`). Preserve the full path:
65
77
  - New capabilities: use the exact path from the proposal at `specs/<capability-path>/spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout.
66
- - Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Do not move or rename the capability.
78
+ - Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store "<id>"` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability.
67
79
 
68
80
  There must be at least one spec file unless the change's `.openspec.yaml`
69
81
  sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`