Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 152 additions & 32 deletions packages/core/src/workflow-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,48 @@ import { ConfigManager } from './config-manager.js';

const logger = createLogger('WorkflowManager');

/**
* Domain descriptions exposed to the LLM via the load_workflows tool and
* domain:// resource to help it discover and choose domains intelligently.
*/
export const DOMAIN_DESCRIPTIONS: Record<string, string> = {
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)',
architecture:
'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)',
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',
'sdd-crowd':
'Multi-agent collaborative specification-driven development: role-based handoffs between business analysts (specify), architects (plan), and developers (implement) for coordinated distributed teams',
skilled:
'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',
office:
'Content creation and communication: structured workflows for writing blog posts (discovery through distribution) and creating slide presentations (ideate through deliver)',
children:
'Educational game development for children ages 8-12: simplified, age-appropriate programming concepts with frequent positive reinforcement and incremental achievement',
};

/**
* Known domain names — single source of truth. Import from
* '@codemcp/workflows-core' in plugin/tool handler code.
*/
export const KNOWN_DOMAIN_NAMES = Object.keys(DOMAIN_DESCRIPTIONS) as [
'code',
'architecture',
'sdd',
'sdd-crowd',
'skilled',
'office',
'children',
];

export interface WorkflowManagerOptions {
/**
* Default domains to use for workflow filtering.
* Takes precedence over all environment variables.
* Can be a comma-separated string or an array of domain names.
*/
defaultDomains?: string | string[];
}

export interface WorkflowInfo {
name: string;
displayName: string;
Expand All @@ -41,46 +83,132 @@ export class WorkflowManager {
private stateMachineLoader: StateMachineLoader;
private lastProjectPath: string | null = null; // Track last loaded project path
private enabledDomains: Set<string>;
private _defaultDomains: string | string[] | null = null; // Constructor override

constructor() {
constructor(options?: WorkflowManagerOptions) {
this.stateMachineLoader = new StateMachineLoader();
if (options?.defaultDomains !== undefined) {
this._defaultDomains = options.defaultDomains;
}
this.enabledDomains = this.parseEnabledDomains();
this.loadPredefinedWorkflows();
}

/**
* Parse enabled domains from environment variable.
* WORKFLOW_DOMAINS is the canonical name.
* VIBE_WORKFLOW_DOMAINS is supported as a legacy alias for backward compatibility.
* WORKFLOW_DOMAINS takes precedence when both are set.
* Parse enabled domains from environment variable with four-level precedence chain:
* 1. Constructor parameter `defaultDomains` (highest priority)
* 2. `WORKFLOW_DOMAINS` env var (canonical runtime configuration)
* 3. `DEFAULT_DOMAINS` env var (runtime default when canonical is unset)
* 4. `VIBE_WORKFLOW_DOMAINS` env var (legacy alias for backward compatibility)
* 5. 'code' — final fallback: backward-compatible default
*/
private parseEnabledDomains(): Set<string> {
// WORKFLOW_DOMAINS (canonical) takes precedence over VIBE_WORKFLOW_DOMAINS (legacy alias)
const domainsEnv =
process.env['WORKFLOW_DOMAINS'] || process.env['VIBE_WORKFLOW_DOMAINS'];
// 1. Constructor parameter (highest priority)
if (this._defaultDomains !== null) {
const domains = new Set(
Array.isArray(this._defaultDomains)
? this._defaultDomains
: this._defaultDomains
.split(',')
.map(d => d.trim())
.filter(d => d)
);
logger.debug('Using constructor default domains', {
domains: Array.from(domains),
});
return domains;
}

// 2. WORKFLOW_DOMAINS (canonical)
if (process.env['WORKFLOW_DOMAINS']) {
return this._parseDomainString(
process.env['WORKFLOW_DOMAINS'],
'WORKFLOW_DOMAINS'
);
}

if (!domainsEnv) {
logger.debug('No domain configuration found, using default: code');
return new Set(['code']);
// 3. DEFAULT_DOMAINS (runtime default)
if (process.env['DEFAULT_DOMAINS']) {
return this._parseDomainString(
process.env['DEFAULT_DOMAINS'],
'DEFAULT_DOMAINS'
);
}

// 4. VIBE_WORKFLOW_DOMAINS (legacy alias)
if (process.env['VIBE_WORKFLOW_DOMAINS']) {
return this._parseDomainString(
process.env['VIBE_WORKFLOW_DOMAINS'],
'VIBE_WORKFLOW_DOMAINS (legacy)'
);
}

// 5. Default — 'code'
logger.debug('No domain configuration found, using default: code');
return new Set(['code']);
}

/**
* Parse a comma-separated domain string into a Set.
*/
private _parseDomainString(
domainString: string,
source: string
): Set<string> {
const domains = new Set(
domainsEnv
domainString
.split(',')
.map(d => d.trim())
.filter(d => d)
);

logger.debug('Parsed enabled domains', {
source: process.env['WORKFLOW_DOMAINS']
? 'WORKFLOW_DOMAINS'
: 'VIBE_WORKFLOW_DOMAINS (legacy)',
source,
domains: Array.from(domains),
});

return domains;
}

/**
* Replace the current domain set and reload workflows.
* Validates domains against DOMAIN_DESCRIPTIONS.
* Pass an empty array to load ALL workflows (no domain filtering).
*
* @throws Error if an unknown domain is provided
*/
public setDomains(domains: string | string[]): void {
const newSet = new Set(
Array.isArray(domains)
? domains
: domains
.split(',')
.map(d => d.trim())
.filter(d => d)
);

// Validate domains against known set
const knownDomains = new Set(Object.keys(DOMAIN_DESCRIPTIONS));
for (const domain of newSet) {
if (!knownDomains.has(domain)) {
throw new Error(
`Unknown domain: '${domain}'. Known domains: ${Array.from(knownDomains).join(', ')}`
);
}
}

this.enabledDomains = newSet;
this.predefinedWorkflows.clear();
this.workflowInfos.clear();
this.loadPredefinedWorkflows();
if (this.lastProjectPath) {
this.loadProjectWorkflows(this.lastProjectPath);
}

logger.info('Domains updated', {
domains: Array.from(newSet),
totalWorkflows: this.predefinedWorkflows.size,
});
}

/**
* Load project-specific workflows from .vibe/workflows/
*/
Expand Down Expand Up @@ -183,23 +311,15 @@ export class WorkflowManager {
}
}
/**
* Get all available workflows regardless of domain filtering
* Get all available workflows regardless of domain filtering.
* Uses DEFAULT_ALL_DOMAINS env var if set, otherwise falls back to all known domains.
*/
public getAllAvailableWorkflows(): WorkflowInfo[] {
// Create a temporary manager with all domains enabled
const originalEnv = process.env['WORKFLOW_DOMAINS'];
process.env['WORKFLOW_DOMAINS'] = 'code,architecture,office,sdd';

try {
const tempManager = new WorkflowManager();
return tempManager.getAvailableWorkflows();
} finally {
if (originalEnv !== undefined) {
process.env['WORKFLOW_DOMAINS'] = originalEnv;
} else {
delete process.env['WORKFLOW_DOMAINS'];
}
}
const allDomains =
process.env['DEFAULT_ALL_DOMAINS'] ||
Object.keys(DOMAIN_DESCRIPTIONS).join(',');
const tempManager = new WorkflowManager({ defaultDomains: allDomains });
return tempManager.getAvailableWorkflows();
}

public getAvailableWorkflows(): WorkflowInfo[] {
Expand Down
27 changes: 27 additions & 0 deletions packages/mcp-server/src/server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { TemplateManager } from '@codemcp/workflows-core';
import {
createLogger,
setLoggingLevelFromString,
DOMAIN_DESCRIPTIONS,
KNOWN_DOMAIN_NAMES,
} from '@codemcp/workflows-core';

import {
Expand Down Expand Up @@ -447,6 +449,31 @@ export async function registerMcpTools(
createToolHandler('list_workflows', toolRegistry, responseRenderer, context)
);

// Register load_workflows tool — allows LLM to dynamically switch domains
const domainList = Object.entries(DOMAIN_DESCRIPTIONS)
.map(([d, desc]) => `${d}: ${desc}`)
.join(' | ');

mcpServer.registerTool(
'load_workflows',
{
description: `Load workflows from one or more domains. Replaces the current domain set. Available domains — ${domainList}`,
inputSchema: {
domains: z
.array(z.enum(KNOWN_DOMAIN_NAMES))
.describe('Domain names to load.'),
},
annotations: {
title: 'Workflow Domain Loader',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
createToolHandler('load_workflows', toolRegistry, responseRenderer, context)
);

// Register setup_project_docs tool with enhanced file linking support
const templateManager = new TemplateManager();
const availableTemplates = await templateManager.getAvailableTemplates();
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp-server/src/tool-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ResetDevelopmentHandler } from './reset-development.js';

import { ListWorkflowsHandler } from './list-workflows.js';
import { SetupProjectDocsHandler } from './setup-project-docs.js';
import { LoadWorkflowsHandler } from './load-workflows.js';
import { ToolHandler, ToolRegistry } from '../types.js';

const logger = createLogger('ToolRegistry');
Expand Down Expand Up @@ -57,6 +58,7 @@ export function createToolRegistry(): ToolRegistry {
registry.register('reset_development', new ResetDevelopmentHandler());
registry.register('list_workflows', new ListWorkflowsHandler());
registry.register('setup_project_docs', new SetupProjectDocsHandler());
registry.register('load_workflows', new LoadWorkflowsHandler());

logger.info('Tool registry created with handlers', {
handlers: registry.list(),
Expand All @@ -74,6 +76,7 @@ export { ResumeWorkflowHandler } from './resume-workflow.js';
export { ResetDevelopmentHandler } from './reset-development.js';
export { ListWorkflowsHandler } from './list-workflows.js';
export { SetupProjectDocsHandler } from './setup-project-docs.js';
export { LoadWorkflowsHandler } from './load-workflows.js';
export {
BaseToolHandler,
ConversationRequiredToolHandler,
Expand Down
81 changes: 81 additions & 0 deletions packages/mcp-server/src/tool-handlers/load-workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Load Workflows Tool Handler
*
* Allows the LLM to dynamically load workflows from one or more domains
* at runtime. Essential for long-lived processes (MCP server, OpenCode
* plugin) where the initial domain configuration may not include all
* needed workflows.
*/

import { z } from 'zod';
import { BaseToolHandler } from './base-tool-handler.js';
import { createLogger } from '@codemcp/workflows-core';
import { ServerContext } from '../types.js';

const logger = createLogger('LoadWorkflowsHandler');

const LoadWorkflowsArgsSchema = z.object({
domains: z
.array(z.string())
.describe(
'Domain names to load. Use domain:// resource to discover available domains and their descriptions.'
),
});

type LoadWorkflowsArgs = z.infer<typeof LoadWorkflowsArgsSchema>;

interface LoadWorkflowsResponse {
success: boolean;
domains: string[];
totalWorkflows: number;
message: string;
}

export class LoadWorkflowsHandler extends BaseToolHandler<
LoadWorkflowsArgs,
LoadWorkflowsResponse
> {
protected readonly argsSchema = LoadWorkflowsArgsSchema;

async executeHandler(
args: LoadWorkflowsArgs,
context: ServerContext
): Promise<LoadWorkflowsResponse> {
logger.info('Loading workflows from domains', {
domains: args.domains,
projectPath: context.projectPath,
});

try {
context.workflowManager.setDomains(args.domains);

const totalWorkflows =
context.workflowManager.getAvailableWorkflows().length;

logger.info('Workflows loaded successfully', {
domains: args.domains,
totalWorkflows,
});

return {
success: true,
domains: args.domains,
totalWorkflows,
message: `Loaded workflows from domains: ${args.domains.join(', ')}. Total workflows available: ${totalWorkflows}.`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to load workflows', error as Error, {
domains: args.domains,
});

return {
success: false,
domains: [],
totalWorkflows: 0,
message: `Failed to load workflows: ${errorMessage}`,
};
}
}
}
5 changes: 5 additions & 0 deletions packages/opencode-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createConductReviewTool } from './tool-handlers/conduct-review.js';
import { createResetDevelopmentTool } from './tool-handlers/reset-development.js';
import { createStartDevelopmentTool } from './tool-handlers/start-development.js';
import { createSetupProjectDocsTool } from './tool-handlers/setup-project-docs.js';
import { createLoadWorkflowsTool } from './tool-handlers/load-workflows.js';
import {
createOpenCodeLogger,
createOpenCodeLoggerFactory,
Expand Down Expand Up @@ -844,6 +845,10 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin
'setup_project_docs',
await createSetupProjectDocsTool(input.directory, getServerContext)
),
load_workflows: wrap(
'load_workflows',
createLoadWorkflowsTool(getServerContext)
),
};
})(),
};
Expand Down
Loading
Loading