diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index 18e01df..8fe1aa0 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -150,6 +150,7 @@ describe('executePipeline', () => { 'Fix the bug', '/tmp/test', 'default', + false, undefined, undefined, ); @@ -172,6 +173,7 @@ describe('executePipeline', () => { 'Fix the bug', '/tmp/test', 'default', + false, undefined, { provider: 'codex', model: 'codex-model' }, ); @@ -229,6 +231,7 @@ describe('executePipeline', () => { 'From --task flag', '/tmp/test', 'magi', + false, undefined, undefined, ); @@ -389,6 +392,7 @@ describe('executePipeline', () => { 'Fix the bug', '/tmp/test', 'default', + false, undefined, undefined, ); diff --git a/src/__tests__/workflow-path-loading.test.ts b/src/__tests__/workflow-path-loading.test.ts new file mode 100644 index 0000000..2c6ded9 --- /dev/null +++ b/src/__tests__/workflow-path-loading.test.ts @@ -0,0 +1,189 @@ +/** + * Tests for path-based workflow loading + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { loadWorkflow, loadWorkflowFromPath } from '../config/workflowLoader.js'; + +describe('Path-based workflow loading', () => { + let tempDir: string; + let projectDir: string; + + beforeEach(() => { + // Create temporary directories for testing + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-')); + projectDir = mkdtempSync(join(tmpdir(), 'takt-project-')); + + // Create a test workflow in temp directory + writeFileSync( + join(tempDir, 'test-workflow.yaml'), + `name: test-path-workflow +description: Test workflow for path-based loading +initial_step: plan +max_iterations: 5 + +steps: + - name: plan + agent: planner + instruction: "Plan the task" + rules: + - condition: ai("Ready?") + next: implement + + - name: implement + agent: coder + instruction: "Implement" +`, + ); + + // Create project-local workflow directory + const projectWorkflowsDir = join(projectDir, '.takt', 'workflows'); + rmSync(projectWorkflowsDir, { recursive: true, force: true }); + writeFileSync( + join(tempDir, 'project-local.yaml'), + `name: project-local-workflow +description: Project-local workflow +initial_step: test +max_iterations: 1 + +steps: + - name: test + agent: tester + instruction: "Run tests" +`, + ); + }); + + afterEach(() => { + // Clean up temporary directories + rmSync(tempDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('should load workflow by absolute path', () => { + const absolutePath = join(tempDir, 'test-workflow.yaml'); + const workflow = loadWorkflowFromPath(absolutePath); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('test-path-workflow'); + expect(workflow!.description).toBe('Test workflow for path-based loading'); + }); + + it('should load workflow by relative path', () => { + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + const relativePath = './test-workflow.yaml'; + const workflow = loadWorkflowFromPath(relativePath, tempDir); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('test-path-workflow'); + } finally { + process.chdir(originalCwd); + } + }); + + it('should load workflow with .yaml extension in name', () => { + const pathWithExtension = join(tempDir, 'test-workflow.yaml'); + const workflow = loadWorkflowFromPath(pathWithExtension); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('test-path-workflow'); + }); + + it('should return null for non-existent path', () => { + const nonExistentPath = join(tempDir, 'non-existent.yaml'); + const workflow = loadWorkflowFromPath(nonExistentPath); + + expect(workflow).toBeNull(); + }); + + it('should maintain backward compatibility with name-based loading', () => { + // Load builtin workflow by name + const workflow = loadWorkflow('default'); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('default'); + }); + + it('should prioritize project-local workflows over global when loading by name', () => { + // Create project-local workflow directory + const projectWorkflowsDir = join(projectDir, '.takt', 'workflows'); + mkdirSync(projectWorkflowsDir, { recursive: true }); + + // Create project-local workflow with same name as builtin + writeFileSync( + join(projectWorkflowsDir, 'default.yaml'), + `name: project-override +description: Project-local override of default workflow +initial_step: custom +max_iterations: 1 + +steps: + - name: custom + agent: custom + instruction: "Custom step" +`, + ); + + // Load by name with projectCwd - should get project-local version + const workflow = loadWorkflow('default', projectDir); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('project-override'); + expect(workflow!.description).toBe('Project-local override of default workflow'); + }); + + it('should load workflows via loadWorkflowFromPath function', () => { + // Absolute paths + const pathWithSlash = join(tempDir, 'test-workflow.yaml'); + const workflow1 = loadWorkflowFromPath(pathWithSlash); + expect(workflow1).not.toBeNull(); + + // Relative paths + const workflow2 = loadWorkflowFromPath('./test-workflow.yaml', tempDir); + expect(workflow2).not.toBeNull(); + + // Explicit path loading + const yamlFile = join(tempDir, 'test-workflow.yaml'); + const workflow3 = loadWorkflowFromPath(yamlFile); + expect(workflow3).not.toBeNull(); + }); + + it('should handle workflow files with .yml extension', () => { + // Create workflow with .yml extension + const ymlPath = join(tempDir, 'test-yml.yml'); + writeFileSync( + ymlPath, + `name: yml-workflow +description: Workflow with .yml extension +initial_step: start +max_iterations: 1 + +steps: + - name: start + agent: starter + instruction: "Start" +`, + ); + + const workflow = loadWorkflowFromPath(ymlPath); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('yml-workflow'); + }); + + it('should resolve relative paths against provided base directory', () => { + const relativePath = 'test-workflow.yaml'; + const workflow = loadWorkflowFromPath(relativePath, tempDir); + + expect(workflow).not.toBeNull(); + expect(workflow!.name).toBe('test-path-workflow'); + }); +}); + +// Import for test setup +import { mkdirSync } from 'node:fs'; diff --git a/src/cli.ts b/src/cli.ts index c31e504..20addba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -111,6 +111,7 @@ export interface SelectAndExecuteOptions { autoPr?: boolean; repo?: string; workflow?: string; + workflowPath?: string; createWorktree?: boolean | undefined; } @@ -120,9 +121,24 @@ async function selectAndExecuteTask( options?: SelectAndExecuteOptions, agentOverrides?: TaskExecutionOptions, ): Promise { - const selectedWorkflow = await determineWorkflow(cwd, options?.workflow); + // Validate that only one workflow option is specified + if (options?.workflow && options?.workflowPath) { + error('Cannot specify both --workflow and --workflow-path'); + process.exit(1); + } - if (selectedWorkflow === null) { + let workflowIdentifier: string | null; + let isPathBased = false; + + if (options?.workflowPath) { + workflowIdentifier = await determineWorkflowPath(cwd, options.workflowPath); + isPathBased = true; + } else { + workflowIdentifier = await determineWorkflow(cwd, options?.workflow); + isPathBased = false; + } + + if (workflowIdentifier === null) { info('Cancelled'); return; } @@ -133,8 +149,8 @@ async function selectAndExecuteTask( options?.createWorktree, ); - log.info('Starting task execution', { workflow: selectedWorkflow, worktree: isWorktree }); - const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd, agentOverrides); + log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree, pathBased: isPathBased }); + const taskSuccess = await executeTask(task, execCwd, workflowIdentifier, isPathBased, cwd, agentOverrides); if (taskSuccess && isWorktree) { const commitResult = autoCommitAndPush(execCwd, task, cwd); @@ -149,7 +165,7 @@ async function selectAndExecuteTask( const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); if (shouldCreatePr) { info('Creating pull request...'); - const prBody = buildPrBody(undefined, `Workflow \`${selectedWorkflow}\` completed successfully.`); + const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`); const prResult = createPullRequest(execCwd, { branch, title: task.length > 100 ? `${task.slice(0, 97)}...` : task, @@ -171,12 +187,17 @@ async function selectAndExecuteTask( } /** - * Ask user whether to create a shared clone, and create one if confirmed. - * Returns the execution directory and whether a clone was created. - * Task name is summarized to English by AI for use in branch/clone names. + * Determine workflow to use. + * If override is provided, validate it (either as a name or path). + * Otherwise, prompt user to select interactively. + */ +/** + * Determine workflow to use (name-based only). + * For path-based loading, use determineWorkflowPath() instead. */ async function determineWorkflow(cwd: string, override?: string): Promise { if (override) { + // Validate workflow name exists const availableWorkflows = listWorkflows(); const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows; if (!knownWorkflows.includes(override)) { @@ -188,6 +209,34 @@ async function determineWorkflow(cwd: string, override?: string): Promise { + const { existsSync } = await import('node:fs'); + const { resolve: resolvePath, isAbsolute } = await import('node:path'); + const { homedir } = await import('node:os'); + + let resolvedPath = workflowPath; + + // Handle home directory + if (workflowPath.startsWith('~')) { + const home = homedir(); + resolvedPath = resolvePath(home, workflowPath.slice(1).replace(/^\//, '')); + } else if (!isAbsolute(workflowPath)) { + // Relative path + resolvedPath = resolvePath(cwd, workflowPath); + } + + if (!existsSync(resolvedPath)) { + error(`Workflow file not found: ${workflowPath}`); + return null; + } + + return workflowPath; // Return original path (loader will resolve it) +} + export async function confirmAndCreateWorktree( cwd: string, task: string, @@ -254,7 +303,8 @@ program // --- Global options --- program .option('-i, --issue ', 'GitHub issue number (equivalent to #N)', (val: string) => parseInt(val, 10)) - .option('-w, --workflow ', 'Workflow to use') + .option('-w, --workflow ', 'Workflow name to use') + .option('--workflow-path ', 'Path to workflow file (alternative to --workflow)') .option('-b, --branch ', 'Branch name (auto-generated if omitted)') .option('--auto-pr', 'Create PR after successful execution') .option('--repo ', 'Repository (defaults to current)') @@ -388,6 +438,7 @@ program autoPr: opts.autoPr === true, repo: opts.repo as string | undefined, workflow: opts.workflow as string | undefined, + workflowPath: opts.workflowPath as string | undefined, createWorktree: createWorktreeOverride, }; diff --git a/src/commands/listTasks.ts b/src/commands/listTasks.ts index 243a384..30f5351 100644 --- a/src/commands/listTasks.ts +++ b/src/commands/listTasks.ts @@ -324,7 +324,7 @@ export async function instructBranch( : instruction; // 5. Execute task on temp clone - const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir, options); + const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, false, projectDir, options); // 6. Auto-commit+push if successful if (taskSuccess) { diff --git a/src/commands/pipelineExecution.ts b/src/commands/pipelineExecution.ts index 259a8ab..e06954d 100644 --- a/src/commands/pipelineExecution.ts +++ b/src/commands/pipelineExecution.ts @@ -191,7 +191,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis ? { provider: options.provider, model: options.model } : undefined; - const taskSuccess = await executeTask(task, cwd, workflow, undefined, agentOverrides); + const taskSuccess = await executeTask(task, cwd, workflow, false, undefined, agentOverrides); if (!taskSuccess) { error(`Workflow '${workflow}' failed`); diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index 039e9e8..d24c117 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -2,7 +2,7 @@ * Task execution logic */ -import { loadWorkflow, loadGlobalConfig } from '../config/index.js'; +import { loadWorkflow, loadWorkflowFromPath, loadGlobalConfig } from '../config/index.js'; import { TaskRunner, type TaskInfo } from '../task/index.js'; import { createSharedClone } from '../task/clone.js'; import { autoCommitAndPush } from '../task/autoCommit.js'; @@ -31,28 +31,38 @@ export interface TaskExecutionOptions { * Execute a single task with workflow * @param task - Task content * @param cwd - Working directory (may be a clone path) - * @param workflowName - Workflow to use + * @param workflowIdentifier - Workflow name or path + * @param isPathBased - True if workflowIdentifier is a file path, false if it's a name * @param projectCwd - Project root (where .takt/ lives). Defaults to cwd. */ export async function executeTask( task: string, cwd: string, - workflowName: string = DEFAULT_WORKFLOW_NAME, + workflowIdentifier: string = DEFAULT_WORKFLOW_NAME, + isPathBased: boolean = false, projectCwd?: string, options?: TaskExecutionOptions ): Promise { - const workflowConfig = loadWorkflow(workflowName); + const effectiveProjectCwd = projectCwd || cwd; + + const workflowConfig = isPathBased + ? loadWorkflowFromPath(workflowIdentifier, effectiveProjectCwd) + : loadWorkflow(workflowIdentifier, effectiveProjectCwd); if (!workflowConfig) { - error(`Workflow "${workflowName}" not found.`); - info('Available workflows are in ~/.takt/workflows/'); - info('Use "takt switch" to select a workflow.'); + if (isPathBased) { + error(`Workflow file not found: ${workflowIdentifier}`); + } else { + error(`Workflow "${workflowIdentifier}" not found.`); + info('Available workflows are in ~/.takt/workflows/ or .takt/workflows/'); + info('Use "takt switch" to select a workflow.'); + } return false; } log.debug('Running workflow', { name: workflowConfig.name, - steps: workflowConfig.steps.map(s => s.name), + steps: workflowConfig.steps.map((s: { name: string }) => s.name), }); const globalConfig = loadGlobalConfig(); @@ -87,7 +97,7 @@ export async function executeAndCompleteTask( const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName); // cwd is always the project root; pass it as projectCwd so reports/sessions go there - const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd, options); + const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, false, cwd, options); const completedAt = new Date().toISOString(); if (taskSuccess && isWorktree) { diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 6066a29..b18e487 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -48,7 +48,7 @@ export async function switchWorkflow(cwd: string, workflowName?: string): Promis } // Check if workflow exists - const config = getBuiltinWorkflow(workflowName) || loadWorkflow(workflowName); + const config = getBuiltinWorkflow(workflowName) || loadWorkflow(workflowName, cwd); if (!config) { error(`Workflow "${workflowName}" not found`); diff --git a/src/config/loader.ts b/src/config/loader.ts index 63761b4..a858079 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -8,6 +8,7 @@ export { getBuiltinWorkflow, loadWorkflowFromFile, + loadWorkflowFromPath, loadWorkflow, loadAllWorkflows, listWorkflows, diff --git a/src/config/workflowLoader.ts b/src/config/workflowLoader.ts index c18faf6..8f8d35d 100644 --- a/src/config/workflowLoader.ts +++ b/src/config/workflowLoader.ts @@ -1,17 +1,20 @@ /** * Workflow configuration loader * - * Loads workflows with user → builtin fallback: - * 1. User workflows: ~/.takt/workflows/{name}.yaml - * 2. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml + * Loads workflows with the following priority: + * 1. Path-based input (absolute, relative, or home-dir) → load directly from file + * 2. Project-local workflows: .takt/workflows/{name}.yaml + * 3. User workflows: ~/.takt/workflows/{name}.yaml + * 4. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml */ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -import { join, dirname, basename } from 'node:path'; +import { join, dirname, basename, resolve, isAbsolute } from 'node:path'; +import { homedir } from 'node:os'; import { parse as parseYaml } from 'yaml'; import { WorkflowConfigRawSchema } from '../models/schemas.js'; import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../models/types.js'; -import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir } from './paths.js'; +import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from './paths.js'; import { getLanguage, getDisabledBuiltins } from './globalConfig.js'; /** Get builtin workflow by name */ @@ -241,18 +244,78 @@ export function loadWorkflowFromFile(filePath: string): WorkflowConfig { } /** - * Load workflow by name. - * Priority: user (~/.takt/workflows/) → builtin (resources/global/{lang}/workflows/) + * Resolve a path that may be relative, absolute, or home-directory-relative. + * @param pathInput Path to resolve + * @param basePath Base directory for relative paths (defaults to cwd) + * @returns Absolute resolved path */ -export function loadWorkflow(name: string): WorkflowConfig | null { - // 1. User workflow +function resolvePath(pathInput: string, basePath: string = process.cwd()): string { + // Home directory expansion + if (pathInput.startsWith('~')) { + const home = homedir(); + return resolve(home, pathInput.slice(1).replace(/^\//, '')); + } + + // Absolute path + if (isAbsolute(pathInput)) { + return pathInput; + } + + // Relative path + return resolve(basePath, pathInput); +} + +/** + * Load workflow from a file path (explicit path-based loading). + * Use this when user explicitly specifies a workflow file path via --workflow-path. + * + * @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~) + * @param basePath Base directory for resolving relative paths (default: cwd) + * @returns WorkflowConfig or null if file not found + */ +export function loadWorkflowFromPath( + filePath: string, + basePath: string = process.cwd() +): WorkflowConfig | null { + const resolvedPath = resolvePath(filePath, basePath); + + if (!existsSync(resolvedPath)) { + return null; + } + + return loadWorkflowFromFile(resolvedPath); +} + +/** + * Load workflow by name (name-based loading only, no path detection). + * + * Priority: + * 1. Project-local workflows → .takt/workflows/{name}.yaml + * 2. User workflows → ~/.takt/workflows/{name}.yaml + * 3. Builtin workflows → resources/global/{lang}/workflows/{name}.yaml + * + * @param name Workflow name (not a file path) + * @param projectCwd Project root directory (default: cwd, for project-local workflow resolution) + */ +export function loadWorkflow( + name: string, + projectCwd: string = process.cwd() +): WorkflowConfig | null { + // 1. Project-local workflow (.takt/workflows/{name}.yaml) + const projectWorkflowsDir = join(getProjectConfigDir(projectCwd), 'workflows'); + const projectWorkflowPath = join(projectWorkflowsDir, `${name}.yaml`); + if (existsSync(projectWorkflowPath)) { + return loadWorkflowFromFile(projectWorkflowPath); + } + + // 2. User workflow (~/.takt/workflows/{name}.yaml) const globalWorkflowsDir = getGlobalWorkflowsDir(); const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`); if (existsSync(workflowYamlPath)) { return loadWorkflowFromFile(workflowYamlPath); } - // 2. Builtin fallback + // 3. Builtin fallback return getBuiltinWorkflow(name); }