Skip to content

Commit 1999cd7

Browse files
committed
feat: make default workflow domain configurable at runtime
Eliminate hardcoded 'code' defaults in WorkflowManager through: - Four-level env var chain: constructor param > WORKFLOW_DOMAINS > DEFAULT_DOMAINS > VIBE_WORKFLOW_DOMAINS > empty Set (all workflows) - defaultDomains constructor parameter for programmatic override - DEFAULT_ALL_DOMAINS env var for getAllAvailableWorkflows() - setDomains() method for runtime domain switching with validation - DOMAIN_DESCRIPTIONS constant with meaningful domain summaries - load_workflows tool in MCP server and OpenCode plugin for LLM to dynamically load domains without restarting the process Fix pre-existing bug: loadPredefinedWorkflows() now clears maps before reloading, preventing workflow accumulation across domain switches. Add 18 new tests: 9 for env var precedence chain, 9 for setDomains(). Update existing domain filtering test for new empty Set default.
1 parent 1c8e69e commit 1999cd7

10 files changed

Lines changed: 1206 additions & 29 deletions

File tree

.vibe/development-plan-refactor-configurable-default-domain.md

Lines changed: 482 additions & 0 deletions
Large diffs are not rendered by default.

packages/core/src/workflow-manager.ts

Lines changed: 172 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,38 @@ import { ConfigManager } from './config-manager.js';
1515

1616
const logger = createLogger('WorkflowManager');
1717

18+
/**
19+
* Domain descriptions for tool parameter metadata.
20+
* These are exposed to the LLM via the load_workflows tool to help it
21+
* discover and choose domains intelligently.
22+
*
23+
* Each description summarizes what the domain is suitable for,
24+
* based on the actual workflow YAML descriptions.
25+
*/
26+
export const DOMAIN_DESCRIPTIONS: Record<string, string> = {
27+
code: 'Day-to-day software engineering: features (epcc), test-driven development (tdd), bug fixes (bugfix, minor), greenfield projects (greenfield), large structured development (waterfall), and code reviews (pr-review)',
28+
architecture:
29+
'System understanding and planning: architectural decisions (adr), legacy system modernization (big-bang-conversion), API and boundary analysis (boundary-testing), business capability modeling (business-analysis), and progressive architecture discovery (c4-analysis)',
30+
sdd: 'Specification-driven development: write detailed specs before coding — structured requirements, user stories, testability focus, and constitutional compliance gates for bugfixes, features, and greenfield projects',
31+
'sdd-crowd':
32+
'Multi-agent collaborative specification-driven development: role-based handoffs between business analysts (specify), architects (plan), and developers (implement) for coordinated distributed teams',
33+
skilled:
34+
'Skill-augmented development: explicit prompts to apply specialized expertise (architecture, coding, testing, application design) at each phase — for scenarios where best practices and domain expertise should be leveraged',
35+
office:
36+
'Content creation and communication: structured workflows for writing blog posts (discovery through distribution) and creating slide presentations (ideate through deliver)',
37+
children:
38+
'Educational game development for children ages 8-12: simplified, age-appropriate programming concepts with frequent positive reinforcement and incremental achievement',
39+
};
40+
41+
export interface WorkflowManagerOptions {
42+
/**
43+
* Default domains to use for workflow filtering.
44+
* Takes precedence over all environment variables.
45+
* Can be a comma-separated string or an array of domain names.
46+
*/
47+
defaultDomains?: string | string[];
48+
}
49+
1850
export interface WorkflowInfo {
1951
name: string;
2052
displayName: string;
@@ -41,43 +73,88 @@ export class WorkflowManager {
4173
private stateMachineLoader: StateMachineLoader;
4274
private lastProjectPath: string | null = null; // Track last loaded project path
4375
private enabledDomains: Set<string>;
76+
private _defaultDomains: string | string[] | null = null; // Constructor override
4477

45-
constructor() {
78+
constructor(options?: WorkflowManagerOptions) {
4679
this.stateMachineLoader = new StateMachineLoader();
80+
if (options?.defaultDomains !== undefined) {
81+
this._defaultDomains = options.defaultDomains;
82+
}
4783
this.enabledDomains = this.parseEnabledDomains();
4884
this.loadPredefinedWorkflows();
4985
}
5086

5187
/**
52-
* Parse enabled domains from environment variable.
53-
* WORKFLOW_DOMAINS is the canonical name.
54-
* VIBE_WORKFLOW_DOMAINS is supported as a legacy alias for backward compatibility.
55-
* WORKFLOW_DOMAINS takes precedence when both are set.
88+
* Parse enabled domains from environment variable with four-level precedence chain:
89+
* 1. Constructor parameter `defaultDomains` (highest priority)
90+
* 2. `WORKFLOW_DOMAINS` env var (canonical runtime configuration)
91+
* 3. `DEFAULT_DOMAINS` env var (new: runtime default when canonical is unset)
92+
* 4. `VIBE_WORKFLOW_DOMAINS` env var (legacy alias for backward compatibility)
93+
* 5. Empty Set — final fallback: no filtering, all workflows load
5694
*/
5795
private parseEnabledDomains(): Set<string> {
58-
// WORKFLOW_DOMAINS (canonical) takes precedence over VIBE_WORKFLOW_DOMAINS (legacy alias)
59-
const domainsEnv =
60-
process.env['WORKFLOW_DOMAINS'] || process.env['VIBE_WORKFLOW_DOMAINS'];
96+
// 1. Constructor parameter (highest priority)
97+
if (this._defaultDomains !== null) {
98+
const domains = new Set(
99+
Array.isArray(this._defaultDomains)
100+
? this._defaultDomains
101+
: this._defaultDomains
102+
.split(',')
103+
.map(d => d.trim())
104+
.filter(d => d)
105+
);
106+
logger.debug('Using constructor default domains', {
107+
domains: Array.from(domains),
108+
});
109+
return domains;
110+
}
61111

62-
if (!domainsEnv) {
63-
logger.debug('No domain configuration found, using default: code');
64-
return new Set(['code']);
112+
// 2. WORKFLOW_DOMAINS (canonical)
113+
if (process.env['WORKFLOW_DOMAINS']) {
114+
return this._parseDomainString(
115+
process.env['WORKFLOW_DOMAINS'],
116+
'WORKFLOW_DOMAINS'
117+
);
65118
}
66119

120+
// 3. DEFAULT_DOMAINS (new: runtime default)
121+
if (process.env['DEFAULT_DOMAINS']) {
122+
return this._parseDomainString(
123+
process.env['DEFAULT_DOMAINS'],
124+
'DEFAULT_DOMAINS'
125+
);
126+
}
127+
128+
// 4. VIBE_WORKFLOW_DOMAINS (legacy alias)
129+
if (process.env['VIBE_WORKFLOW_DOMAINS']) {
130+
return this._parseDomainString(
131+
process.env['VIBE_WORKFLOW_DOMAINS'],
132+
'VIBE_WORKFLOW_DOMAINS (legacy)'
133+
);
134+
}
135+
136+
// 5. Empty Set — no filtering, all workflows load
137+
logger.debug('No domain configuration found, loading all workflows');
138+
return new Set();
139+
}
140+
141+
/**
142+
* Parse a comma-separated domain string into a Set.
143+
*/
144+
private _parseDomainString(
145+
domainString: string,
146+
source: string
147+
): Set<string> {
67148
const domains = new Set(
68-
domainsEnv
149+
domainString
69150
.split(',')
70151
.map(d => d.trim())
71152
.filter(d => d)
72153
);
73-
74154
logger.debug('Parsed enabled domains', {
75-
source: process.env['WORKFLOW_DOMAINS']
76-
? 'WORKFLOW_DOMAINS'
77-
: 'VIBE_WORKFLOW_DOMAINS (legacy)',
155+
source,
78156
domains: Array.from(domains),
79157
});
80-
81158
return domains;
82159
}
83160

@@ -183,12 +260,17 @@ export class WorkflowManager {
183260
}
184261
}
185262
/**
186-
* Get all available workflows regardless of domain filtering
263+
* Get all available workflows regardless of domain filtering.
264+
* Uses DEFAULT_ALL_DOMAINS env var if set, otherwise falls back to all known domains.
187265
*/
188266
public getAllAvailableWorkflows(): WorkflowInfo[] {
189267
// Create a temporary manager with all domains enabled
190268
const originalEnv = process.env['WORKFLOW_DOMAINS'];
191-
process.env['WORKFLOW_DOMAINS'] = 'code,architecture,office,sdd';
269+
const allDomains =
270+
process.env['DEFAULT_ALL_DOMAINS'] ||
271+
'code,architecture,office,sdd,sdd-crowd,skilled,children';
272+
273+
process.env['WORKFLOW_DOMAINS'] = allDomains;
192274

193275
try {
194276
const tempManager = new WorkflowManager();
@@ -202,6 +284,73 @@ export class WorkflowManager {
202284
}
203285
}
204286

287+
/**
288+
* Get information about any currently active workflow.
289+
* Returns null if no active workflow is detected.
290+
*/
291+
private getActiveWorkflow(): WorkflowInfo | null {
292+
// Check if any loaded workflow has metadata indicating it's active.
293+
// Since WorkflowManager doesn't track conversation state directly,
294+
// we return null here. The actual active workflow detection is handled
295+
// by ConversationManager. This method exists as a placeholder for
296+
// future integration if needed.
297+
return null;
298+
}
299+
300+
/**
301+
* Replace the current domain set and reload workflows.
302+
*
303+
* This allows runtime switching of domains without recreating the WorkflowManager.
304+
* Validates domains against known set and checks for active workflow conflicts.
305+
*
306+
* @param domains - Comma-separated string or array of domain names
307+
* @throws Error if an unknown domain is provided or if switching would conflict with an active workflow
308+
*/
309+
public setDomains(domains: string | string[]): void {
310+
const newSet = new Set(
311+
Array.isArray(domains)
312+
? domains
313+
: domains
314+
.split(',')
315+
.map(d => d.trim())
316+
.filter(d => d)
317+
);
318+
319+
// Validate domains against known set
320+
const knownDomains = new Set(Object.keys(DOMAIN_DESCRIPTIONS));
321+
for (const domain of newSet) {
322+
if (!knownDomains.has(domain)) {
323+
throw new Error(
324+
`Unknown domain: '${domain}'. Known domains: ${Array.from(knownDomains).join(', ')}`
325+
);
326+
}
327+
}
328+
329+
// Guard: check for active workflow conflict
330+
const activeWorkflow = this.getActiveWorkflow();
331+
if (
332+
activeWorkflow &&
333+
activeWorkflow.metadata?.domain &&
334+
!newSet.has(activeWorkflow.metadata.domain)
335+
) {
336+
throw new Error(
337+
`Cannot switch domains: active workflow '${activeWorkflow.name}' is in domain '${activeWorkflow.metadata.domain}', which is not in the new set. Finish or reset the current workflow first.`
338+
);
339+
}
340+
341+
// Update and reload
342+
this.enabledDomains = newSet;
343+
this.loadPredefinedWorkflows();
344+
if (this.lastProjectPath) {
345+
this.loadProjectWorkflows(this.lastProjectPath);
346+
}
347+
348+
logger.info('Domains updated', {
349+
domains: Array.from(newSet),
350+
totalWorkflows: this.predefinedWorkflows.size,
351+
});
352+
}
353+
205354
public getAvailableWorkflows(): WorkflowInfo[] {
206355
return Array.from(this.workflowInfos.values());
207356
}
@@ -525,6 +674,10 @@ export class WorkflowManager {
525674
*/
526675
private loadPredefinedWorkflows(): void {
527676
try {
677+
// Clear existing workflows before reloading (important for setDomains)
678+
this.predefinedWorkflows.clear();
679+
this.workflowInfos.clear();
680+
528681
const workflowsDir = this.findWorkflowsDirectory();
529682

530683
if (!workflowsDir || !fs.existsSync(workflowsDir)) {

packages/core/test/unit/workflow-domain-filtering.test.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,18 @@ describe('Workflow Domain Filtering', () => {
1616
}
1717
});
1818

19-
it('should load only code workflows when no domain filter is set', () => {
19+
it('should load all workflows when no domain filter is set (empty Set fallback)', () => {
2020
delete process.env.WORKFLOW_DOMAINS;
2121

2222
const manager = new WorkflowManager();
2323
const workflows = manager.getAvailableWorkflows();
2424

25-
// Should only include code domain workflows and workflows without domain
26-
const codeWorkflows = workflows.filter(
27-
w => !w.metadata?.domain || w.metadata.domain === 'code'
25+
// With empty Set, all workflows load (no filtering applied)
26+
// Should have workflows from multiple domains
27+
const domains = new Set(
28+
workflows.map(w => w.metadata?.domain).filter(Boolean) as string[]
2829
);
29-
const nonCodeWorkflows = workflows.filter(
30-
w => w.metadata?.domain && w.metadata.domain !== 'code'
31-
);
32-
33-
expect(codeWorkflows.length).toBeGreaterThan(0);
34-
expect(nonCodeWorkflows.length).toBe(0);
30+
expect(domains.size).toBeGreaterThan(1);
3531
});
3632

3733
it('should filter workflows by domain when WORKFLOW_DOMAINS is set', () => {

0 commit comments

Comments
 (0)