diff --git a/src/core/update.ts b/src/core/update.ts index eab233c7d5..7d4acd521e 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -35,7 +35,7 @@ import { } from './legacy-cleanup.js'; import { isInteractive } from '../utils/interactive.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; -import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js'; +import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { WORKFLOW_TO_SKILL_DIR, @@ -50,7 +50,6 @@ import { const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); -const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; /** * Options for the update command. @@ -156,7 +155,7 @@ export class UpdateCommand { // Still check for new tool directories and extra workflows this.detectNewTools(resolvedProjectPath, configuredTools); this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); - this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows); + this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); return; } @@ -284,7 +283,7 @@ export class UpdateCommand { // 14. Display note about extra workflows not in profile this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows); - this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows); + this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); // 15. List affected tools if (updatedTools.length > 0) { @@ -373,25 +372,26 @@ export class UpdateCommand { } /** - * Suggest opting back into core when a custom profile still matches the old - * pre-sync core set. Keep custom profiles user-owned; do not mutate them. + * Point out core workflows a custom profile is missing, so releases that + * grow CORE_WORKFLOWS stay discoverable. Keep custom profiles user-owned; + * do not mutate them. */ - private displayOldCoreCustomProfileNote(profile: Profile, workflows?: readonly string[]): void { + private displayMissingCoreWorkflowsNote(profile: Profile, workflows?: readonly string[]): void { if (profile !== 'custom' || !workflows) { return; } const workflowSet = new Set(workflows); - const matchesOldCore = - workflowSet.size === OLD_CORE_WORKFLOWS.length && - OLD_CORE_WORKFLOWS.every((workflow) => workflowSet.has(workflow)); + const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow)); - if (!matchesOldCore) { + if (missing.length === 0) { return; } - console.log(chalk.dim('Note: The core profile now includes sync. Your custom profile is preserving the old core workflow set.')); - console.log(chalk.dim('Run `openspec config profile core` and then `openspec update` to add sync.')); + const label = missing.length === 1 ? 'workflow' : 'workflows'; + const pronoun = missing.length === 1 ? 'it' : 'them'; + console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`)); + console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`)); } /** diff --git a/test/core/update.test.ts b/test/core/update.test.ts index dfdadfb58f..bcf0bd1118 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1428,7 +1428,7 @@ More user content after markers. )).toBe(false); }); - it('should suggest core preset when custom profile preserves the old core workflow set', async () => { + it('should list missing core workflows when custom profile preserves the old core workflow set', async () => { setMockConfig({ featureFlags: {}, profile: 'custom', @@ -1447,10 +1447,10 @@ More user content after markers. call.map(arg => String(arg)).join(' ') ); expect(calls.some(call => - call.includes('The core profile now includes sync') + call.includes('Your custom profile is missing 2 core workflows: update, sync') )).toBe(true); expect(calls.some(call => - call.includes('openspec config profile core') && call.includes('openspec update') + call.includes('openspec config profile core') )).toBe(true); expect(await FileSystemUtils.fileExists( @@ -1463,6 +1463,59 @@ More user content after markers. consoleSpy.mockRestore(); }); + it('should list a single missing core workflow when custom profile lacks only update', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'sync', 'archive'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + expect(calls.some(call => + call.includes('Your custom profile is missing 1 core workflow: update') + )).toBe(true); + expect(calls.some(call => + call.includes('to add it, or') + )).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should not display a missing-core note when custom profile covers core workflows', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'verify'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + expect(calls.some(call => + call.includes('Your custom profile is missing') + )).toBe(false); + + consoleSpy.mockRestore(); + }); + it('should respect skills-only delivery setting', async () => { setMockConfig({ featureFlags: {},