Skip to content

Commit cf06d45

Browse files
authored
fix(profiles): include sync with archive workflows (#1663)
* fix(profiles): install sync with archive workflows * test(profiles): harden archive dependency coverage * fix(config): preserve custom profile ownership
1 parent f3aa167 commit cf06d45

7 files changed

Lines changed: 226 additions & 37 deletions

File tree

src/commands/config.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@ export function registerConfigCommand(program: Command): void {
539539
delivery: currentState.delivery,
540540
workflows: [...currentState.workflows],
541541
};
542+
let workflowSelectionChanged = false;
542543

543544
if (action === 'both' || action === 'delivery') {
544545
const deliveryChoices: { value: Delivery; name: string; description: string }[] = [
@@ -602,7 +603,12 @@ export function registerConfigCommand(program: Command): void {
602603
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
603604
});
604605
nextState.workflows = selectedWorkflows;
605-
nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows);
606+
workflowSelectionChanged =
607+
selectedWorkflows.length !== currentState.workflows.length ||
608+
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
609+
nextState.profile = workflowSelectionChanged
610+
? deriveProfileFromWorkflowSelection(selectedWorkflows)
611+
: currentState.profile;
606612
}
607613

608614
const diff = diffProfileState(currentState, nextState);
@@ -620,7 +626,9 @@ export function registerConfigCommand(program: Command): void {
620626

621627
config.profile = nextState.profile;
622628
config.delivery = nextState.delivery;
623-
config.workflows = nextState.workflows;
629+
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
630+
config.workflows = nextState.workflows;
631+
}
624632
saveGlobalConfig(config);
625633

626634
// Check if inside an OpenSpec project

src/core/profiles.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,27 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
3838
* Resolves which workflows should be active for a given profile configuration.
3939
*
4040
* - 'core' profile always returns CORE_WORKFLOWS
41-
* - 'custom' profile returns the provided customWorkflows, or empty array if not provided
41+
* - 'custom' profile returns the provided customWorkflows and required dependencies
4242
*/
4343
export function getProfileWorkflows(
4444
profile: Profile,
4545
customWorkflows?: string[]
4646
): readonly string[] {
4747
if (profile === 'custom') {
48-
return customWorkflows ?? [];
48+
const workflows = customWorkflows ?? [];
49+
const syncDependentIndex = workflows.findIndex(
50+
(workflow) => workflow === 'archive' || workflow === 'bulk-archive'
51+
);
52+
53+
if (syncDependentIndex !== -1 && !workflows.includes('sync')) {
54+
return [
55+
...workflows.slice(0, syncDependentIndex),
56+
'sync',
57+
...workflows.slice(syncDependentIndex),
58+
];
59+
}
60+
61+
return workflows;
4962
}
5063
return CORE_WORKFLOWS;
5164
}

src/core/update.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export class UpdateCommand {
247247
// Still check for new tool directories and extra workflows
248248
this.detectNewTools(resolvedProjectPath, configuredTools);
249249
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
250-
this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
250+
this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
251251
this.displaySetupNotes(configuredTools);
252252
return;
253253
}
@@ -476,7 +476,7 @@ export class UpdateCommand {
476476

477477
// 14. Display note about extra workflows not in profile
478478
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
479-
this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
479+
this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
480480
this.displaySetupNotes(configuredAndNewTools);
481481

482482
// 15. List affected tools
@@ -1115,7 +1115,12 @@ export class UpdateCommand {
11151115
}
11161116
}
11171117

1118-
const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
1118+
const inferredCodexWorkflows = getProfileWorkflows(
1119+
'custom',
1120+
getLegacyWorkflowIdsForTool(detection, 'codex')
1121+
).filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] =>
1122+
(ALL_WORKFLOWS as readonly string[]).includes(workflow)
1123+
);
11191124

11201125
// Create skills/commands for selected tools using effective profile+delivery.
11211126
const newlyConfigured: string[] = [];

test/commands/config-profile.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,65 @@ describe('config profile interactive flow', () => {
288288
expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');
289289
});
290290

291+
it('should preserve a custom profile when dependency expansion matches the core set', async () => {
292+
const { saveGlobalConfig, getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js');
293+
const { select, checkbox, confirm } = await getPromptMocks();
294+
295+
saveGlobalConfig({
296+
featureFlags: {},
297+
profile: 'custom',
298+
delivery: 'both',
299+
workflows: ['propose', 'explore', 'apply', 'update', 'archive'],
300+
});
301+
const configPath = getGlobalConfigPath();
302+
const beforeContent = fs.readFileSync(configPath, 'utf-8');
303+
304+
select.mockResolvedValueOnce('workflows');
305+
checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'update', 'sync', 'archive']);
306+
307+
await runConfigCommand(['profile']);
308+
309+
expect(getGlobalConfig().profile).toBe('custom');
310+
expect(fs.readFileSync(configPath, 'utf-8')).toBe(beforeContent);
311+
expect(confirm).not.toHaveBeenCalled();
312+
expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');
313+
});
314+
315+
it.each(['delivery', 'both'] as const)(
316+
'should preserve raw custom workflows during a %s change',
317+
async (action) => {
318+
const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');
319+
const { select, checkbox } = await getPromptMocks();
320+
321+
saveGlobalConfig({
322+
featureFlags: {},
323+
profile: 'custom',
324+
delivery: 'both',
325+
workflows: ['propose', 'explore', 'apply', 'update', 'archive'],
326+
});
327+
select.mockResolvedValueOnce(action);
328+
select.mockResolvedValueOnce('skills');
329+
if (action === 'both') {
330+
checkbox.mockResolvedValueOnce([
331+
'propose',
332+
'explore',
333+
'apply',
334+
'update',
335+
'sync',
336+
'archive',
337+
]);
338+
}
339+
340+
await runConfigCommand(['profile']);
341+
342+
expect(getGlobalConfig()).toMatchObject({
343+
profile: 'custom',
344+
delivery: 'skills',
345+
workflows: ['propose', 'explore', 'apply', 'update', 'archive'],
346+
});
347+
}
348+
);
349+
291350
it('keep action should warn when project files drift from global config', async () => {
292351
const { saveGlobalConfig } = await import('../../src/core/global-config.js');
293352
const { select } = await getPromptMocks();

test/core/init.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,34 @@ describe('InitCommand', () => {
118118
}
119119
});
120120

121+
it.each([
122+
['archive', 'openspec-archive-change'],
123+
['bulk-archive', 'openspec-bulk-archive-change'],
124+
] as const)(
125+
'should install the sync workflow required by %s in a custom profile',
126+
async (archiveWorkflow, archiveSkill) => {
127+
saveGlobalConfig({
128+
featureFlags: {},
129+
profile: 'custom',
130+
delivery: 'both',
131+
workflows: ['propose', 'explore', 'apply', archiveWorkflow],
132+
});
133+
134+
const initCommand = new InitCommand({ tools: 'claude', force: true });
135+
await initCommand.execute(testDir);
136+
137+
await expect(
138+
fs.access(path.join(testDir, '.claude', 'skills', archiveSkill, 'SKILL.md'))
139+
).resolves.toBeUndefined();
140+
await expect(
141+
fs.access(path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md'))
142+
).resolves.toBeUndefined();
143+
await expect(
144+
fs.access(path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md'))
145+
).resolves.toBeUndefined();
146+
}
147+
);
148+
121149
it('should create core profile commands for Claude Code by default', async () => {
122150
const initCommand = new InitCommand({ tools: 'claude', force: true });
123151

test/core/profiles.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,32 @@ describe('profiles', () => {
5454
expect(result).toEqual(customWorkflows);
5555
});
5656

57+
it('should include sync when a custom profile selects archive', () => {
58+
const result = getProfileWorkflows('custom', ['propose', 'explore', 'apply', 'archive']);
59+
expect(result).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']);
60+
});
61+
62+
it('should include sync when a custom profile selects bulk archive', () => {
63+
const result = getProfileWorkflows('custom', ['explore', 'bulk-archive']);
64+
expect(result).toEqual(['explore', 'sync', 'bulk-archive']);
65+
});
66+
67+
it('should not duplicate or reorder an existing sync dependency', () => {
68+
const workflows = ['sync', 'archive', 'bulk-archive'];
69+
const result = getProfileWorkflows('custom', workflows);
70+
71+
expect(result).toEqual(workflows);
72+
expect(result).toBe(workflows);
73+
});
74+
75+
it('should not mutate the custom workflow selection when adding sync', () => {
76+
const workflows = ['archive', 'bulk-archive'];
77+
const result = getProfileWorkflows('custom', workflows);
78+
79+
expect(result).toEqual(['sync', 'archive', 'bulk-archive']);
80+
expect(workflows).toEqual(['archive', 'bulk-archive']);
81+
});
82+
5783
it('should return empty array for custom profile with no customWorkflows', () => {
5884
const result = getProfileWorkflows('custom');
5985
expect(result).toEqual([]);

test/core/update.test.ts

Lines changed: 80 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2393,6 +2393,33 @@ ${OPENSPEC_MARKERS.end}
23932393
)).toBe(false);
23942394
});
23952395

2396+
it.each([
2397+
['opsx-archive.md', 'openspec-archive-change'],
2398+
['opsx-bulk-archive.md', 'openspec-bulk-archive-change'],
2399+
])('should include sync when replacing legacy Codex %s', async (promptName, archiveSkill) => {
2400+
setMockConfig({
2401+
featureFlags: {},
2402+
profile: 'core',
2403+
delivery: 'skills',
2404+
});
2405+
2406+
const promptDir = path.join(process.env.CODEX_HOME!, 'prompts');
2407+
const managedPrompt = path.join(promptDir, promptName);
2408+
await fs.mkdir(promptDir, { recursive: true });
2409+
await fs.writeFile(managedPrompt, 'legacy archive prompt');
2410+
2411+
const forceUpdateCommand = new UpdateCommand({ force: true });
2412+
await forceUpdateCommand.execute(testDir);
2413+
2414+
expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false);
2415+
expect(await FileSystemUtils.fileExists(
2416+
path.join(testDir, '.agents', 'skills', archiveSkill, 'SKILL.md')
2417+
)).toBe(true);
2418+
expect(await FileSystemUtils.fileExists(
2419+
path.join(testDir, '.agents', 'skills', 'openspec-sync-specs', 'SKILL.md')
2420+
)).toBe(true);
2421+
});
2422+
23962423
it('should print a skill-based getting-started menu when a legacy upgrade newly configures codex', async () => {
23972424
setMockConfig({
23982425
featureFlags: {},
@@ -3065,40 +3092,63 @@ More user content after markers.
30653092
)).toBe(false);
30663093
});
30673094

3068-
it('should list missing core workflows when custom profile preserves the old core workflow set', async () => {
3069-
setMockConfig({
3070-
featureFlags: {},
3071-
profile: 'custom',
3072-
delivery: 'both',
3073-
workflows: ['propose', 'explore', 'apply', 'archive'],
3074-
});
3075-
3076-
const initCommand = new InitCommand({ tools: 'claude', force: true });
3077-
await initCommand.execute(testDir);
3078-
3079-
const consoleSpy = vi.spyOn(console, 'log');
3095+
it.each(['skills', 'commands', 'both'] as const)(
3096+
'should repair an archive profile missing sync with %s delivery',
3097+
async (delivery) => {
3098+
setMockConfig({
3099+
featureFlags: {},
3100+
profile: 'custom',
3101+
delivery,
3102+
workflows: ['propose', 'explore', 'apply', 'archive'],
3103+
});
30803104

3081-
await updateCommand.execute(testDir);
3105+
const archiveSkill = path.join(
3106+
testDir,
3107+
'.claude',
3108+
'skills',
3109+
'openspec-archive-change',
3110+
'SKILL.md'
3111+
);
3112+
const archiveCommand = path.join(
3113+
testDir,
3114+
'.claude',
3115+
'commands',
3116+
'opsx',
3117+
'archive.md'
3118+
);
3119+
if (delivery !== 'commands') {
3120+
await fs.mkdir(path.dirname(archiveSkill), { recursive: true });
3121+
await fs.writeFile(archiveSkill, 'old archive skill');
3122+
}
3123+
if (delivery !== 'skills') {
3124+
await fs.mkdir(path.dirname(archiveCommand), { recursive: true });
3125+
await fs.writeFile(archiveCommand, 'old archive command');
3126+
}
30823127

3083-
const calls = consoleSpy.mock.calls.map(call =>
3084-
call.map(arg => String(arg)).join(' ')
3085-
);
3086-
expect(calls.some(call =>
3087-
call.includes('Your custom profile is missing 2 core workflows: update, sync')
3088-
)).toBe(true);
3089-
expect(calls.some(call =>
3090-
call.includes('openspec config profile core')
3091-
)).toBe(true);
3128+
const consoleSpy = vi.spyOn(console, 'log');
30923129

3093-
expect(await FileSystemUtils.fileExists(
3094-
path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md')
3095-
)).toBe(false);
3096-
expect(await FileSystemUtils.fileExists(
3097-
path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md')
3098-
)).toBe(false);
3130+
await updateCommand.execute(testDir);
30993131

3100-
consoleSpy.mockRestore();
3101-
});
3132+
const calls = consoleSpy.mock.calls.map(call =>
3133+
call.map(arg => String(arg)).join(' ')
3134+
);
3135+
expect(calls.some(call =>
3136+
call.includes('Your custom profile is missing 1 core workflow: update')
3137+
)).toBe(true);
3138+
expect(calls.some(call =>
3139+
call.includes('openspec config profile core')
3140+
)).toBe(true);
3141+
3142+
expect(await FileSystemUtils.fileExists(
3143+
path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md')
3144+
)).toBe(delivery !== 'commands');
3145+
expect(await FileSystemUtils.fileExists(
3146+
path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md')
3147+
)).toBe(delivery !== 'skills');
3148+
3149+
consoleSpy.mockRestore();
3150+
}
3151+
);
31023152

31033153
it('should list a single missing core workflow when custom profile lacks only update', async () => {
31043154
setMockConfig({

0 commit comments

Comments
 (0)