@fission-ai/openspec 1.9.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 (72) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +60 -9
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +223 -0
  5. package/dist/commands/config.js +14 -3
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.d.ts +5 -0
  8. package/dist/commands/schema.js +201 -35
  9. package/dist/commands/show.js +1 -1
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/archive.js +64 -7
  15. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  16. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  17. package/dist/core/artifact-graph/outputs.js +7 -0
  18. package/dist/core/artifact-graph/resolver.js +15 -9
  19. package/dist/core/available-tools.js +16 -1
  20. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  21. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/completion-tip.d.ts +15 -0
  25. package/dist/core/completion-tip.js +133 -0
  26. package/dist/core/completions/command-registry.js +20 -0
  27. package/dist/core/completions/factory.d.ts +11 -0
  28. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  29. package/dist/core/completions/generators/fish-generator.js +79 -47
  30. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  31. package/dist/core/completions/installers/bash-installer.js +15 -0
  32. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  33. package/dist/core/completions/installers/fish-installer.js +15 -0
  34. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  35. package/dist/core/completions/installers/powershell-installer.js +15 -0
  36. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  37. package/dist/core/completions/templates/fish-templates.js +107 -10
  38. package/dist/core/completions/types.d.ts +4 -0
  39. package/dist/core/config-prompts.js +19 -10
  40. package/dist/core/config-schema.d.ts +1 -0
  41. package/dist/core/config-schema.js +2 -0
  42. package/dist/core/config.js +7 -1
  43. package/dist/core/global-config.d.ts +2 -0
  44. package/dist/core/init.d.ts +5 -0
  45. package/dist/core/init.js +117 -14
  46. package/dist/core/legacy-cleanup.js +6 -0
  47. package/dist/core/migration.js +42 -12
  48. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  49. package/dist/core/parsers/requirement-blocks.js +2 -0
  50. package/dist/core/profiles.d.ts +1 -1
  51. package/dist/core/profiles.js +11 -2
  52. package/dist/core/shared/tool-detection.js +9 -3
  53. package/dist/core/shared-skill-target.d.ts +13 -0
  54. package/dist/core/shared-skill-target.js +43 -1
  55. package/dist/core/specs-apply.js +16 -8
  56. package/dist/core/templates/workflows/explore.js +79 -73
  57. package/dist/core/templates/workflows/feedback.js +1 -0
  58. package/dist/core/templates/workflows/onboard.js +4 -4
  59. package/dist/core/update.js +39 -26
  60. package/dist/core/validation/constants.d.ts +3 -0
  61. package/dist/core/validation/constants.js +12 -0
  62. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  63. package/dist/core/validation/purpose-placeholder.js +117 -0
  64. package/dist/core/validation/validator.js +16 -1
  65. package/dist/telemetry/index.js +3 -2
  66. package/dist/utils/change-metadata.js +17 -13
  67. package/dist/utils/change-utils.js +5 -0
  68. package/dist/utils/requirement-diff.d.ts +42 -0
  69. package/dist/utils/requirement-diff.js +61 -0
  70. package/package.json +4 -6
  71. package/schemas/spec-driven/schema.yaml +17 -7
  72. package/scripts/postinstall.js +0 -83
@@ -110,6 +110,21 @@ export class PowerShellInstaller {
110
110
  const profileDir = path.dirname(profilePath);
111
111
  return path.join(profileDir, 'OpenSpecCompletion.ps1');
112
112
  }
113
+ /**
114
+ * Check if a completion script is currently installed.
115
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
116
+ *
117
+ * @returns true if the completion script exists
118
+ */
119
+ async isInstalled() {
120
+ try {
121
+ // stat, not access: a directory at the install path is not a script.
122
+ return (await fs.stat(this.getInstallationPath())).isFile();
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
113
128
  /**
114
129
  * Backup an existing completion file if it exists
115
130
  *
@@ -2,6 +2,6 @@
2
2
  * Static template strings for Fish completion scripts.
3
3
  * These are Fish-specific helper functions that never change.
4
4
  */
5
- export declare const FISH_STATIC_HELPERS = "# Helper function to check if a subcommand is present\nfunction __fish_openspec_using_subcommand\n set -l cmd (commandline -opc)\n set -e cmd[1]\n for i in $argv\n if contains -- $i $cmd\n return 0\n end\n end\n return 1\nend\n\nfunction __fish_openspec_no_subcommand\n set -l cmd (commandline -opc)\n test (count $cmd) -eq 1\nend";
5
+ export declare const FISH_STATIC_HELPERS = "# Helper function to match the command path at the start of the invocation\nfunction __fish_openspec_using_command_path\n set -l expected\n set -l value_flags\n set -l reading_value_flags 0\n for argument in $argv\n if test \"$argument\" = --\n set reading_value_flags 1\n continue\n end\n if test $reading_value_flags -eq 1\n set -a value_flags $argument\n else\n set -a expected $argument\n end\n end\n\n set -l tokens (commandline -opc)\n set -e tokens[1]\n set -l path\n set -l skip 0\n for token in $tokens\n if test $skip -eq 1\n set skip 0\n continue\n end\n if test \"$token\" = --no-color\n continue\n end\n if contains -- $token $value_flags\n set skip 1\n continue\n end\n if string match -q -- '-*' $token\n continue\n end\n set -a path $token\n if test (count $path) -eq (count $expected)\n break\n end\n end\n test (count $path) -eq (count $expected); or return 1\n for index in (seq (count $expected))\n test \"$path[$index]\" = \"$expected[$index]\"; or return 1\n end\n return 0\nend\n\nfunction __fish_openspec_no_subcommand\n set -l tokens (commandline -opc)\n set -e tokens[1]\n for token in $tokens\n if test \"$token\" != --no-color\n return 1\n end\n end\n return 0\nend\n\nfunction __fish_openspec_completing_option_value\n set -l current (commandline -ct)\n for option in $argv\n if string match -q -- \"$option=*\" \"$current\"\n return 0\n end\n end\n\n set -l tokens (commandline -opc)\n test (count $tokens) -gt 0; or return 1\n contains -- $tokens[-1] $argv\nend\n\nfunction __fish_openspec_complete_attached_short_path\n set -l option $argv[1]\n set -l current (commandline -ct)\n test \"$current\" != \"$option\"; or return 1\n string match -q -- \"$option*\" \"$current\"; or return 1\n set -l value (string sub -s (math (string length -- \"$option\") + 1) -- \"$current\")\n __fish_complete_path \"$value\"\nend\n\nfunction __fish_openspec_positional_index\n set -l target $argv[1]\n set -l depth $argv[2]\n set -l value_flags $argv[3..]\n set -l tokens (commandline -opc)\n set -e tokens[1]\n set -l count 0\n set -l options 1\n set -l skip 0\n for token in $tokens\n if test $skip -eq 1\n set skip 0\n continue\n end\n if test $options -eq 1\n if test \"$token\" = --\n set options 0\n continue\n end\n if contains -- $token $value_flags\n set skip 1\n continue\n end\n if string match -q -- '-*' $token\n continue\n end\n end\n set count (math $count + 1)\n end\n test $skip -eq 0; or return 1\n test $count -eq (math $target + $depth)\nend";
6
6
  export declare const FISH_DYNAMIC_HELPERS = "# Dynamic completion helpers\n\nfunction __fish_openspec_changes\n openspec __complete changes 2>/dev/null | while read -l id desc\n printf '%s\\t%s\\n' \"$id\" \"$desc\"\n end\nend\n\nfunction __fish_openspec_specs\n openspec __complete specs 2>/dev/null | while read -l id desc\n printf '%s\\t%s\\n' \"$id\" \"$desc\"\n end\nend\n\nfunction __fish_openspec_items\n __fish_openspec_changes\n __fish_openspec_specs\nend\n\nfunction __fish_openspec_schemas\n openspec __complete schemas 2>/dev/null | while read -l id desc\n printf '%s\\t%s\\n' \"$id\" \"$desc\"\n end\nend";
7
7
  //# sourceMappingURL=fish-templates.d.ts.map
@@ -2,21 +2,118 @@
2
2
  * Static template strings for Fish completion scripts.
3
3
  * These are Fish-specific helper functions that never change.
4
4
  */
5
- export const FISH_STATIC_HELPERS = `# Helper function to check if a subcommand is present
6
- function __fish_openspec_using_subcommand
7
- set -l cmd (commandline -opc)
8
- set -e cmd[1]
9
- for i in $argv
10
- if contains -- $i $cmd
11
- return 0
5
+ export const FISH_STATIC_HELPERS = `# Helper function to match the command path at the start of the invocation
6
+ function __fish_openspec_using_command_path
7
+ set -l expected
8
+ set -l value_flags
9
+ set -l reading_value_flags 0
10
+ for argument in $argv
11
+ if test "$argument" = --
12
+ set reading_value_flags 1
13
+ continue
14
+ end
15
+ if test $reading_value_flags -eq 1
16
+ set -a value_flags $argument
17
+ else
18
+ set -a expected $argument
12
19
  end
13
20
  end
14
- return 1
21
+
22
+ set -l tokens (commandline -opc)
23
+ set -e tokens[1]
24
+ set -l path
25
+ set -l skip 0
26
+ for token in $tokens
27
+ if test $skip -eq 1
28
+ set skip 0
29
+ continue
30
+ end
31
+ if test "$token" = --no-color
32
+ continue
33
+ end
34
+ if contains -- $token $value_flags
35
+ set skip 1
36
+ continue
37
+ end
38
+ if string match -q -- '-*' $token
39
+ continue
40
+ end
41
+ set -a path $token
42
+ if test (count $path) -eq (count $expected)
43
+ break
44
+ end
45
+ end
46
+ test (count $path) -eq (count $expected); or return 1
47
+ for index in (seq (count $expected))
48
+ test "$path[$index]" = "$expected[$index]"; or return 1
49
+ end
50
+ return 0
15
51
  end
16
52
 
17
53
  function __fish_openspec_no_subcommand
18
- set -l cmd (commandline -opc)
19
- test (count $cmd) -eq 1
54
+ set -l tokens (commandline -opc)
55
+ set -e tokens[1]
56
+ for token in $tokens
57
+ if test "$token" != --no-color
58
+ return 1
59
+ end
60
+ end
61
+ return 0
62
+ end
63
+
64
+ function __fish_openspec_completing_option_value
65
+ set -l current (commandline -ct)
66
+ for option in $argv
67
+ if string match -q -- "$option=*" "$current"
68
+ return 0
69
+ end
70
+ end
71
+
72
+ set -l tokens (commandline -opc)
73
+ test (count $tokens) -gt 0; or return 1
74
+ contains -- $tokens[-1] $argv
75
+ end
76
+
77
+ function __fish_openspec_complete_attached_short_path
78
+ set -l option $argv[1]
79
+ set -l current (commandline -ct)
80
+ test "$current" != "$option"; or return 1
81
+ string match -q -- "$option*" "$current"; or return 1
82
+ set -l value (string sub -s (math (string length -- "$option") + 1) -- "$current")
83
+ __fish_complete_path "$value"
84
+ end
85
+
86
+ function __fish_openspec_positional_index
87
+ set -l target $argv[1]
88
+ set -l depth $argv[2]
89
+ set -l value_flags $argv[3..]
90
+ set -l tokens (commandline -opc)
91
+ set -e tokens[1]
92
+ set -l count 0
93
+ set -l options 1
94
+ set -l skip 0
95
+ for token in $tokens
96
+ if test $skip -eq 1
97
+ set skip 0
98
+ continue
99
+ end
100
+ if test $options -eq 1
101
+ if test "$token" = --
102
+ set options 0
103
+ continue
104
+ end
105
+ if contains -- $token $value_flags
106
+ set skip 1
107
+ continue
108
+ end
109
+ if string match -q -- '-*' $token
110
+ continue
111
+ end
112
+ end
113
+ set count (math $count + 1)
114
+ end
115
+ test $skip -eq 0; or return 1
116
+ test $count -eq (math $target + $depth)
20
117
  end`;
21
118
  export const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers
22
119
 
@@ -19,6 +19,10 @@ export interface FlagDefinition {
19
19
  * Whether the flag takes an argument value
20
20
  */
21
21
  takesValue?: boolean;
22
+ /**
23
+ * Completion type for the flag value.
24
+ */
25
+ completionType?: PositionalType;
22
26
  /**
23
27
  * Possible values for the flag (for completion suggestions)
24
28
  */
@@ -9,16 +9,25 @@ export function serializeConfig(config) {
9
9
  // Schema (required)
10
10
  lines.push(`schema: ${config.schema}`);
11
11
  lines.push('');
12
- // Context section with comments
13
- lines.push('# Project context (optional)');
14
- lines.push('# This is shown to AI when creating artifacts.');
15
- lines.push('# Add your tech stack, conventions, style guides, domain knowledge, etc.');
16
- lines.push('# Example:');
17
- lines.push('# context: |');
18
- lines.push('# Tech stack: TypeScript, React, Node.js');
19
- lines.push('# We use conventional commits');
20
- lines.push('# Domain: e-commerce platform');
21
- lines.push('');
12
+ if (config.context !== undefined) {
13
+ lines.push('context: |');
14
+ for (const line of config.context.split('\n')) {
15
+ lines.push(` ${line}`);
16
+ }
17
+ lines.push('');
18
+ }
19
+ else {
20
+ // Context section with comments
21
+ lines.push('# Project context (optional)');
22
+ lines.push('# This is shown to AI when creating artifacts.');
23
+ lines.push('# Add your tech stack, conventions, style guides, domain knowledge, etc.');
24
+ lines.push('# Example:');
25
+ lines.push('# context: |');
26
+ lines.push('# Tech stack: TypeScript, React, Node.js');
27
+ lines.push('# We use conventional commits');
28
+ lines.push('# Domain: e-commerce platform');
29
+ lines.push('');
30
+ }
22
31
  // Rules section with comments
23
32
  lines.push('# Per-artifact rules (optional)');
24
33
  lines.push('# Add custom rules for specific artifacts.');
@@ -19,6 +19,7 @@ export declare const GlobalConfigSchema: z.ZodObject<{
19
19
  telemetry: z.ZodOptional<z.ZodObject<{
20
20
  enabled: z.ZodOptional<z.ZodBoolean>;
21
21
  }, z.core.$loose>>;
22
+ completionTipSeen: z.ZodOptional<z.ZodBoolean>;
22
23
  }, z.core.$loose>;
23
24
  export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
24
25
  /**
@@ -32,6 +32,8 @@ export const GlobalConfigSchema = z
32
32
  })
33
33
  .passthrough()
34
34
  .optional(),
35
+ // Runtime-managed (like telemetry.noticeSeen); not user-settable via CLI set.
36
+ completionTipSeen: z.boolean().optional(),
35
37
  })
36
38
  .passthrough();
37
39
  /**
@@ -19,7 +19,12 @@ export const OPENSPEC_MARKERS = {
19
19
  };
20
20
  export const AI_TOOLS = [
21
21
  { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
22
- { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', requiresIdeRestart: true },
22
+ // Antigravity moved workspace skills and workflows from `.agent` to the
23
+ // shared `.agents` root in v1.20.5. Detection keys off `.agent` and
24
+ // `.agents/workflows` rather than the bare `.agents` root: that root is
25
+ // shared with Codex, Zed, and the vendor-neutral target, so its presence
26
+ // alone says nothing about Antigravity.
27
+ { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agents', legacySkillsDirs: ['.agent'], detectionPaths: ['.agent', '.agents/workflows'], requiresIdeRestart: true },
23
28
  { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
24
29
  { name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
25
30
  { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
@@ -54,6 +59,7 @@ export const AI_TOOLS = [
54
59
  { name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
55
60
  { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
56
61
  { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
62
+ { name: 'Zed Agent', value: 'zed', available: true, successLabel: 'Zed Agent', skillsDir: '.agents', detectionPaths: ['.zed', '.agents/skills'] },
57
63
  { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
58
64
  // Vendor-neutral target for assistants that read the shared `.agents` root.
59
65
  // Detection keys off `.agents/skills` rather than the bare root: frameworks use
@@ -26,6 +26,8 @@ export interface GlobalConfig {
26
26
  openers?: unknown;
27
27
  /** Anonymous usage analytics settings and identity. */
28
28
  telemetry?: TelemetryConfig;
29
+ /** Whether the first-run shell-completions tip has been shown. */
30
+ completionTipSeen?: boolean;
29
31
  }
30
32
  /**
31
33
  * Gets the global configuration directory path following XDG Base Directory Specification.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  type InitCommandOptions = {
8
8
  tools?: string;
9
+ language?: string;
9
10
  force?: boolean;
10
11
  interactive?: boolean;
11
12
  profile?: string;
@@ -20,6 +21,7 @@ type InitCommandOptions = {
20
21
  };
21
22
  export declare class InitCommand {
22
23
  private readonly toolsArg?;
24
+ private readonly language?;
23
25
  private readonly force;
24
26
  private readonly interactiveOption?;
25
27
  private readonly profileOverride?;
@@ -85,6 +87,9 @@ export declare class InitCommand {
85
87
  * @returns Created, refreshed, and failed tools plus removed artifact counts
86
88
  */
87
89
  private generateSkillsAndCommands;
90
+ private normalizeLanguage;
91
+ private languageContext;
92
+ private assertLanguageCanBeApplied;
88
93
  private createConfig;
89
94
  private displaySuccessMessage;
90
95
  private startSpinner;
package/dist/core/init.js CHANGED
@@ -10,7 +10,7 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
- import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
13
+ import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerProblem, } from './project-config.js';
14
14
  import { findRepoPlanningRootSync } from './planning-home.js';
15
15
  import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
16
16
  import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
@@ -23,7 +23,7 @@ import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandCont
23
23
  import { getGlobalConfig } from './global-config.js';
24
24
  import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
25
25
  import { getAvailableTools } from './available-tools.js';
26
- import { writeSharedSkillTarget } from './shared-skill-target.js';
26
+ import { resolveSharedSkillWriters, sharedSkillRootOwner, writeSharedSkillTarget, } from './shared-skill-target.js';
27
27
  import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
28
28
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
29
29
  import { writeCopilotCloudFiles, readCopilotCloudOptIn, hasExistingManagedCloudFiles, persistCopilotCloudOptIn, removeCopilotCloudFiles, findUnmanagedCloudFiles, listManagedCloudFiles, } from './github-copilot/cloud-agent.js';
@@ -33,6 +33,13 @@ const { version: OPENSPEC_VERSION } = require('../../package.json');
33
33
  // Constants
34
34
  // -----------------------------------------------------------------------------
35
35
  const DEFAULT_SCHEMA = 'spec-driven';
36
+ function formatLanguageContext(language) {
37
+ return [
38
+ `Language: ${language}`,
39
+ `All artifacts must be written in ${language}.`,
40
+ 'Keep OpenSpec structural headings and SHALL/MUST keywords in English.',
41
+ ].join('\n');
42
+ }
36
43
  const PROGRESS_SPINNER = {
37
44
  interval: 80,
38
45
  frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
@@ -56,6 +63,7 @@ const WORKFLOW_TO_SKILL_DIR = {
56
63
  // -----------------------------------------------------------------------------
57
64
  export class InitCommand {
58
65
  toolsArg;
66
+ language;
59
67
  force;
60
68
  interactiveOption;
61
69
  profileOverride;
@@ -63,6 +71,7 @@ export class InitCommand {
63
71
  copilotCloudOption;
64
72
  constructor(options = {}) {
65
73
  this.toolsArg = options.tools;
74
+ this.language = this.normalizeLanguage(options.language);
66
75
  this.force = options.force ?? false;
67
76
  this.interactiveOption = options.interactive;
68
77
  this.profileOverride = options.profile;
@@ -97,6 +106,7 @@ export class InitCommand {
97
106
  }
98
107
  }
99
108
  }
109
+ await this.assertLanguageCanBeApplied(projectPath, openspecPath);
100
110
  // Check for legacy artifacts and handle cleanup
101
111
  const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode);
102
112
  // Migrate OpenSpec-managed skills left in renamed tool directories
@@ -516,14 +526,8 @@ export class InitCommand {
516
526
  return deduped;
517
527
  }
518
528
  validateTools(toolIds, toolStates, projectPath) {
519
- const validatedTools = [];
520
- const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents')
521
- ? toolIds.filter((toolId) => toolId !== 'agents')
522
- : toolIds;
523
- if (reconciledToolIds.length !== toolIds.length) {
524
- console.log(chalk.dim('Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.'));
525
- }
526
- for (const toolId of reconciledToolIds) {
529
+ const selectedTools = [];
530
+ for (const toolId of toolIds) {
527
531
  const tool = AI_TOOLS.find((t) => t.value === toolId);
528
532
  if (!tool) {
529
533
  const validToolIds = getToolsWithSkillsDir();
@@ -533,6 +537,48 @@ export class InitCommand {
533
537
  const validToolsWithSkills = getToolsWithSkillsDir();
534
538
  throw new Error(`Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}`);
535
539
  }
540
+ selectedTools.push(tool);
541
+ }
542
+ // A selected tool may share its physical skills root with an already
543
+ // configured owner. Include that owner in the refresh without dropping the
544
+ // selected tool: it may still have an independent command surface.
545
+ const generationTools = [...selectedTools];
546
+ const delivery = getGlobalConfig().delivery ?? 'both';
547
+ for (const selected of selectedTools) {
548
+ if (!selected.skillsDir)
549
+ continue;
550
+ const selectedOwner = selected.value === 'codex' ||
551
+ !shouldGenerateSkillsForTool(selected.value, delivery)
552
+ ? undefined
553
+ : sharedSkillRootOwner(projectPath, selected.value);
554
+ for (const candidate of AI_TOOLS) {
555
+ if (candidate.skillsDir === selected.skillsDir &&
556
+ toolStates.get(candidate.value)?.configured &&
557
+ candidate.value === selectedOwner &&
558
+ !generationTools.includes(candidate)) {
559
+ generationTools.push(candidate);
560
+ }
561
+ }
562
+ }
563
+ const skillWriters = resolveSharedSkillWriters(projectPath, generationTools);
564
+ const sharedRoots = new Map();
565
+ for (const tool of generationTools) {
566
+ if (!tool.skillsDir)
567
+ continue;
568
+ const group = sharedRoots.get(tool.skillsDir) ?? [];
569
+ group.push(tool);
570
+ sharedRoots.set(tool.skillsDir, group);
571
+ }
572
+ for (const [root, group] of sharedRoots) {
573
+ if (group.length < 2)
574
+ continue;
575
+ const owner = group.find((tool) => skillWriters.has(tool.value));
576
+ console.log(chalk.dim(`${group.map((tool) => tool.name).join(', ')} share ${root}/skills; writing one tree for ${owner?.value}.`));
577
+ }
578
+ const validatedTools = [];
579
+ for (const tool of generationTools) {
580
+ if (!toolSupportsSkills(tool))
581
+ continue;
536
582
  const preState = toolStates.get(tool.value);
537
583
  const skillsPath = resolveToolSkillsDir(projectPath, tool);
538
584
  const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
@@ -545,6 +591,7 @@ export class InitCommand {
545
591
  isGlobalSkillTarget,
546
592
  wasConfigured: preState?.configured ?? false,
547
593
  requiresIdeRestart: tool.requiresIdeRestart,
594
+ writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
548
595
  });
549
596
  }
550
597
  return validatedTools;
@@ -618,7 +665,7 @@ export class InitCommand {
618
665
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
619
666
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
620
667
  // Generate skill files if the selected delivery and tool capability allow skills
621
- if (shouldGenerateSkills) {
668
+ if (shouldGenerateSkills && tool.writesSkills) {
622
669
  // Create skill directories and SKILL.md files
623
670
  for (const { template, dirName } of skillTemplates) {
624
671
  const skillDir = path.join(tool.skillsPath, dirName);
@@ -632,7 +679,9 @@ export class InitCommand {
632
679
  }
633
680
  writeSharedSkillTarget(projectPath, tool.value);
634
681
  }
635
- if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
682
+ if (shouldRemoveSkillsForTool(tool.value, delivery) &&
683
+ tool.writesSkills &&
684
+ !tool.isGlobalSkillTarget) {
636
685
  removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
637
686
  // Retain an explicit selection even when this delivery mode produces
638
687
  // no skills, so a divergent legacy sibling cannot reclaim ownership.
@@ -699,6 +748,53 @@ export class InitCommand {
699
748
  // ═══════════════════════════════════════════════════════════
700
749
  // CONFIG FILE
701
750
  // ═══════════════════════════════════════════════════════════
751
+ normalizeLanguage(language) {
752
+ if (language === undefined)
753
+ return undefined;
754
+ const normalized = language.trim();
755
+ if (!normalized) {
756
+ throw new Error('The --language option requires a non-empty value.');
757
+ }
758
+ if (/\p{Cc}|\p{Bidi_Control}|[\u200B\u2028\u2029\uFEFF]/u.test(normalized)) {
759
+ throw new Error('The --language option must be a single line without control or invisible formatting characters.');
760
+ }
761
+ const serializedContext = `${formatLanguageContext(normalized)}\n`;
762
+ if (Buffer.byteLength(serializedContext, 'utf8') > MAX_CONTEXT_SIZE) {
763
+ throw new Error(`The --language option is too long for OpenSpec's ${MAX_CONTEXT_SIZE / 1024}KB project context limit.`);
764
+ }
765
+ return normalized;
766
+ }
767
+ languageContext() {
768
+ if (!this.language)
769
+ return undefined;
770
+ return formatLanguageContext(this.language);
771
+ }
772
+ async assertLanguageCanBeApplied(projectPath, openspecPath) {
773
+ const languageContext = this.languageContext();
774
+ if (!languageContext)
775
+ return;
776
+ const configPath = path.join(openspecPath, 'config.yaml');
777
+ const hasConfig = fs.existsSync(configPath) ||
778
+ fs.existsSync(path.join(openspecPath, 'config.yml'));
779
+ if (!hasConfig) {
780
+ try {
781
+ FileSystemUtils.assertProjectArtifactPath(projectPath, configPath);
782
+ }
783
+ catch (error) {
784
+ const reason = error instanceof Error ? `: ${error.message}` : '';
785
+ throw new Error(`Cannot create openspec/config.yaml for --language${reason}`);
786
+ }
787
+ if (!(await FileSystemUtils.canWriteFile(configPath))) {
788
+ throw new Error('Cannot create openspec/config.yaml for --language: the destination is not writable.');
789
+ }
790
+ return;
791
+ }
792
+ const existingContext = readProjectConfig(projectPath)?.context;
793
+ if (existingContext?.includes(languageContext))
794
+ return;
795
+ throw new Error('--language does not overwrite an existing OpenSpec config. ' +
796
+ 'Add the language instruction to its context field instead.');
797
+ }
702
798
  async createConfig(openspecPath, extendMode) {
703
799
  const configPath = path.join(openspecPath, 'config.yaml');
704
800
  const configYmlPath = path.join(openspecPath, 'config.yml');
@@ -708,12 +804,19 @@ export class InitCommand {
708
804
  return 'exists';
709
805
  }
710
806
  try {
711
- const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
807
+ const yamlContent = serializeConfig({
808
+ schema: DEFAULT_SCHEMA,
809
+ context: this.languageContext(),
810
+ });
712
811
  FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
713
812
  await FileSystemUtils.writeFile(configPath, yamlContent);
714
813
  return 'created';
715
814
  }
716
- catch {
815
+ catch (error) {
816
+ if (this.language) {
817
+ const reason = error instanceof Error ? `: ${error.message}` : '';
818
+ throw new Error(`Failed to create openspec/config.yaml for --language${reason}`);
819
+ }
717
820
  return 'skipped';
718
821
  }
719
822
  }
@@ -51,6 +51,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
51
51
  'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
52
52
  'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
53
53
  'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
54
+ // Scoped to the pre-opsx filenames under Antigravity's former `.agent` root.
55
+ // The current `.agents/workflows/opsx-*.md` files postdate that rename, and
56
+ // the `.agent` copies of them are relocated by LEGACY_TOOL_ROOTS, which
57
+ // preserves a customized file instead of deleting it. These patterns are
58
+ // matched in every project, so a shared root like `.agents` is not listed:
59
+ // OpenSpec never wrote `openspec-*` files there, and a user might have.
54
60
  'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
55
61
  'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
56
62
  'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
@@ -34,6 +34,12 @@ export const LEGACY_TOOL_ROOTS = {
34
34
  // Codex now reads the canonical shared .agents root. Generate the current
35
35
  // replacement first so a divergent legacy file is preserved, not overwritten.
36
36
  codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
37
+ // Antigravity v1.20.5 moved workspace skills and workflows to `.agents` and
38
+ // reads the old `.agent` root only as a fallback, so leaving a copy there
39
+ // just gives the agent two of everything. Same after-generation timing as
40
+ // Codex: the replacement is written first, so a divergent legacy file is
41
+ // reported and kept rather than overwritten.
42
+ antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
37
43
  };
38
44
  function classifyManagedFile(source, destination) {
39
45
  if (isSamePath(source, destination))
@@ -121,7 +127,7 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
121
127
  continue;
122
128
  }
123
129
  const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation');
124
- const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
130
+ const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation');
125
131
  if (apply) {
126
132
  removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
127
133
  removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
@@ -201,7 +207,7 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
201
207
  }
202
208
  return { moved, kept };
203
209
  }
204
- function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
210
+ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false) {
205
211
  const adapter = CommandAdapterRegistry.get(tool.value);
206
212
  if (!adapter || !tool.skillsDir)
207
213
  return { moved: 0, kept: 0 };
@@ -212,10 +218,16 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
212
218
  const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot);
213
219
  if (!legacyPath)
214
220
  continue;
215
- const source = path.join(projectPath, legacyPath);
221
+ const source = FileSystemUtils.resolveProjectArtifactPath(projectPath, legacyPath);
216
222
  if (!fs.existsSync(source))
217
223
  continue;
218
- const destination = path.join(projectPath, currentPath);
224
+ const destination = FileSystemUtils.resolveProjectArtifactPath(projectPath, currentPath.split(/[\\/]/).join(path.sep));
225
+ // An after-generation move runs once the tool has written its replacement.
226
+ // No replacement means this command is not one OpenSpec installs now — a
227
+ // skills-only delivery or a deselected workflow — so relocating the legacy
228
+ // file would resurrect it under the current root.
229
+ if (requireDestination && !fs.existsSync(destination))
230
+ continue;
219
231
  if (!areProjectArtifacts(projectPath, source, destination)) {
220
232
  console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
221
233
  continue;
@@ -332,7 +344,7 @@ function removeDirIfEmpty(dirPath) {
332
344
  // Missing or non-empty directory — nothing to do
333
345
  }
334
346
  }
335
- function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills = false) {
347
+ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false) {
336
348
  const installed = new Set();
337
349
  let hasSkills = false;
338
350
  let hasCommands = false;
@@ -345,7 +357,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills
345
357
  }
346
358
  else if (isSharedSkillTargetActive(projectPath, tool.value)) {
347
359
  skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
348
- if (includeLegacySkills) {
360
+ if (includeLegacyRoots) {
349
361
  skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
350
362
  }
351
363
  }
@@ -362,14 +374,32 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills
362
374
  const adapter = CommandAdapterRegistry.get(tool.value);
363
375
  if (!adapter)
364
376
  continue;
377
+ // A root the tool has moved away from still holds the command files the
378
+ // user installed there. Reading only the current root would report a
379
+ // commands install as skills-only, and delivery inferred from that answer
380
+ // deletes those commands on the next update.
381
+ const legacyRoots = includeLegacyRoots
382
+ ? (LEGACY_TOOL_ROOTS[tool.value] ?? []).map((legacy) => legacy.root)
383
+ : [];
365
384
  for (const workflowId of ALL_WORKFLOWS) {
366
385
  const commandPath = adapter.getFilePath(workflowId);
367
- const fullPath = path.isAbsolute(commandPath)
368
- ? commandPath
369
- : path.join(projectPath, commandPath);
370
- if (fs.existsSync(fullPath)) {
371
- installed.add(workflowId);
372
- hasCommands = true;
386
+ const candidates = [commandPath];
387
+ if (tool.skillsDir) {
388
+ for (const root of legacyRoots) {
389
+ const legacyPath = legacyCommandPath(commandPath, tool.skillsDir, root);
390
+ if (legacyPath)
391
+ candidates.push(legacyPath);
392
+ }
393
+ }
394
+ for (const candidate of candidates) {
395
+ const fullPath = path.isAbsolute(candidate)
396
+ ? candidate
397
+ : path.join(projectPath, candidate);
398
+ if (fs.existsSync(fullPath)) {
399
+ installed.add(workflowId);
400
+ hasCommands = true;
401
+ break;
402
+ }
373
403
  }
374
404
  }
375
405
  }
@@ -37,6 +37,7 @@ export interface DeltaPlan {
37
37
  added: RequirementBlock[];
38
38
  modified: RequirementBlock[];
39
39
  removed: string[];
40
+ removedBlocks: RequirementBlock[];
40
41
  renamed: Array<{
41
42
  from: string;
42
43
  to: string;