fix(init): name the workflows the profile left out (#1779)

* fix(init): name the workflows the profile left out

Setup output listed the workflows it installed but never mentioned the
ones it did not, so a user on the default core profile who typed
/opsx:ff saw nothing and read it as a broken install. The docs explain
profiles; nobody reads them before typing a command that should be
there.

init now closes with the missing workflows by name and the two commands
that add them. The note is skipped when nothing was generated at all,
where the existing delivery correction is the whole story, and when the
profile already installs everything.

Also adds a troubleshooting entry for the "only some /opsx: commands
show up" symptom, which the existing list did not cover.

Relates to #1076 (the optional-workflow discoverability half; the Windows command-discovery repro in that thread is not addressed here)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: drop a test scratch directory committed by mistake

test-show-command-tmp/ is created by a test run and does not exist on
main; it was picked up by a `git add -A`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(init): keep the workflow note off runs that generate nothing

With no tools selected (or only tools that could not receive a surface),
`openspec config profile` followed by `openspec update` writes nothing,
so naming the missing workflows pointed at the wrong problem.

Reported by CodeRabbit on this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(init): drop the redundant update step from the workflow note

`openspec config profile` offers to apply to the current project before
it exits, and prints the `openspec update` guidance itself when the user
declines, so naming a second command was one step too many.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(troubleshooting): match the profile steps to what the CLI does

`openspec config profile` applies to the current project itself, so
listing `openspec update` as a second required step was wrong; it is the
fallback for declining the prompt or for other projects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(update): name the workflows the profile left out

`openspec update` is what the troubleshooting checklist tells a user to
run when a command they read about never appeared, and it is what people
run after upgrading the CLI. Neither of its existing profile notes fires
on the default `core` profile, so that user reached "All tools up to
date" and still learned nothing about the six workflows they don't have.

The note is the fallback pointer: silent when the extra-workflow or
missing-core note already named `openspec config profile`, and when no
configured tool can receive a workflow surface under the active delivery.

Reading the two existing notes as one short-circuited `||` would have
swallowed whichever ran second; they are evaluated separately.

Relates to #1076 (the optional-workflow discoverability half; the Windows command-discovery repro in that thread is not addressed here)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(update): gather the profile notes behind one call

The two call sites had grown identical six-line blocks. One
displayProfileNotes() keeps the ordering and the single-pointer rule in
one place, where the "evaluate every note, never chain them with ||"
constraint can be stated once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop the legacy troubleshooting entry

alfred-openspec on #1779: docs-lab/README.md says the old docs/ tree is legacy,
is no longer used by the site, and must stay untouched. The canonical
docs-lab/customize/profiles.md already lists the six optional workflows and the
'openspec config profile' command that adds them, and the root README already
calls out the expanded set, so this entry was a third copy in a stale tree.

The docs-lab troubleshooting page is a heading-only skeleton held back from the
site, so there is nothing to move it to; this PR is now source and tests only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-09-09 16:24:52 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 6d2dbe62d3
commit 3c6d318b83
7 changed files with 339 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---
`openspec init` and `openspec update` now name the workflows your profile left out and how to add them, so a command that was never installed no longer reads as a broken setup.
+21
View File
@@ -61,6 +61,7 @@ import {
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
import { formatOptionalWorkflowsNote } from './onboarding-commands.js';
import {
resolveSharedSkillWriters,
sharedSkillRootOwner,
@@ -1388,15 +1389,35 @@ export class InitCommand {
)
);
}
let advertisedAnInvocation = true;
if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) {
// Nothing was generated for any tool: the correction above is the
// whole story, so don't advertise an invocation that doesn't exist.
advertisedAnInvocation = false;
} else if (activeWorkflows.includes('propose')) {
printStartHints('/opsx:propose');
} else if (activeWorkflows.includes('new')) {
printStartHints('/opsx:new');
} else {
console.log("Done. Run 'openspec config profile' to configure your workflows.");
advertisedAnInvocation = false;
}
// Workflows the active profile left out. Setup is the only moment a user
// is told what exists, so name them here rather than let a missing
// command read as a broken install (#1076). Skipped when the branch above
// already pointed at `openspec config profile`, and when no tool received
// a workflow surface at all (no tools selected, or none that could take
// one) — there, adding workflows writes nothing, so naming them would
// point at the wrong problem.
if (advertisedAnInvocation && (commandsGenerated || skillsGenerated)) {
const optionalWorkflowsNote = formatOptionalWorkflowsNote(activeWorkflows);
if (optionalWorkflowsNote) {
console.log();
for (const line of optionalWorkflowsNote) {
console.log(chalk.dim(line));
}
}
}
// Links
+31 -1
View File
@@ -10,7 +10,7 @@
* src/utils/command-references.ts at the call site.
*/
import type { WorkflowId } from './profiles.js';
import { ALL_WORKFLOWS, type WorkflowId } from './profiles.js';
export type OnboardingCommand = {
workflow: WorkflowId;
@@ -48,3 +48,33 @@ export function getOnboardingCommands(
const installed = new Set(workflows);
return ONBOARDING_COMMANDS.filter((entry) => installed.has(entry.workflow));
}
/**
* Returns the note telling a user which workflows their profile left out, or
* null when every workflow is already installed.
*
* Setup output otherwise never names the workflows that exist but were not
* installed, so a user on the default profile has no way to learn that
* `/opsx:ff` and friends are one command away. The docs say it; nobody reads
* the docs before typing a command that isn't there.
*/
export function formatOptionalWorkflowsNote(
installedWorkflows: readonly string[]
): string[] | null {
const installed = new Set(installedWorkflows);
const missing = ALL_WORKFLOWS.filter((workflow) => !installed.has(workflow));
if (missing.length === 0) {
return null;
}
const label = missing.length === 1 ? 'workflow is' : 'workflows are';
const pronoun = missing.length === 1 ? 'it' : 'them';
// `openspec config profile` offers to apply to this project before it
// exits, and prints the `openspec update` guidance itself when declined, so
// naming a second command here would be one step too many.
return [
`Note: ${missing.length} more ${label} available (${missing.join(', ')}).`,
`Add ${pronoun} with \`openspec config profile\`.`,
];
}
+80 -10
View File
@@ -46,7 +46,7 @@ import {
import { isInteractive } from '../utils/interactive.js';
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
import { getOnboardingCommands } from './onboarding-commands.js';
import { formatOptionalWorkflowsNote, getOnboardingCommands } from './onboarding-commands.js';
import { getAvailableTools } from './available-tools.js';
import {
WORKFLOW_TO_SKILL_DIR,
@@ -250,8 +250,7 @@ export class UpdateCommand {
// Still check for new tool directories and extra workflows
this.detectNewTools(resolvedProjectPath, configuredTools);
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
this.displayProfileNotes(resolvedProjectPath, configuredTools, desiredWorkflows, profile, delivery);
this.displaySetupNotes(configuredTools);
return;
}
@@ -489,9 +488,8 @@ export class UpdateCommand {
// 13. Detect new tool directories not currently configured
this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
// 14. Display note about extra workflows not in profile
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
// 14. Display the profile notes
this.displayProfileNotes(resolvedProjectPath, configuredAndNewTools, desiredWorkflows, profile, delivery);
this.displaySetupNotes(configuredAndNewTools);
// 15. List affected tools
@@ -636,6 +634,34 @@ export class UpdateCommand {
}
}
/**
* Prints the profile notes, in order, with one pointer at
* `openspec config profile` rather than three.
*
* Every note is evaluated: reading them as one short-circuited `||` chain
* would swallow whichever ran second.
*/
private displayProfileNotes(
projectPath: string,
configuredTools: string[],
desiredWorkflows: readonly string[] | undefined,
profile: Profile,
delivery: Delivery
): void {
const printedExtraNote = this.displayExtraWorkflowsNote(
projectPath,
configuredTools,
desiredWorkflows ?? []
);
const printedMissingCoreNote = this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
this.displayOptionalWorkflowsNote(
configuredTools,
desiredWorkflows,
delivery,
printedExtraNote || printedMissingCoreNote
);
}
/**
* Displays a note about extra workflows installed that aren't in the current profile.
*/
@@ -643,14 +669,16 @@ export class UpdateCommand {
projectPath: string,
configuredTools: string[],
profileWorkflows: readonly string[]
): void {
): boolean {
const installedWorkflows = scanInstalledWorkflows(projectPath, configuredTools);
const profileSet = new Set(profileWorkflows);
const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));
if (extraWorkflows.length > 0) {
console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
return true;
}
return false;
}
/**
@@ -658,22 +686,64 @@ export class UpdateCommand {
* grow CORE_WORKFLOWS stay discoverable. Keep custom profiles user-owned;
* do not mutate them.
*/
private displayMissingCoreWorkflowsNote(profile: Profile, workflows?: readonly string[]): void {
private displayMissingCoreWorkflowsNote(profile: Profile, workflows?: readonly string[]): boolean {
if (profile !== 'custom' || !workflows) {
return;
return false;
}
const workflowSet = new Set(workflows);
const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow));
if (missing.length === 0) {
return;
return false;
}
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.`));
return true;
}
/**
* Fallback pointer to the workflows the profile leaves out.
*
* `update` already points at `openspec config profile` when files drift from
* the profile, and when a custom profile is missing core workflows. Neither
* fires for the default `core` profile, so the user `update` is most likely
* to be helping — the one who ran it because a command they read about never
* appeared — learns nothing (#1076). This covers that gap.
*
* Silent when another note already pointed at the same command, and when no
* configured tool can receive a workflow surface under the active delivery:
* adding workflows would write nothing there.
*/
private displayOptionalWorkflowsNote(
configuredTools: string[],
workflows: readonly string[] | undefined,
delivery: Delivery,
alreadyPointedAtProfileConfig: boolean
): void {
if (alreadyPointedAtProfileConfig || !workflows) {
return;
}
const anyToolHasASurface = configuredTools.some(
(toolId) =>
shouldGenerateSkillsForTool(toolId, delivery) ||
shouldGenerateCommandsForTool(toolId, delivery)
);
if (!anyToolHasASurface) {
return;
}
const note = formatOptionalWorkflowsNote(workflows);
if (!note) {
return;
}
for (const line of note) {
console.log(chalk.dim(line));
}
}
/**
+59
View File
@@ -6,6 +6,7 @@ import { InitCommand } from '../../src/core/init.js';
import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js';
import { MAX_CONTEXT_SIZE, readProjectConfig } from '../../src/core/project-config.js';
import { FileSystemUtils } from '../../src/utils/file-system.js';
import { ALL_WORKFLOWS } from '../../src/core/profiles.js';
const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({
confirmMock: vi.fn(),
@@ -2106,6 +2107,64 @@ describe('InitCommand - profile and detection features', () => {
expect(startHint).not.toContain('/opsx:propose');
});
it('should name the workflows the core profile leaves out (#1076)', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
const note = logCalls.find((entry) => entry.includes('more workflows are available'));
expect(note).toBeTruthy();
for (const workflow of ['new', 'continue', 'ff', 'bulk-archive', 'verify', 'onboard']) {
expect(note).toContain(workflow);
}
// Workflows that were installed must not be advertised as missing
expect(note).not.toContain('propose,');
expect(logCalls.some((entry) => entry.includes('openspec config profile'))).toBe(true);
});
it('should not advertise missing workflows when the profile installs all of them', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'custom',
delivery: 'both',
workflows: [...ALL_WORKFLOWS],
});
const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('more workflows are available'))).toBe(false);
expect(logCalls.some((entry) => entry.includes('more workflow is available'))).toBe(false);
});
it('should not advertise missing workflows when no tool was selected', async () => {
// With no tools, `openspec config profile` + `openspec update` would write
// nothing, so naming the workflows would point at the wrong problem.
const initCommand = new InitCommand({ tools: 'none', force: true });
await initCommand.execute(testDir);
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('more workflows are available'))).toBe(false);
});
it('should not advertise missing workflows when nothing was generated at all', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'core',
delivery: 'commands',
});
// Kimi has no command adapter: the configuration correction is the whole
// story, so a "6 more workflows" note would point at the wrong problem.
const initCommand = new InitCommand({ tools: 'kimi', force: true });
await initCommand.execute(testDir);
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('No skills or commands were generated'))).toBe(true);
expect(logCalls.some((entry) => entry.includes('more workflows are available'))).toBe(false);
});
it('should print a configuration correction, not a dead hint, when delivery=commands generates nothing (adapterless tool)', async () => {
saveGlobalConfig({
featureFlags: {},
+44
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
DESCRIPTION_BUDGET,
formatOptionalWorkflowsNote,
getOnboardingCommands,
} from '../../src/core/onboarding-commands.js';
import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js';
@@ -41,3 +42,46 @@ describe('getOnboardingCommands', () => {
}
});
});
describe('formatOptionalWorkflowsNote', () => {
it('names every workflow the core profile leaves out', () => {
const note = formatOptionalWorkflowsNote(CORE_WORKFLOWS);
expect(note).not.toBeNull();
expect(note?.[0]).toBe(
'Note: 6 more workflows are available (new, continue, ff, bulk-archive, verify, onboard).'
);
expect(note?.[1]).toBe(
'Add them with `openspec config profile`.'
);
});
it('lists the missing workflows in declaration order, not the order given', () => {
const installed = ALL_WORKFLOWS.filter(
(workflow) => workflow !== 'new' && workflow !== 'verify'
);
const note = formatOptionalWorkflowsNote([...installed].reverse());
expect(note?.[0]).toBe('Note: 2 more workflows are available (new, verify).');
});
it('reads as a singular sentence when exactly one workflow is missing', () => {
const note = formatOptionalWorkflowsNote(
ALL_WORKFLOWS.filter((workflow) => workflow !== 'onboard')
);
expect(note?.[0]).toBe('Note: 1 more workflow is available (onboard).');
expect(note?.[1]).toBe(
'Add it with `openspec config profile`.'
);
});
it('returns null when every workflow is installed', () => {
expect(formatOptionalWorkflowsNote(ALL_WORKFLOWS)).toBeNull();
});
it('ignores workflow names that are not part of the system', () => {
expect(formatOptionalWorkflowsNote([...ALL_WORKFLOWS, 'not-a-workflow'])).toBeNull();
});
});
+99
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { UpdateCommand, scanInstalledWorkflows } from '../../src/core/update.js';
import { InitCommand } from '../../src/core/init.js';
import { getConfiguredToolsForProfileSync } from '../../src/core/profile-sync-drift.js';
import { ALL_WORKFLOWS } from '../../src/core/profiles.js';
import { FileSystemUtils } from '../../src/utils/file-system.js';
import { OPENSPEC_MARKERS } from '../../src/core/config.js';
import type { GlobalConfig } from '../../src/core/global-config.js';
@@ -3420,6 +3421,104 @@ More user content after markers.
consoleSpy.mockRestore();
});
it('should name the workflows the core profile leaves out (#1076)', async () => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'both' });
const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);
const consoleSpy = vi.spyOn(console, 'log');
// The up-to-date path is where a user chasing a missing command lands:
// troubleshooting tells them to run `openspec update` first.
await updateCommand.execute(testDir);
const calls = consoleSpy.mock.calls.map(call =>
call.map(arg => String(arg)).join(' ')
);
const note = calls.find(call => call.includes('more workflows are available'));
expect(note).toBeTruthy();
for (const workflow of ['new', 'continue', 'ff', 'bulk-archive', 'verify', 'onboard']) {
expect(note).toContain(workflow);
}
consoleSpy.mockRestore();
});
it('should not repeat the profile pointer when the missing-core note already gave it', 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'))).toBe(true);
expect(calls.some(call => call.includes('more workflows are available'))).toBe(false);
consoleSpy.mockRestore();
});
it('should not advertise missing workflows when the profile installs all of them', async () => {
setMockConfig({
featureFlags: {},
profile: 'custom',
delivery: 'both',
workflows: [...ALL_WORKFLOWS],
});
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('more workflows are available'))).toBe(false);
expect(calls.some(call => call.includes('more workflow is available'))).toBe(false);
consoleSpy.mockRestore();
});
it('should not advertise missing workflows when no tool can receive one', async () => {
// A project set up for a skills-only tool, then switched to
// delivery=commands: the tool stays configured but can receive nothing,
// so adding workflows would write nothing and the pointer would send the
// user the wrong way. `update` prints its delivery correction instead.
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'both' });
const initCommand = new InitCommand({ tools: 'kimi', force: true });
await initCommand.execute(testDir);
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const calls = consoleSpy.mock.calls.map(call =>
call.map(arg => String(arg)).join(' ')
);
// Proves the run got as far as the notes rather than bailing earlier
expect(calls.some(call => call.includes('No skills or commands remain for'))).toBe(true);
expect(calls.some(call => call.includes('more workflows are available'))).toBe(false);
consoleSpy.mockRestore();
});
it('should respect skills-only delivery setting', async () => {
setMockConfig({
featureFlags: {},