@fission-ai/openspec 1.9.0 → 1.10.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 (38) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +49 -7
  3. package/dist/commands/config.js +14 -3
  4. package/dist/commands/feedback.js +28 -7
  5. package/dist/core/archive.js +64 -7
  6. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  7. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  8. package/dist/core/artifact-graph/outputs.js +7 -0
  9. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  10. package/dist/core/command-generation/adapters/opencode.js +13 -2
  11. package/dist/core/completion-tip.d.ts +15 -0
  12. package/dist/core/completion-tip.js +133 -0
  13. package/dist/core/completions/command-registry.js +5 -0
  14. package/dist/core/completions/factory.d.ts +11 -0
  15. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  16. package/dist/core/completions/installers/bash-installer.js +15 -0
  17. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  18. package/dist/core/completions/installers/fish-installer.js +15 -0
  19. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  20. package/dist/core/completions/installers/powershell-installer.js +15 -0
  21. package/dist/core/config-prompts.js +19 -10
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +2 -0
  24. package/dist/core/config.js +1 -0
  25. package/dist/core/global-config.d.ts +2 -0
  26. package/dist/core/init.d.ts +5 -0
  27. package/dist/core/init.js +91 -7
  28. package/dist/core/profiles.d.ts +1 -1
  29. package/dist/core/profiles.js +11 -2
  30. package/dist/core/templates/workflows/feedback.js +1 -0
  31. package/dist/core/templates/workflows/onboard.js +4 -4
  32. package/dist/core/update.js +15 -4
  33. package/dist/telemetry/index.js +3 -2
  34. package/dist/utils/change-metadata.js +17 -13
  35. package/dist/utils/change-utils.js +5 -0
  36. package/package.json +3 -6
  37. package/schemas/spec-driven/schema.yaml +17 -7
  38. package/scripts/postinstall.js +0 -83
@@ -55,6 +55,21 @@ export class BashInstaller {
55
55
  // For user installation, use local directory
56
56
  return path.join(localCompletionDir, 'openspec');
57
57
  }
58
+ /**
59
+ * Check if a completion script is currently installed.
60
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
61
+ *
62
+ * @returns true if the completion script exists
63
+ */
64
+ async isInstalled() {
65
+ try {
66
+ // stat, not access: a directory at the install path is not a script.
67
+ return (await fs.stat(await this.getInstallationPath())).isFile();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
58
73
  /**
59
74
  * Backup an existing completion file if it exists
60
75
  *
@@ -12,6 +12,13 @@ export declare class FishInstaller {
12
12
  * @returns Installation path
13
13
  */
14
14
  getInstallationPath(): string;
15
+ /**
16
+ * Check if a completion script is currently installed.
17
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
18
+ *
19
+ * @returns true if the completion script exists
20
+ */
21
+ isInstalled(): Promise<boolean>;
15
22
  /**
16
23
  * Backup an existing completion file if it exists
17
24
  *
@@ -19,6 +19,21 @@ export class FishInstaller {
19
19
  getInstallationPath() {
20
20
  return path.join(this.homeDir, '.config', 'fish', 'completions', 'openspec.fish');
21
21
  }
22
+ /**
23
+ * Check if a completion script is currently installed.
24
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
25
+ *
26
+ * @returns true if the completion script exists
27
+ */
28
+ async isInstalled() {
29
+ try {
30
+ // stat, not access: a directory at the install path is not a script.
31
+ return (await fs.stat(this.getInstallationPath())).isFile();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
22
37
  /**
23
38
  * Backup an existing completion file if it exists
24
39
  *
@@ -43,6 +43,13 @@ export declare class PowerShellInstaller {
43
43
  * @returns Installation path
44
44
  */
45
45
  getInstallationPath(): string;
46
+ /**
47
+ * Check if a completion script is currently installed.
48
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
49
+ *
50
+ * @returns true if the completion script exists
51
+ */
52
+ isInstalled(): Promise<boolean>;
46
53
  /**
47
54
  * Backup an existing completion file if it exists
48
55
  *
@@ -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
  *
@@ -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
  /**
@@ -54,6 +54,7 @@ export const AI_TOOLS = [
54
54
  { name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
55
55
  { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
56
56
  { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
57
+ { name: 'Zed Agent', value: 'zed', available: true, successLabel: 'Zed Agent', skillsDir: '.agents', detectionPaths: ['.zed', '.agents/skills'] },
57
58
  { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
58
59
  // Vendor-neutral target for assistants that read the shared `.agents` root.
59
60
  // 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';
@@ -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
@@ -517,11 +527,31 @@ export class InitCommand {
517
527
  }
518
528
  validateTools(toolIds, toolStates, projectPath) {
519
529
  const validatedTools = [];
520
- const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents')
521
- ? toolIds.filter((toolId) => toolId !== 'agents')
530
+ const sharedAgentsTargets = ['codex', 'zed', 'agents'];
531
+ const selectedSharedTargets = sharedAgentsTargets.filter((toolId) => toolIds.includes(toolId));
532
+ // A Codex-rendered tree already serves Zed. Keep it when Zed is added later
533
+ // so Codex users do not lose the `$openspec-*` references they require.
534
+ const preserveConfiguredCodex = selectedSharedTargets.includes('zed') &&
535
+ toolStates.get('codex')?.configured;
536
+ const sharedTargetCandidates = preserveConfiguredCodex
537
+ ? [...new Set([...selectedSharedTargets, 'codex'])]
538
+ : selectedSharedTargets;
539
+ const sharedTargetOwner = sharedTargetCandidates.includes('codex')
540
+ ? 'codex'
541
+ : selectedSharedTargets.includes('zed')
542
+ ? 'zed'
543
+ : selectedSharedTargets[0];
544
+ const firstSharedIndex = toolIds.findIndex((id) => sharedAgentsTargets.includes(id));
545
+ const reconciledToolIds = sharedTargetCandidates.length > 1
546
+ ? toolIds.flatMap((toolId, index) => {
547
+ if (!sharedAgentsTargets.includes(toolId))
548
+ return [toolId];
549
+ return index === firstSharedIndex && sharedTargetOwner ? [sharedTargetOwner] : [];
550
+ })
522
551
  : 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.'));
552
+ if (reconciledToolIds.length !== toolIds.length ||
553
+ reconciledToolIds.some((toolId, index) => toolId !== toolIds[index])) {
554
+ console.log(chalk.dim(`Codex, Zed, and agents share .agents/skills; writing one tree for ${sharedTargetOwner}.`));
525
555
  }
526
556
  for (const toolId of reconciledToolIds) {
527
557
  const tool = AI_TOOLS.find((t) => t.value === toolId);
@@ -699,6 +729,53 @@ export class InitCommand {
699
729
  // ═══════════════════════════════════════════════════════════
700
730
  // CONFIG FILE
701
731
  // ═══════════════════════════════════════════════════════════
732
+ normalizeLanguage(language) {
733
+ if (language === undefined)
734
+ return undefined;
735
+ const normalized = language.trim();
736
+ if (!normalized) {
737
+ throw new Error('The --language option requires a non-empty value.');
738
+ }
739
+ if (/\p{Cc}|\p{Bidi_Control}|[\u200B\u2028\u2029\uFEFF]/u.test(normalized)) {
740
+ throw new Error('The --language option must be a single line without control or invisible formatting characters.');
741
+ }
742
+ const serializedContext = `${formatLanguageContext(normalized)}\n`;
743
+ if (Buffer.byteLength(serializedContext, 'utf8') > MAX_CONTEXT_SIZE) {
744
+ throw new Error(`The --language option is too long for OpenSpec's ${MAX_CONTEXT_SIZE / 1024}KB project context limit.`);
745
+ }
746
+ return normalized;
747
+ }
748
+ languageContext() {
749
+ if (!this.language)
750
+ return undefined;
751
+ return formatLanguageContext(this.language);
752
+ }
753
+ async assertLanguageCanBeApplied(projectPath, openspecPath) {
754
+ const languageContext = this.languageContext();
755
+ if (!languageContext)
756
+ return;
757
+ const configPath = path.join(openspecPath, 'config.yaml');
758
+ const hasConfig = fs.existsSync(configPath) ||
759
+ fs.existsSync(path.join(openspecPath, 'config.yml'));
760
+ if (!hasConfig) {
761
+ try {
762
+ FileSystemUtils.assertProjectArtifactPath(projectPath, configPath);
763
+ }
764
+ catch (error) {
765
+ const reason = error instanceof Error ? `: ${error.message}` : '';
766
+ throw new Error(`Cannot create openspec/config.yaml for --language${reason}`);
767
+ }
768
+ if (!(await FileSystemUtils.canWriteFile(configPath))) {
769
+ throw new Error('Cannot create openspec/config.yaml for --language: the destination is not writable.');
770
+ }
771
+ return;
772
+ }
773
+ const existingContext = readProjectConfig(projectPath)?.context;
774
+ if (existingContext?.includes(languageContext))
775
+ return;
776
+ throw new Error('--language does not overwrite an existing OpenSpec config. ' +
777
+ 'Add the language instruction to its context field instead.');
778
+ }
702
779
  async createConfig(openspecPath, extendMode) {
703
780
  const configPath = path.join(openspecPath, 'config.yaml');
704
781
  const configYmlPath = path.join(openspecPath, 'config.yml');
@@ -708,12 +785,19 @@ export class InitCommand {
708
785
  return 'exists';
709
786
  }
710
787
  try {
711
- const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
788
+ const yamlContent = serializeConfig({
789
+ schema: DEFAULT_SCHEMA,
790
+ context: this.languageContext(),
791
+ });
712
792
  FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
713
793
  await FileSystemUtils.writeFile(configPath, yamlContent);
714
794
  return 'created';
715
795
  }
716
- catch {
796
+ catch (error) {
797
+ if (this.language) {
798
+ const reason = error instanceof Error ? `: ${error.message}` : '';
799
+ throw new Error(`Failed to create openspec/config.yaml for --language${reason}`);
800
+ }
717
801
  return 'skipped';
718
802
  }
719
803
  }
@@ -20,7 +20,7 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
20
20
  * Resolves which workflows should be active for a given profile configuration.
21
21
  *
22
22
  * - 'core' profile always returns CORE_WORKFLOWS
23
- * - 'custom' profile returns the provided customWorkflows, or empty array if not provided
23
+ * - 'custom' profile returns the provided customWorkflows and required dependencies
24
24
  */
25
25
  export declare function getProfileWorkflows(profile: Profile, customWorkflows?: string[]): readonly string[];
26
26
  //# sourceMappingURL=profiles.d.ts.map
@@ -30,11 +30,20 @@ export const ALL_WORKFLOWS = [
30
30
  * Resolves which workflows should be active for a given profile configuration.
31
31
  *
32
32
  * - 'core' profile always returns CORE_WORKFLOWS
33
- * - 'custom' profile returns the provided customWorkflows, or empty array if not provided
33
+ * - 'custom' profile returns the provided customWorkflows and required dependencies
34
34
  */
35
35
  export function getProfileWorkflows(profile, customWorkflows) {
36
36
  if (profile === 'custom') {
37
- return customWorkflows ?? [];
37
+ const workflows = customWorkflows ?? [];
38
+ const syncDependentIndex = workflows.findIndex((workflow) => workflow === 'archive' || workflow === 'bulk-archive');
39
+ if (syncDependentIndex !== -1 && !workflows.includes('sync')) {
40
+ return [
41
+ ...workflows.slice(0, syncDependentIndex),
42
+ 'sync',
43
+ ...workflows.slice(syncDependentIndex),
44
+ ];
45
+ }
46
+ return workflows;
38
47
  }
39
48
  return CORE_WORKFLOWS;
40
49
  }
@@ -39,6 +39,7 @@ export function getFeedbackSkillTemplate() {
39
39
  5. **Submit on confirmation**
40
40
  - Use the \`openspec feedback\` command to submit
41
41
  - Format: \`openspec feedback "title" --body "body content"\`
42
+ - The command preserves the title text in the issue body and shortens long GitHub issue titles
42
43
  - The command will automatically add metadata (version, platform, timestamp)
43
44
 
44
45
  **Example Draft**
@@ -374,12 +374,12 @@ Here are the implementation tasks:
374
374
 
375
375
  ## 1. [Category or file]
376
376
 
377
- - [ ] 1.1 [Specific task]
378
- - [ ] 1.2 [Specific task]
377
+ - [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
378
+ - [ ] 1.2 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
379
379
 
380
- ## 2. Verify
380
+ ## 2. Integration Verification
381
381
 
382
- - [ ] 2.1 [Verification step]
382
+ - [ ] 2.1 Verify [broader integration or system behavior] with [end-to-end test or observable result]
383
383
 
384
384
  ---
385
385
 
@@ -135,7 +135,7 @@ export class UpdateCommand {
135
135
  // Still check for new tool directories and extra workflows
136
136
  this.detectNewTools(resolvedProjectPath, configuredTools);
137
137
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
138
- this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
138
+ this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
139
139
  this.displaySetupNotes(configuredTools);
140
140
  return;
141
141
  }
@@ -155,6 +155,7 @@ export class UpdateCommand {
155
155
  // 10. Update tools (all if force, otherwise only those needing update)
156
156
  const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
157
157
  const updatedTools = [];
158
+ const updatedToolIds = [];
158
159
  const failedTools = [];
159
160
  const skillsInvocableCommandSkips = [];
160
161
  const zeroArtifactTools = [];
@@ -223,6 +224,7 @@ export class UpdateCommand {
223
224
  }
224
225
  spinner.succeed(`Updated ${tool.name}`);
225
226
  updatedTools.push(tool.name);
227
+ updatedToolIds.push(tool.value);
226
228
  for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
227
229
  if (hasMovableContent(migration)) {
228
230
  console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
@@ -310,7 +312,7 @@ export class UpdateCommand {
310
312
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
311
313
  // 14. Display note about extra workflows not in profile
312
314
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
313
- this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
315
+ this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
314
316
  this.displaySetupNotes(configuredAndNewTools);
315
317
  // 15. List affected tools
316
318
  if (updatedTools.length > 0) {
@@ -318,7 +320,16 @@ export class UpdateCommand {
318
320
  console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
319
321
  }
320
322
  console.log();
321
- console.log(chalk.dim('Restart your IDE for changes to take effect.'));
323
+ const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
324
+ const shouldRestartIde = affectedToolIds.some((toolId) => {
325
+ const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
326
+ return Boolean(tool?.requiresIdeRestart &&
327
+ (shouldGenerateCommandsForTool(toolId, delivery) ||
328
+ shouldGenerateSkillsForTool(toolId, delivery)));
329
+ });
330
+ if (shouldRestartIde) {
331
+ console.log(chalk.dim('Restart your IDE for changes to take effect.'));
332
+ }
322
333
  if (failedTools.length > 0) {
323
334
  throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
324
335
  }
@@ -787,7 +798,7 @@ export class UpdateCommand {
787
798
  return { newlyConfiguredTools: [], workflowOverrides: {} };
788
799
  }
789
800
  }
790
- const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
801
+ const inferredCodexWorkflows = getProfileWorkflows('custom', getLegacyWorkflowIdsForTool(detection, 'codex')).filter((workflow) => ALL_WORKFLOWS.includes(workflow));
791
802
  // Create skills/commands for selected tools using effective profile+delivery.
792
803
  const newlyConfigured = [];
793
804
  const skippedSharedSkillTools = [];
@@ -174,8 +174,9 @@ export async function maybeShowTelemetryNotice(options = {}) {
174
174
  if (options.silent) {
175
175
  return;
176
176
  }
177
- // Display notice
178
- console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
177
+ // Display notice on stderr, not stdout: stdout is reserved for command
178
+ // output (raw passthrough text, JSON, etc.) and must stay parser/pipe-safe.
179
+ console.error('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
179
180
  // Mark as seen
180
181
  await updateTelemetryConfig({ noticeSeen: true });
181
182
  }
@@ -179,6 +179,18 @@ export function readRetireCapabilitiesMarker(changeDir) {
179
179
  * name. One body rather than two, so a marker can never drift into honoring
180
180
  * metadata the other rejects - the whole point of the contract described above.
181
181
  */
182
+ /**
183
+ * A marker that cannot be honored, with its reason made safe to print.
184
+ *
185
+ * Every reason quotes something the author wrote - a schema name, a parser
186
+ * message carrying one, a filesystem error carrying a path - and callers print
187
+ * it straight to a terminal (`openspec archive`, `openspec validate`). A raw CR
188
+ * could forge a line of its own and an ESC could redraw the screen, so control
189
+ * characters never leave this function.
190
+ */
191
+ function unhonorable(reason) {
192
+ return { declared: false, invalidReason: reason.replace(/[\u0000-\u001f\u007f]/g, '?') };
193
+ }
182
194
  function readBooleanMarker(changeDir, key) {
183
195
  let raw;
184
196
  try {
@@ -193,10 +205,7 @@ function readBooleanMarker(changeDir, key) {
193
205
  // set cannot be determined - fail closed rather than let archive treat
194
206
  // the change as unmarked while every metadata-reading surface errors.
195
207
  const message = err instanceof Error ? err.message : String(err);
196
- return {
197
- declared: false,
198
- invalidReason: `the metadata file cannot be read (${message})`,
199
- };
208
+ return unhonorable(`the metadata file cannot be read (${message})`);
200
209
  }
201
210
  let parsed;
202
211
  try {
@@ -206,9 +215,7 @@ function readBooleanMarker(changeDir, key) {
206
215
  // Anchored so a comment like "# maybe add skip_specs later" does not
207
216
  // claim the marker was set.
208
217
  const mentioned = new RegExp(`^\\s*(['"]?)${key}\\1\\s*:`, 'm').test(raw);
209
- return mentioned
210
- ? { declared: false, invalidReason: 'the file is not valid YAML' }
211
- : { declared: false };
218
+ return mentioned ? unhonorable('the file is not valid YAML') : { declared: false };
212
219
  }
213
220
  const result = ChangeMetadataSchema.safeParse(parsed);
214
221
  if (result.success) {
@@ -225,16 +232,13 @@ function readBooleanMarker(changeDir, key) {
225
232
  try {
226
233
  const projectRoot = path.resolve(changeDir, '../../..');
227
234
  if (!listSchemas(projectRoot).includes(result.data.schema)) {
228
- return {
229
- declared: false,
230
- invalidReason: `schema: unknown schema '${result.data.schema}'`,
231
- };
235
+ return unhonorable(`schema: unknown schema '${result.data.schema}'`);
232
236
  }
233
237
  resolveSchema(result.data.schema, projectRoot);
234
238
  }
235
239
  catch (err) {
236
240
  const message = err instanceof Error ? err.message : String(err);
237
- return { declared: false, invalidReason: message };
241
+ return unhonorable(message);
238
242
  }
239
243
  return { declared: true };
240
244
  }
@@ -250,7 +254,7 @@ function readBooleanMarker(changeDir, key) {
250
254
  if (markerMentioned) {
251
255
  const first = result.error.issues[0];
252
256
  const where = first.path.length > 0 ? `${first.path.join('.')}: ` : '';
253
- return { declared: false, invalidReason: `${where}${first.message}` };
257
+ return unhonorable(`${where}${first.message}`);
254
258
  }
255
259
  return { declared: false };
256
260
  }
@@ -4,6 +4,8 @@ import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
4
4
  import { formatLocalDate } from './date.js';
5
5
  import { readProjectConfig } from '../core/project-config.js';
6
6
  import { isKebabId } from '../core/id.js';
7
+ import { resolveSchema } from '../core/artifact-graph/resolver.js';
8
+ import { isSpecsArtifactPath } from '../core/artifact-graph/outputs.js';
7
9
  const DEFAULT_SCHEMA = 'spec-driven';
8
10
  /**
9
11
  * Validates that a change name follows kebab-case conventions.
@@ -117,6 +119,8 @@ export async function createChange(projectRoot, name, options = {}) {
117
119
  if (await FileSystemUtils.directoryExists(changeDir)) {
118
120
  throw new Error(`Change '${name}' already exists at ${changeDir}`);
119
121
  }
122
+ const schema = resolveSchema(schemaName, projectRoot);
123
+ const skipsSpecs = !schema.artifacts.some(artifact => isSpecsArtifactPath(artifact.generates));
120
124
  // Creating a change may scaffold or complete the root itself (an
121
125
  // implicit root, or a config-only/incomplete clone). Never leave a
122
126
  // half-root behind that doctor immediately calls unhealthy: ensure
@@ -138,6 +142,7 @@ export async function createChange(projectRoot, name, options = {}) {
138
142
  writeChangeMetadata(changeDir, {
139
143
  schema: schemaName,
140
144
  created: formatLocalDate(),
145
+ ...(skipsSpecs ? { skip_specs: true } : {}),
141
146
  ...options.metadata,
142
147
  }, projectRoot);
143
148
  return { schema: schemaName, changeDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -33,7 +33,6 @@
33
33
  "dist",
34
34
  "bin",
35
35
  "schemas",
36
- "scripts/postinstall.js",
37
36
  "!dist/**/*.test.js",
38
37
  "!dist/**/__tests__",
39
38
  "!dist/**/*.map"
@@ -53,8 +52,8 @@
53
52
  "vitest": "^3.2.6"
54
53
  },
55
54
  "dependencies": {
56
- "@inquirer/core": "^10.3.2",
57
- "@inquirer/prompts": "^7.10.1",
55
+ "@inquirer/core": "^11.2.1",
56
+ "@inquirer/prompts": "^8.5.2",
58
57
  "chalk": "^5.6.2",
59
58
  "commander": "^14.0.0",
60
59
  "cross-spawn": "7.0.6",
@@ -74,8 +73,6 @@
74
73
  "test:watch": "vitest",
75
74
  "test:ui": "vitest --ui",
76
75
  "test:coverage": "vitest --coverage",
77
- "test:postinstall": "node scripts/postinstall.js",
78
- "postinstall": "node scripts/postinstall.js",
79
76
  "check:pack-version": "node scripts/pack-version-check.mjs",
80
77
  "release": "pnpm run release:ci",
81
78
  "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",