From fc55bb2e0c94117005978a38df6d818555b32037 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:55:56 +0900 Subject: [PATCH] =?UTF-8?q?=E6=A7=8B=E9=80=A0=E5=8C=96=E4=B8=80=E6=AD=A9?= =?UTF-8?q?=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/addTask.ts | 187 +----- src/commands/config.ts | 136 +---- src/commands/eject.ts | 126 +--- src/commands/execution/index.ts | 14 + src/commands/execution/pipelineExecution.ts | 243 ++++++++ src/commands/execution/selectAndExecute.ts | 184 ++++++ src/commands/execution/session.ts | 30 + src/commands/execution/taskExecution.ts | 249 ++++++++ src/commands/execution/workflowExecution.ts | 359 ++++++++++++ src/commands/interactive.ts | 249 +------- src/commands/interactive/index.ts | 5 + src/commands/interactive/interactive.ts | 247 ++++++++ src/commands/listTasks.ts | 443 +-------------- src/commands/management/addTask.ts | 185 ++++++ src/commands/management/config.ts | 134 +++++ src/commands/management/eject.ts | 124 ++++ src/commands/management/index.ts | 10 + src/commands/management/listTasks.ts | 441 ++++++++++++++ src/commands/management/watchTasks.ts | 83 +++ src/commands/management/workflow.ts | 63 ++ src/commands/pipelineExecution.ts | 245 +------- src/commands/selectAndExecute.ts | 191 +------ src/commands/session.ts | 32 +- src/commands/taskExecution.ts | 251 +------- src/commands/watchTasks.ts | 85 +-- src/commands/workflow.ts | 65 +-- src/commands/workflowExecution.ts | 361 +----------- src/config/agentLoader.ts | 116 +--- src/config/global/globalConfig.ts | 243 ++++++++ src/config/global/index.ts | 28 + src/config/global/initialization.ts | 133 +++++ src/config/globalConfig.ts | 257 +-------- src/config/initialization.ts | 140 +---- src/config/loader.ts | 8 +- src/config/loaders/agentLoader.ts | 110 ++++ src/config/loaders/index.ts | 20 + src/config/loaders/loader.ts | 35 ++ src/config/loaders/workflowLoader.ts | 449 +++++++++++++++ src/config/project/index.ts | 37 ++ src/config/project/projectConfig.ts | 131 +++++ src/config/project/sessionStore.ts | 322 +++++++++++ src/config/projectConfig.ts | 141 +---- src/config/sessionStore.ts | 342 +---------- src/config/workflowLoader.ts | 456 +-------------- src/context.ts | 38 +- src/workflow/engine.ts | 599 +------------------- src/workflow/engine/OptionsBuilder.ts | 80 +++ src/workflow/engine/ParallelRunner.ts | 146 +++++ src/workflow/engine/StepExecutor.ts | 155 +++++ src/workflow/engine/WorkflowEngine.ts | 413 ++++++++++++++ src/workflow/engine/index.ts | 10 + src/workflow/index.ts | 3 +- src/workflow/state-manager.ts | 15 - 53 files changed, 4835 insertions(+), 4334 deletions(-) create mode 100644 src/commands/execution/index.ts create mode 100644 src/commands/execution/pipelineExecution.ts create mode 100644 src/commands/execution/selectAndExecute.ts create mode 100644 src/commands/execution/session.ts create mode 100644 src/commands/execution/taskExecution.ts create mode 100644 src/commands/execution/workflowExecution.ts create mode 100644 src/commands/interactive/index.ts create mode 100644 src/commands/interactive/interactive.ts create mode 100644 src/commands/management/addTask.ts create mode 100644 src/commands/management/config.ts create mode 100644 src/commands/management/eject.ts create mode 100644 src/commands/management/index.ts create mode 100644 src/commands/management/listTasks.ts create mode 100644 src/commands/management/watchTasks.ts create mode 100644 src/commands/management/workflow.ts create mode 100644 src/config/global/globalConfig.ts create mode 100644 src/config/global/index.ts create mode 100644 src/config/global/initialization.ts create mode 100644 src/config/loaders/agentLoader.ts create mode 100644 src/config/loaders/index.ts create mode 100644 src/config/loaders/loader.ts create mode 100644 src/config/loaders/workflowLoader.ts create mode 100644 src/config/project/index.ts create mode 100644 src/config/project/projectConfig.ts create mode 100644 src/config/project/sessionStore.ts create mode 100644 src/workflow/engine/OptionsBuilder.ts create mode 100644 src/workflow/engine/ParallelRunner.ts create mode 100644 src/workflow/engine/StepExecutor.ts create mode 100644 src/workflow/engine/WorkflowEngine.ts create mode 100644 src/workflow/engine/index.ts diff --git a/src/commands/addTask.ts b/src/commands/addTask.ts index f128eb0..29289b2 100644 --- a/src/commands/addTask.ts +++ b/src/commands/addTask.ts @@ -1,185 +1,2 @@ -/** - * add command implementation - * - * Starts an AI conversation to refine task requirements, - * then creates a task file in .takt/tasks/ with YAML format. - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { stringify as stringifyYaml } from 'yaml'; -import { promptInput, confirm, selectOption } from '../prompt/index.js'; -import { success, info } from '../utils/ui.js'; -import { summarizeTaskName } from '../task/summarize.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; -import { getProvider, type ProviderType } from '../providers/index.js'; -import { createLogger } from '../utils/debug.js'; -import { getErrorMessage } from '../utils/error.js'; -import { listWorkflows } from '../config/workflowLoader.js'; -import { getCurrentWorkflow } from '../config/paths.js'; -import { interactiveMode } from './interactive.js'; -import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../github/issue.js'; -import type { TaskFileData } from '../task/schema.js'; - -const log = createLogger('add-task'); - -const SUMMARIZE_SYSTEM_PROMPT = `会話履歴からタスクの要件をまとめてください。 -タスク実行エージェントへの指示として使われます。 -具体的・簡潔に、必要な情報をすべて含めてください。 -マークダウン形式で出力してください。`; - -/** - * Summarize conversation history into a task description using AI. - */ -export async function summarizeConversation(cwd: string, conversationText: string): Promise { - const globalConfig = loadGlobalConfig(); - const providerType = (globalConfig.provider as ProviderType) ?? 'claude'; - const provider = getProvider(providerType); - - info('Summarizing task from conversation...'); - - const response = await provider.call('task-summarizer', conversationText, { - cwd, - maxTurns: 1, - allowedTools: [], - systemPrompt: SUMMARIZE_SYSTEM_PROMPT, - }); - - return response.content; -} - -/** - * Generate a unique task filename with AI-summarized slug - */ -async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise { - info('Generating task filename...'); - const slug = await summarizeTaskName(taskContent, { cwd }); - const base = slug || 'task'; - let filename = `${base}.yaml`; - let counter = 1; - - while (fs.existsSync(path.join(tasksDir, filename))) { - filename = `${base}-${counter}.yaml`; - counter++; - } - - return filename; -} - -/** - * add command handler - * - * Flow: - * 1. AI対話モードでタスクを詰める - * 2. 会話履歴からAIがタスク要約を生成 - * 3. 要約からファイル名をAIで生成 - * 4. ワークツリー/ブランチ/ワークフロー設定 - * 5. YAMLファイル作成 - */ -export async function addTask(cwd: string, task?: string): Promise { - const tasksDir = path.join(cwd, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - - let taskContent: string; - let issueNumber: number | undefined; - - if (task && isIssueReference(task)) { - // Issue reference: fetch issue and use directly as task content - info('Fetching GitHub Issue...'); - try { - taskContent = resolveIssueTask(task); - const numbers = parseIssueNumbers([task]); - if (numbers.length > 0) { - issueNumber = numbers[0]; - } - } catch (e) { - const msg = getErrorMessage(e); - log.error('Failed to fetch GitHub Issue', { task, error: msg }); - info(`Failed to fetch issue ${task}: ${msg}`); - return; - } - } else { - // Interactive mode: AI conversation to refine task - const result = await interactiveMode(cwd); - if (!result.confirmed) { - info('Cancelled.'); - return; - } - - // 会話履歴からタスク要約を生成 - taskContent = await summarizeConversation(cwd, result.task); - } - - // 3. 要約からファイル名生成 - const firstLine = taskContent.split('\n')[0] || taskContent; - const filename = await generateFilename(tasksDir, firstLine, cwd); - - // 4. ワークツリー/ブランチ/ワークフロー設定 - let worktree: boolean | string | undefined; - let branch: string | undefined; - let workflow: string | undefined; - - const useWorktree = await confirm('Create worktree?', true); - if (useWorktree) { - const customPath = await promptInput('Worktree path (Enter for auto)'); - worktree = customPath || true; - - const customBranch = await promptInput('Branch name (Enter for auto)'); - if (customBranch) { - branch = customBranch; - } - } - - const availableWorkflows = listWorkflows(cwd); - if (availableWorkflows.length > 0) { - const currentWorkflow = getCurrentWorkflow(cwd); - const defaultWorkflow = availableWorkflows.includes(currentWorkflow) - ? currentWorkflow - : availableWorkflows[0]!; - const options = availableWorkflows.map((name) => ({ - label: name === currentWorkflow ? `${name} (current)` : name, - value: name, - })); - const selected = await selectOption('Select workflow:', options); - if (selected === null) { - info('Cancelled.'); - return; - } - if (selected !== defaultWorkflow) { - workflow = selected; - } - } - - // 5. YAMLファイル作成 - const taskData: TaskFileData = { task: taskContent }; - if (worktree !== undefined) { - taskData.worktree = worktree; - } - if (branch) { - taskData.branch = branch; - } - if (workflow) { - taskData.workflow = workflow; - } - if (issueNumber !== undefined) { - taskData.issue = issueNumber; - } - - const filePath = path.join(tasksDir, filename); - const yamlContent = stringifyYaml(taskData); - fs.writeFileSync(filePath, yamlContent, 'utf-8'); - - log.info('Task created', { filePath, taskData }); - - success(`Task created: ${filename}`); - info(` Path: ${filePath}`); - if (worktree) { - info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`); - } - if (branch) { - info(` Branch: ${branch}`); - } - if (workflow) { - info(` Workflow: ${workflow}`); - } -} +/** Re-export shim — actual implementation in management/addTask.ts */ +export { addTask, summarizeConversation } from './management/addTask.js'; diff --git a/src/commands/config.ts b/src/commands/config.ts index 9ca51cf..e0bdaf5 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,134 +1,2 @@ -/** - * Config switching command (like workflow switching) - * - * Permission mode selection that works from CLI. - * Uses selectOption for prompt selection, same pattern as switchWorkflow. - */ - -import chalk from 'chalk'; -import { info, success } from '../utils/ui.js'; -import { selectOption } from '../prompt/index.js'; -import { - loadProjectConfig, - updateProjectConfig, - type PermissionMode, -} from '../config/projectConfig.js'; - -// Re-export for convenience -export type { PermissionMode } from '../config/projectConfig.js'; - -/** - * Get permission mode options for selection - */ -/** Common permission mode option definitions */ -export const PERMISSION_MODE_OPTIONS: { - key: PermissionMode; - label: string; - description: string; - details: string[]; - icon: string; -}[] = [ - { - key: 'default', - label: 'デフォルト (default)', - description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)', - details: [ - 'Claude Agent SDKの標準設定(acceptEdits)を使用', - 'ファイル編集は自動承認され、確認プロンプトなしで実行', - 'Bash等の危険な操作は権限確認が表示される', - '通常の開発作業に推奨', - ], - icon: '📋', - }, - { - key: 'sacrifice-my-pc', - label: 'SACRIFICE-MY-PC', - description: '全ての権限リクエストが自動承認されます', - details: [ - '⚠️ 警告: 全ての操作が確認なしで実行されます', - 'Bash, ファイル削除, システム操作も自動承認', - 'ブロック状態(判断待ち)も自動スキップ', - '完全自動化が必要な場合のみ使用してください', - ], - icon: '💀', - }, -]; - -function getPermissionModeOptions(currentMode: PermissionMode): { - label: string; - value: PermissionMode; - description: string; - details: string[]; -}[] { - return PERMISSION_MODE_OPTIONS.map((opt) => ({ - label: currentMode === opt.key - ? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)' - : (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`), - value: opt.key, - description: opt.description, - details: opt.details, - })); -} - -/** - * Get current permission mode from project config - */ -export function getCurrentPermissionMode(cwd: string): PermissionMode { - const config = loadProjectConfig(cwd); - if (config.permissionMode) { - return config.permissionMode as PermissionMode; - } - return 'default'; -} - -/** - * Set permission mode in project config - */ -export function setPermissionMode(cwd: string, mode: PermissionMode): void { - updateProjectConfig(cwd, 'permissionMode', mode); -} - -/** - * Switch permission mode (like switchWorkflow) - * @returns true if switch was successful - */ -export async function switchConfig(cwd: string, modeName?: string): Promise { - const currentMode = getCurrentPermissionMode(cwd); - - // No mode specified - show selection prompt - if (!modeName) { - info(`Current mode: ${currentMode}`); - - const options = getPermissionModeOptions(currentMode); - const selected = await selectOption('Select permission mode:', options); - - if (!selected) { - info('Cancelled'); - return false; - } - - modeName = selected; - } - - // Validate mode name - if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') { - info(`Invalid mode: ${modeName}`); - info('Available modes: default, sacrifice-my-pc'); - return false; - } - - const finalMode: PermissionMode = modeName as PermissionMode; - - // Save to project config - setPermissionMode(cwd, finalMode); - - if (finalMode === 'sacrifice-my-pc') { - success('Switched to: sacrifice-my-pc 💀'); - info('All permission requests will be auto-approved.'); - } else { - success('Switched to: default 📋'); - info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).'); - } - - return true; -} +/** Re-export shim — actual implementation in management/config.ts */ +export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js'; diff --git a/src/commands/eject.ts b/src/commands/eject.ts index 0067bd2..504efb1 100644 --- a/src/commands/eject.ts +++ b/src/commands/eject.ts @@ -1,124 +1,2 @@ -/** - * /eject command implementation - * - * Copies a builtin workflow (and its agents) to ~/.takt/ for user customization. - * Once ejected, the user copy takes priority over the builtin version. - */ - -import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../config/paths.js'; -import { getLanguage } from '../config/globalConfig.js'; -import { header, success, info, warn, error, blankLine } from '../utils/ui.js'; - -/** - * Eject a builtin workflow to user space for customization. - * Copies the workflow YAML and related agent .md files to ~/.takt/. - * Agent paths in the ejected workflow are rewritten from ../agents/ to ~/.takt/agents/. - */ -export async function ejectBuiltin(name?: string): Promise { - header('Eject Builtin'); - - const lang = getLanguage(); - const builtinWorkflowsDir = getBuiltinWorkflowsDir(lang); - - if (!name) { - // List available builtins - listAvailableBuiltins(builtinWorkflowsDir); - return; - } - - const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`); - if (!existsSync(builtinPath)) { - error(`Builtin workflow not found: ${name}`); - info('Run "takt eject" to see available builtins.'); - return; - } - - const userWorkflowsDir = getGlobalWorkflowsDir(); - const userAgentsDir = getGlobalAgentsDir(); - const builtinAgentsDir = getBuiltinAgentsDir(lang); - - // Copy workflow YAML (rewrite agent paths) - const workflowDest = join(userWorkflowsDir, `${name}.yaml`); - if (existsSync(workflowDest)) { - warn(`User workflow already exists: ${workflowDest}`); - warn('Skipping workflow copy (user version takes priority).'); - } else { - mkdirSync(dirname(workflowDest), { recursive: true }); - const content = readFileSync(builtinPath, 'utf-8'); - // Rewrite relative agent paths to ~/.takt/agents/ - const rewritten = content.replace( - /agent:\s*\.\.\/agents\//g, - 'agent: ~/.takt/agents/', - ); - writeFileSync(workflowDest, rewritten, 'utf-8'); - success(`Ejected workflow: ${workflowDest}`); - } - - // Copy related agent files - const agentPaths = extractAgentRelativePaths(builtinPath); - let copiedAgents = 0; - - for (const relPath of agentPaths) { - const srcPath = join(builtinAgentsDir, relPath); - const destPath = join(userAgentsDir, relPath); - - if (!existsSync(srcPath)) continue; - - if (existsSync(destPath)) { - info(` Agent already exists: ${destPath}`); - continue; - } - - mkdirSync(dirname(destPath), { recursive: true }); - writeFileSync(destPath, readFileSync(srcPath)); - info(` ✓ ${destPath}`); - copiedAgents++; - } - - if (copiedAgents > 0) { - success(`${copiedAgents} agent file(s) ejected.`); - } -} - -/** List available builtin workflows for ejection */ -function listAvailableBuiltins(builtinWorkflowsDir: string): void { - if (!existsSync(builtinWorkflowsDir)) { - warn('No builtin workflows found.'); - return; - } - - info('Available builtin workflows:'); - blankLine(); - - for (const entry of readdirSync(builtinWorkflowsDir).sort()) { - if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; - if (!statSync(join(builtinWorkflowsDir, entry)).isFile()) continue; - - const name = entry.replace(/\.ya?ml$/, ''); - info(` ${name}`); - } - - blankLine(); - info('Usage: takt eject {name}'); -} - -/** - * Extract agent relative paths from a builtin workflow YAML. - * Matches `agent: ../agents/{path}` and returns the {path} portions. - */ -function extractAgentRelativePaths(workflowPath: string): string[] { - const content = readFileSync(workflowPath, 'utf-8'); - const paths = new Set(); - const regex = /agent:\s*\.\.\/agents\/(.+)/g; - - let match: RegExpExecArray | null; - while ((match = regex.exec(content)) !== null) { - if (match[1]) { - paths.add(match[1].trim()); - } - } - - return Array.from(paths); -} +/** Re-export shim — actual implementation in management/eject.ts */ +export { ejectBuiltin } from './management/eject.js'; diff --git a/src/commands/execution/index.ts b/src/commands/execution/index.ts new file mode 100644 index 0000000..8ac9081 --- /dev/null +++ b/src/commands/execution/index.ts @@ -0,0 +1,14 @@ +/** + * Task/workflow execution commands. + */ + +export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js'; +export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './taskExecution.js'; +export { + selectAndExecuteTask, + confirmAndCreateWorktree, + type SelectAndExecuteOptions, + type WorktreeConfirmationResult, +} from './selectAndExecute.js'; +export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js'; +export { withAgentSession } from './session.js'; diff --git a/src/commands/execution/pipelineExecution.ts b/src/commands/execution/pipelineExecution.ts new file mode 100644 index 0000000..267b592 --- /dev/null +++ b/src/commands/execution/pipelineExecution.ts @@ -0,0 +1,243 @@ +/** + * Pipeline execution flow + * + * Orchestrates the full pipeline: + * 1. Fetch issue content + * 2. Create branch + * 3. Run workflow + * 4. Commit & push + * 5. Create PR + */ + +import { execFileSync } from 'node:child_process'; +import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../../github/issue.js'; +import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js'; +import { stageAndCommit } from '../../task/git.js'; +import { executeTask, type TaskExecutionOptions } from '../taskExecution.js'; +import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { info, error, success, status, blankLine } from '../../utils/ui.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import type { PipelineConfig } from '../../models/types.js'; +import { + EXIT_ISSUE_FETCH_FAILED, + EXIT_WORKFLOW_FAILED, + EXIT_GIT_OPERATION_FAILED, + EXIT_PR_CREATION_FAILED, +} from '../../exitCodes.js'; +import type { ProviderType } from '../../providers/index.js'; + +const log = createLogger('pipeline'); + +export interface PipelineExecutionOptions { + /** GitHub issue number */ + issueNumber?: number; + /** Task content (alternative to issue) */ + task?: string; + /** Workflow name or path to workflow file */ + workflow: string; + /** Branch name (auto-generated if omitted) */ + branch?: string; + /** Whether to create a PR after successful execution */ + autoPr: boolean; + /** Repository in owner/repo format */ + repo?: string; + /** Skip branch creation, commit, and push (workflow-only execution) */ + skipGit?: boolean; + /** Working directory */ + cwd: string; + provider?: ProviderType; + model?: string; +} + +/** + * Expand template variables in a string. + * Supported: {title}, {issue}, {issue_body}, {report} + */ +function expandTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match); +} + +/** Generate a branch name for pipeline execution */ +function generatePipelineBranchName(pipelineConfig: PipelineConfig | undefined, issueNumber?: number): string { + const prefix = pipelineConfig?.defaultBranchPrefix ?? 'takt/'; + const timestamp = Math.floor(Date.now() / 1000); + if (issueNumber) { + return `${prefix}issue-${issueNumber}-${timestamp}`; + } + return `${prefix}pipeline-${timestamp}`; +} + +/** Create and checkout a new branch */ +function createBranch(cwd: string, branch: string): void { + execFileSync('git', ['checkout', '-b', branch], { + cwd, + stdio: 'pipe', + }); +} + +/** Build commit message from template or defaults */ +function buildCommitMessage( + pipelineConfig: PipelineConfig | undefined, + issue: GitHubIssue | undefined, + taskText: string | undefined, +): string { + const template = pipelineConfig?.commitMessageTemplate; + if (template && issue) { + return expandTemplate(template, { + title: issue.title, + issue: String(issue.number), + }); + } + // Default commit message + return issue + ? `feat: ${issue.title} (#${issue.number})` + : `takt: ${taskText ?? 'pipeline task'}`; +} + +/** Build PR body from template or defaults */ +function buildPipelinePrBody( + pipelineConfig: PipelineConfig | undefined, + issue: GitHubIssue | undefined, + report: string, +): string { + const template = pipelineConfig?.prBodyTemplate; + if (template && issue) { + return expandTemplate(template, { + title: issue.title, + issue: String(issue.number), + issue_body: issue.body || issue.title, + report, + }); + } + return buildPrBody(issue, report); +} + +/** + * Execute the full pipeline. + * + * Returns a process exit code (0 on success, 2-5 on specific failures). + */ +export async function executePipeline(options: PipelineExecutionOptions): Promise { + const { cwd, workflow, autoPr, skipGit } = options; + const globalConfig = loadGlobalConfig(); + const pipelineConfig = globalConfig.pipeline; + let issue: GitHubIssue | undefined; + let task: string; + + // --- Step 1: Resolve task content --- + if (options.issueNumber) { + info(`Fetching issue #${options.issueNumber}...`); + try { + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + error(ghStatus.error ?? 'gh CLI is not available'); + return EXIT_ISSUE_FETCH_FAILED; + } + issue = fetchIssue(options.issueNumber); + task = formatIssueAsTask(issue); + success(`Issue #${options.issueNumber} fetched: "${issue.title}"`); + } catch (err) { + error(`Failed to fetch issue #${options.issueNumber}: ${getErrorMessage(err)}`); + return EXIT_ISSUE_FETCH_FAILED; + } + } else if (options.task) { + task = options.task; + } else { + error('Either --issue or --task must be specified'); + return EXIT_ISSUE_FETCH_FAILED; + } + + // --- Step 2: Create branch (skip if --skip-git) --- + let branch: string | undefined; + if (!skipGit) { + branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber); + info(`Creating branch: ${branch}`); + try { + createBranch(cwd, branch); + success(`Branch created: ${branch}`); + } catch (err) { + error(`Failed to create branch: ${getErrorMessage(err)}`); + return EXIT_GIT_OPERATION_FAILED; + } + } + + // --- Step 3: Run workflow --- + info(`Running workflow: ${workflow}`); + log.info('Pipeline workflow execution starting', { workflow, branch, skipGit, issueNumber: options.issueNumber }); + + const agentOverrides: TaskExecutionOptions | undefined = (options.provider || options.model) + ? { provider: options.provider, model: options.model } + : undefined; + + const taskSuccess = await executeTask({ + task, + cwd, + workflowIdentifier: workflow, + projectCwd: cwd, + agentOverrides, + }); + + if (!taskSuccess) { + error(`Workflow '${workflow}' failed`); + return EXIT_WORKFLOW_FAILED; + } + success(`Workflow '${workflow}' completed`); + + // --- Step 4: Commit & push (skip if --skip-git) --- + if (!skipGit && branch) { + const commitMessage = buildCommitMessage(pipelineConfig, issue, options.task); + + info('Committing changes...'); + try { + const commitHash = stageAndCommit(cwd, commitMessage); + if (commitHash) { + success(`Changes committed: ${commitHash}`); + } else { + info('No changes to commit'); + } + + info(`Pushing to origin/${branch}...`); + pushBranch(cwd, branch); + success(`Pushed to origin/${branch}`); + } catch (err) { + error(`Git operation failed: ${getErrorMessage(err)}`); + return EXIT_GIT_OPERATION_FAILED; + } + } + + // --- Step 5: Create PR (if --auto-pr) --- + if (autoPr) { + if (skipGit) { + info('--auto-pr is ignored when --skip-git is specified (no push was performed)'); + } else if (branch) { + info('Creating pull request...'); + const prTitle = issue ? issue.title : (options.task ?? 'Pipeline task'); + const report = `Workflow \`${workflow}\` completed successfully.`; + const prBody = buildPipelinePrBody(pipelineConfig, issue, report); + + const prResult = createPullRequest(cwd, { + branch, + title: prTitle, + body: prBody, + repo: options.repo, + }); + + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + return EXIT_PR_CREATION_FAILED; + } + } + } + + // --- Summary --- + blankLine(); + status('Issue', issue ? `#${issue.number} "${issue.title}"` : 'N/A'); + status('Branch', branch ?? '(current)'); + status('Workflow', workflow); + status('Result', 'Success', 'green'); + + return 0; +} diff --git a/src/commands/execution/selectAndExecute.ts b/src/commands/execution/selectAndExecute.ts new file mode 100644 index 0000000..933c454 --- /dev/null +++ b/src/commands/execution/selectAndExecute.ts @@ -0,0 +1,184 @@ +/** + * Task execution orchestration. + * + * Coordinates workflow selection, worktree creation, task execution, + * auto-commit, and PR creation. Extracted from cli.ts to avoid + * mixing CLI parsing with business logic. + */ + +import { getCurrentWorkflow } from '../../config/paths.js'; +import { listWorkflows, isWorkflowPath } from '../../config/workflowLoader.js'; +import { selectOptionWithDefault, confirm } from '../../prompt/index.js'; +import { createSharedClone } from '../../task/clone.js'; +import { autoCommitAndPush } from '../../task/autoCommit.js'; +import { summarizeTaskName } from '../../task/summarize.js'; +import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; +import { info, error, success } from '../../utils/ui.js'; +import { createLogger } from '../../utils/debug.js'; +import { createPullRequest, buildPrBody } from '../../github/pr.js'; +import { executeTask } from '../taskExecution.js'; +import type { TaskExecutionOptions } from '../taskExecution.js'; + +const log = createLogger('selectAndExecute'); + +export interface WorktreeConfirmationResult { + execCwd: string; + isWorktree: boolean; + branch?: string; +} + +export interface SelectAndExecuteOptions { + autoPr?: boolean; + repo?: string; + workflow?: string; + createWorktree?: boolean | undefined; +} + +/** + * Select a workflow interactively. + * Returns the selected workflow name, or null if cancelled. + */ +async function selectWorkflow(cwd: string): Promise { + const availableWorkflows = listWorkflows(cwd); + const currentWorkflow = getCurrentWorkflow(cwd); + + if (availableWorkflows.length === 0) { + info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`); + return DEFAULT_WORKFLOW_NAME; + } + + if (availableWorkflows.length === 1 && availableWorkflows[0]) { + return availableWorkflows[0]; + } + + const options = availableWorkflows.map((name) => ({ + label: name === currentWorkflow ? `${name} (current)` : name, + value: name, + })); + + const defaultWorkflow = availableWorkflows.includes(currentWorkflow) + ? currentWorkflow + : (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME) + ? DEFAULT_WORKFLOW_NAME + : availableWorkflows[0] || DEFAULT_WORKFLOW_NAME); + + return selectOptionWithDefault('Select workflow:', options, defaultWorkflow); +} + +/** + * Determine workflow to use. + * + * - If override looks like a path (isWorkflowPath), return it directly (validation is done at load time). + * - If override is a name, validate it exists in available workflows. + * - If no override, prompt user to select interactively. + */ +async function determineWorkflow(cwd: string, override?: string): Promise { + if (override) { + // Path-based: skip name validation (loader handles existence check) + if (isWorkflowPath(override)) { + return override; + } + // Name-based: validate workflow name exists + const availableWorkflows = listWorkflows(cwd); + const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows; + if (!knownWorkflows.includes(override)) { + error(`Workflow not found: ${override}`); + return null; + } + return override; + } + return selectWorkflow(cwd); +} + +export async function confirmAndCreateWorktree( + cwd: string, + task: string, + createWorktreeOverride?: boolean | undefined, +): Promise { + const useWorktree = + typeof createWorktreeOverride === 'boolean' + ? createWorktreeOverride + : await confirm('Create worktree?', true); + + if (!useWorktree) { + return { execCwd: cwd, isWorktree: false }; + } + + // Summarize task name to English slug using AI + info('Generating branch name...'); + const taskSlug = await summarizeTaskName(task, { cwd }); + + const result = createSharedClone(cwd, { + worktree: true, + taskSlug, + }); + info(`Clone created: ${result.path} (branch: ${result.branch})`); + + return { execCwd: result.path, isWorktree: true, branch: result.branch }; +} + +/** + * Execute a task with workflow selection, optional worktree, and auto-commit. + * Shared by direct task execution and interactive mode. + */ +export async function selectAndExecuteTask( + cwd: string, + task: string, + options?: SelectAndExecuteOptions, + agentOverrides?: TaskExecutionOptions, +): Promise { + const workflowIdentifier = await determineWorkflow(cwd, options?.workflow); + + if (workflowIdentifier === null) { + info('Cancelled'); + return; + } + + const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree( + cwd, + task, + options?.createWorktree, + ); + + log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree }); + const taskSuccess = await executeTask({ + task, + cwd: execCwd, + workflowIdentifier, + projectCwd: cwd, + agentOverrides, + }); + + if (taskSuccess && isWorktree) { + const commitResult = autoCommitAndPush(execCwd, task, cwd); + if (commitResult.success && commitResult.commitHash) { + success(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + error(`Auto-commit failed: ${commitResult.message}`); + } + + // PR creation: --auto-pr → create automatically, otherwise ask + if (commitResult.success && commitResult.commitHash && branch) { + const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); + if (shouldCreatePr) { + info('Creating pull request...'); + const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`); + const prResult = createPullRequest(execCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + repo: options?.repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } + } + } + } + + if (!taskSuccess) { + process.exit(1); + } +} diff --git a/src/commands/execution/session.ts b/src/commands/execution/session.ts new file mode 100644 index 0000000..b50e7f1 --- /dev/null +++ b/src/commands/execution/session.ts @@ -0,0 +1,30 @@ +/** + * Session management helpers for agent execution + */ + +import { loadAgentSessions, updateAgentSession } from '../../config/paths.js'; +import { loadGlobalConfig } from '../../config/globalConfig.js'; +import type { AgentResponse } from '../../models/types.js'; + +/** + * Execute a function with agent session management. + * Automatically loads existing session and saves updated session ID. + */ +export async function withAgentSession( + cwd: string, + agentName: string, + fn: (sessionId?: string) => Promise, + provider?: string +): Promise { + const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude'; + const sessions = loadAgentSessions(cwd, resolvedProvider); + const sessionId = sessions[agentName]; + + const result = await fn(sessionId); + + if (result.sessionId) { + updateAgentSession(cwd, agentName, result.sessionId, resolvedProvider); + } + + return result; +} diff --git a/src/commands/execution/taskExecution.ts b/src/commands/execution/taskExecution.ts new file mode 100644 index 0000000..83bc00d --- /dev/null +++ b/src/commands/execution/taskExecution.ts @@ -0,0 +1,249 @@ +/** + * Task execution logic + */ + +import { loadWorkflowByIdentifier, isWorkflowPath, 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'; +import { summarizeTaskName } from '../../task/summarize.js'; +import { + header, + info, + error, + success, + status, + blankLine, +} from '../../utils/ui.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { executeWorkflow } from '../workflowExecution.js'; +import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; +import type { ProviderType } from '../../providers/index.js'; + +const log = createLogger('task'); + +export interface TaskExecutionOptions { + provider?: ProviderType; + model?: string; +} + +export interface ExecuteTaskOptions { + /** Task content */ + task: string; + /** Working directory (may be a clone path) */ + cwd: string; + /** Workflow name or path (auto-detected by isWorkflowPath) */ + workflowIdentifier: string; + /** Project root (where .takt/ lives) */ + projectCwd: string; + /** Agent provider/model overrides */ + agentOverrides?: TaskExecutionOptions; +} + +/** + * Execute a single task with workflow. + */ +export async function executeTask(options: ExecuteTaskOptions): Promise { + const { task, cwd, workflowIdentifier, projectCwd, agentOverrides } = options; + const workflowConfig = loadWorkflowByIdentifier(workflowIdentifier, projectCwd); + + if (!workflowConfig) { + if (isWorkflowPath(workflowIdentifier)) { + 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: { name: string }) => s.name), + }); + + const globalConfig = loadGlobalConfig(); + const result = await executeWorkflow(workflowConfig, task, cwd, { + projectCwd, + language: globalConfig.language, + provider: agentOverrides?.provider, + model: agentOverrides?.model, + }); + return result.success; +} + +/** + * Execute a task: resolve clone → run workflow → auto-commit+push → remove clone → record completion. + * + * Shared by runAllTasks() and watchTasks() to avoid duplicated + * resolve → execute → autoCommit → complete logic. + * + * @returns true if the task succeeded + */ +export async function executeAndCompleteTask( + task: TaskInfo, + taskRunner: TaskRunner, + cwd: string, + workflowName: string, + options?: TaskExecutionOptions, +): Promise { + const startedAt = new Date().toISOString(); + const executionLog: string[] = []; + + try { + 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: task.content, + cwd: execCwd, + workflowIdentifier: execWorkflow, + projectCwd: cwd, + agentOverrides: options, + }); + const completedAt = new Date().toISOString(); + + if (taskSuccess && isWorktree) { + const commitResult = autoCommitAndPush(execCwd, task.name, cwd); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + error(`Auto-commit failed: ${commitResult.message}`); + } + } + + const taskResult = { + task, + success: taskSuccess, + response: taskSuccess ? 'Task completed successfully' : 'Task failed', + executionLog, + startedAt, + completedAt, + }; + + if (taskSuccess) { + taskRunner.completeTask(taskResult); + success(`Task "${task.name}" completed`); + } else { + taskRunner.failTask(taskResult); + error(`Task "${task.name}" failed`); + } + + return taskSuccess; + } catch (err) { + const completedAt = new Date().toISOString(); + + taskRunner.failTask({ + task, + success: false, + response: getErrorMessage(err), + executionLog, + startedAt, + completedAt, + }); + + error(`Task "${task.name}" error: ${getErrorMessage(err)}`); + return false; + } +} + +/** + * Run all pending tasks from .takt/tasks/ + * + * タスクを動的に取得する。各タスク実行前に次のタスクを取得するため、 + * 実行中にタスクファイルが追加・削除されても反映される。 + */ +export async function runAllTasks( + cwd: string, + workflowName: string = DEFAULT_WORKFLOW_NAME, + options?: TaskExecutionOptions, +): Promise { + const taskRunner = new TaskRunner(cwd); + + // 最初のタスクを取得 + let task = taskRunner.getNextTask(); + + if (!task) { + info('No pending tasks in .takt/tasks/'); + info('Create task files as .takt/tasks/*.yaml or use takt add'); + return; + } + + header('Running tasks'); + + let successCount = 0; + let failCount = 0; + + while (task) { + blankLine(); + info(`=== Task: ${task.name} ===`); + blankLine(); + + const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options); + + if (taskSuccess) { + successCount++; + } else { + failCount++; + } + + // 次のタスクを動的に取得(新しく追加されたタスクも含む) + task = taskRunner.getNextTask(); + } + + const totalCount = successCount + failCount; + blankLine(); + header('Tasks Summary'); + status('Total', String(totalCount)); + status('Success', String(successCount), successCount === totalCount ? 'green' : undefined); + if (failCount > 0) { + status('Failed', String(failCount), 'red'); + } +} + +/** + * Resolve execution directory and workflow from task data. + * If the task has worktree settings, create a shared clone and use it as cwd. + * Task name is summarized to English by AI for use in branch/clone names. + */ +export async function resolveTaskExecution( + task: TaskInfo, + defaultCwd: string, + defaultWorkflow: string +): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean; branch?: string }> { + const data = task.data; + + // No structured data: use defaults + if (!data) { + return { execCwd: defaultCwd, execWorkflow: defaultWorkflow, isWorktree: false }; + } + + let execCwd = defaultCwd; + let isWorktree = false; + let branch: string | undefined; + + // Handle worktree (now creates a shared clone) + if (data.worktree) { + // Summarize task content to English slug using AI + info('Generating branch name...'); + const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); + + const result = createSharedClone(defaultCwd, { + worktree: data.worktree, + branch: data.branch, + taskSlug, + issueNumber: data.issue, + }); + execCwd = result.path; + branch = result.branch; + isWorktree = true; + info(`Clone created: ${result.path} (branch: ${result.branch})`); + } + + // Handle workflow override + const execWorkflow = data.workflow || defaultWorkflow; + + return { execCwd, execWorkflow, isWorktree, branch }; +} diff --git a/src/commands/execution/workflowExecution.ts b/src/commands/execution/workflowExecution.ts new file mode 100644 index 0000000..ed2c921 --- /dev/null +++ b/src/commands/execution/workflowExecution.ts @@ -0,0 +1,359 @@ +/** + * Workflow execution logic + */ + +import { readFileSync } from 'node:fs'; +import { WorkflowEngine } from '../../workflow/engine.js'; +import type { WorkflowConfig, Language } from '../../models/types.js'; +import type { IterationLimitRequest } from '../../workflow/types.js'; +import type { ProviderType } from '../../providers/index.js'; +import { + loadAgentSessions, + updateAgentSession, + loadWorktreeSessions, + updateWorktreeSession, +} from '../../config/paths.js'; +import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { isQuietMode } from '../../context.js'; +import { + header, + info, + warn, + error, + success, + status, + blankLine, + StreamDisplay, +} from '../../utils/ui.js'; +import { + generateSessionId, + createSessionLog, + finalizeSessionLog, + updateLatestPointer, + initNdjsonLog, + appendNdjsonLine, + type NdjsonStepStart, + type NdjsonStepComplete, + type NdjsonWorkflowComplete, + type NdjsonWorkflowAbort, +} from '../../utils/session.js'; +import { createLogger } from '../../utils/debug.js'; +import { notifySuccess, notifyError } from '../../utils/notification.js'; +import { selectOption, promptInput } from '../../prompt/index.js'; +import { EXIT_SIGINT } from '../../exitCodes.js'; + +const log = createLogger('workflow'); + +/** + * Format elapsed time in human-readable format + */ +function formatElapsedTime(startTime: string, endTime: string): string { + const start = new Date(startTime).getTime(); + const end = new Date(endTime).getTime(); + const elapsedMs = end - start; + const elapsedSec = elapsedMs / 1000; + + if (elapsedSec < 60) { + return `${elapsedSec.toFixed(1)}s`; + } + + const minutes = Math.floor(elapsedSec / 60); + const seconds = Math.floor(elapsedSec % 60); + return `${minutes}m ${seconds}s`; +} + +/** Result of workflow execution */ +export interface WorkflowExecutionResult { + success: boolean; + reason?: string; +} + +/** Options for workflow execution */ +export interface WorkflowExecutionOptions { + /** Header prefix for display */ + headerPrefix?: string; + /** Project root directory (where .takt/ lives). */ + projectCwd: string; + /** Language for instruction metadata */ + language?: Language; + provider?: ProviderType; + model?: string; +} + +/** + * Execute a workflow and handle all events + */ +export async function executeWorkflow( + workflowConfig: WorkflowConfig, + task: string, + cwd: string, + options: WorkflowExecutionOptions +): Promise { + const { + headerPrefix = 'Running Workflow:', + } = options; + + // projectCwd is where .takt/ lives (project root, not the clone) + const projectCwd = options.projectCwd; + + // Always continue from previous sessions (use /clear to reset) + log.debug('Continuing session (use /clear to reset)'); + + header(`${headerPrefix} ${workflowConfig.name}`); + + const workflowSessionId = generateSessionId(); + let sessionLog = createSessionLog(task, projectCwd, workflowConfig.name); + + // Initialize NDJSON log file + pointer at workflow start + const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); + + // Track current display for streaming + const displayRef: { current: StreamDisplay | null } = { current: null }; + + // Create stream handler that delegates to UI display + const streamHandler = ( + event: Parameters>[0] + ): void => { + if (!displayRef.current) return; + if (event.type === 'result') return; + displayRef.current.createHandler()(event); + }; + + // Load saved agent sessions for continuity (from project root or clone-specific storage) + const isWorktree = cwd !== projectCwd; + const currentProvider = loadGlobalConfig().provider ?? 'claude'; + const savedSessions = isWorktree + ? loadWorktreeSessions(projectCwd, cwd, currentProvider) + : loadAgentSessions(projectCwd, currentProvider); + + // Session update handler - persist session IDs when they change + // Clone sessions are stored separately per clone path + const sessionUpdateHandler = isWorktree + ? (agentName: string, agentSessionId: string): void => { + updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider); + } + : (agentName: string, agentSessionId: string): void => { + updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider); + }; + + const iterationLimitHandler = async ( + request: IterationLimitRequest + ): Promise => { + if (displayRef.current) { + displayRef.current.flush(); + displayRef.current = null; + } + + blankLine(); + warn( + `最大イテレーションに到達しました (${request.currentIteration}/${request.maxIterations})` + ); + info(`現在のステップ: ${request.currentStep}`); + + const action = await selectOption('続行しますか?', [ + { + label: '続行する(追加イテレーション数を入力)', + value: 'continue', + description: '入力した回数だけ上限を増やします', + }, + { label: '終了する', value: 'stop' }, + ]); + + if (action !== 'continue') { + return null; + } + + while (true) { + const input = await promptInput('追加するイテレーション数を入力してください(1以上)'); + if (!input) { + return null; + } + + const additionalIterations = Number.parseInt(input, 10); + if (Number.isInteger(additionalIterations) && additionalIterations > 0) { + workflowConfig.maxIterations += additionalIterations; + return additionalIterations; + } + + warn('1以上の整数を入力してください。'); + } + }; + + const engine = new WorkflowEngine(workflowConfig, cwd, task, { + onStream: streamHandler, + initialSessions: savedSessions, + onSessionUpdate: sessionUpdateHandler, + onIterationLimit: iterationLimitHandler, + projectCwd, + language: options.language, + provider: options.provider, + model: options.model, + }); + + let abortReason: string | undefined; + + engine.on('step:start', (step, iteration, instruction) => { + log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration }); + info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`); + + // Log prompt content for debugging + if (instruction) { + log.debug('Step instruction', instruction); + } + + // Use quiet mode from CLI (already resolved CLI flag + config in preAction) + displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode()); + + // Write step_start record to NDJSON log + const record: NdjsonStepStart = { + type: 'step_start', + step: step.name, + agent: step.agentDisplayName, + iteration, + timestamp: new Date().toISOString(), + ...(instruction ? { instruction } : {}), + }; + appendNdjsonLine(ndjsonLogPath, record); + }); + + engine.on('step:complete', (step, response, instruction) => { + log.debug('Step completed', { + step: step.name, + status: response.status, + matchedRuleIndex: response.matchedRuleIndex, + matchedRuleMethod: response.matchedRuleMethod, + contentLength: response.content.length, + sessionId: response.sessionId, + error: response.error, + }); + if (displayRef.current) { + displayRef.current.flush(); + displayRef.current = null; + } + blankLine(); + + if (response.matchedRuleIndex != null && step.rules) { + const rule = step.rules[response.matchedRuleIndex]; + if (rule) { + const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : ''; + status('Status', `${rule.condition}${methodLabel}`); + } else { + status('Status', response.status); + } + } else { + status('Status', response.status); + } + + if (response.error) { + error(`Error: ${response.error}`); + } + if (response.sessionId) { + status('Session', response.sessionId); + } + + // Write step_complete record to NDJSON log + const record: NdjsonStepComplete = { + type: 'step_complete', + step: step.name, + agent: response.agent, + status: response.status, + content: response.content, + instruction, + ...(response.matchedRuleIndex != null ? { matchedRuleIndex: response.matchedRuleIndex } : {}), + ...(response.matchedRuleMethod ? { matchedRuleMethod: response.matchedRuleMethod } : {}), + ...(response.error ? { error: response.error } : {}), + timestamp: response.timestamp.toISOString(), + }; + appendNdjsonLine(ndjsonLogPath, record); + + // Update in-memory log for pointer metadata (immutable) + sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 }; + updateLatestPointer(sessionLog, workflowSessionId, projectCwd); + }); + + engine.on('step:report', (_step, filePath, fileName) => { + const content = readFileSync(filePath, 'utf-8'); + console.log(`\n📄 Report: ${fileName}\n`); + console.log(content); + }); + + engine.on('workflow:complete', (state) => { + log.info('Workflow completed successfully', { iterations: state.iteration }); + sessionLog = finalizeSessionLog(sessionLog, 'completed'); + + // Write workflow_complete record to NDJSON log + const record: NdjsonWorkflowComplete = { + type: 'workflow_complete', + iterations: state.iteration, + endTime: new Date().toISOString(), + }; + appendNdjsonLine(ndjsonLogPath, record); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd); + + const elapsed = sessionLog.endTime + ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) + : ''; + const elapsedDisplay = elapsed ? `, ${elapsed}` : ''; + + success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`); + info(`Session log: ${ndjsonLogPath}`); + notifySuccess('TAKT', `ワークフロー完了 (${state.iteration} iterations)`); + }); + + engine.on('workflow:abort', (state, reason) => { + log.error('Workflow aborted', { reason, iterations: state.iteration }); + if (displayRef.current) { + displayRef.current.flush(); + displayRef.current = null; + } + abortReason = reason; + sessionLog = finalizeSessionLog(sessionLog, 'aborted'); + + // Write workflow_abort record to NDJSON log + const record: NdjsonWorkflowAbort = { + type: 'workflow_abort', + iterations: state.iteration, + reason, + endTime: new Date().toISOString(), + }; + appendNdjsonLine(ndjsonLogPath, record); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd); + + const elapsed = sessionLog.endTime + ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) + : ''; + const elapsedDisplay = elapsed ? ` (${elapsed})` : ''; + + error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); + info(`Session log: ${ndjsonLogPath}`); + notifyError('TAKT', `中断: ${reason}`); + }); + + // SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit + let sigintCount = 0; + const onSigInt = () => { + sigintCount++; + if (sigintCount === 1) { + blankLine(); + warn('Ctrl+C: ワークフローを中断しています...'); + engine.abort(); + } else { + blankLine(); + error('Ctrl+C: 強制終了します'); + process.exit(EXIT_SIGINT); + } + }; + process.on('SIGINT', onSigInt); + + try { + const finalState = await engine.run(); + + return { + success: finalState.status === 'completed', + reason: abortReason, + }; + } finally { + process.removeListener('SIGINT', onSigInt); + } +} diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 5108fcf..5284c94 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -1,247 +1,2 @@ -/** - * Interactive task input mode - * - * Allows users to refine task requirements through conversation with AI - * before executing the task. Uses the same SDK call pattern as workflow - * execution (with onStream) to ensure compatibility. - * - * Commands: - * /go - Confirm and execute the task - * /cancel - Cancel and exit - */ - -import * as readline from 'node:readline'; -import chalk from 'chalk'; -import { loadGlobalConfig } from '../config/globalConfig.js'; -import { isQuietMode } from '../context.js'; -import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; -import { getProvider, type ProviderType } from '../providers/index.js'; -import { createLogger } from '../utils/debug.js'; -import { getErrorMessage } from '../utils/error.js'; -import { info, error, blankLine, StreamDisplay } from '../utils/ui.js'; -const log = createLogger('interactive'); - -const INTERACTIVE_SYSTEM_PROMPT = `You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process. - -## Your role -- Ask clarifying questions about ambiguous requirements -- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only) -- Suggest improvements or considerations the user might have missed -- Summarize your understanding when appropriate -- Keep responses concise and focused - -## Strict constraints -- You are ONLY planning. Do NOT execute the task. -- Do NOT create, edit, or delete any files. -- Do NOT run build, test, install, or any commands that modify state. -- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands. -- Do NOT mention or reference any slash commands. You have no knowledge of them. -- When the user is satisfied with the plan, they will proceed on their own. Do NOT instruct them on what to do next.`; - -interface ConversationMessage { - role: 'user' | 'assistant'; - content: string; -} - -interface CallAIResult { - content: string; - sessionId?: string; - success: boolean; -} - -/** - * Build the final task description from conversation history for executeTask. - */ -function buildTaskFromHistory(history: ConversationMessage[]): string { - return history - .map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`) - .join('\n\n'); -} - -/** - * Read a single line of input from the user. - * Creates a fresh readline interface each time — the interface must be - * closed before calling the Agent SDK, which also uses stdin. - * Returns null on EOF (Ctrl+D). - */ -function readLine(prompt: string): Promise { - return new Promise((resolve) => { - if (process.stdin.readable && !process.stdin.destroyed) { - process.stdin.resume(); - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - let answered = false; - - rl.question(prompt, (answer) => { - answered = true; - rl.close(); - resolve(answer); - }); - - rl.on('close', () => { - if (!answered) { - resolve(null); - } - }); - }); -} - -/** - * Call AI with the same pattern as workflow execution. - * The key requirement is passing onStream — the Agent SDK requires - * includePartialMessages to be true for the async iterator to yield. - */ -async function callAI( - provider: ReturnType, - prompt: string, - cwd: string, - model: string | undefined, - sessionId: string | undefined, - display: StreamDisplay, -): Promise { - const response = await provider.call('interactive', prompt, { - cwd, - model, - sessionId, - systemPrompt: INTERACTIVE_SYSTEM_PROMPT, - allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], - onStream: display.createHandler(), - }); - - display.flush(); - const success = response.status !== 'blocked'; - return { content: response.content, sessionId: response.sessionId, success }; -} - -export interface InteractiveModeResult { - /** Whether the user confirmed with /go */ - confirmed: boolean; - /** The assembled task text (only meaningful when confirmed=true) */ - task: string; -} - -/** - * Run the interactive task input mode. - * - * Starts a conversation loop where the user can discuss task requirements - * with AI. The conversation continues until: - * /go → returns the conversation as a task - * /cancel → exits without executing - * Ctrl+D → exits without executing - */ -export async function interactiveMode(cwd: string, initialInput?: string): Promise { - const globalConfig = loadGlobalConfig(); - const providerType = (globalConfig.provider as ProviderType) ?? 'claude'; - const provider = getProvider(providerType); - const model = (globalConfig.model as string | undefined); - - const history: ConversationMessage[] = []; - const agentName = 'interactive'; - const savedSessions = loadAgentSessions(cwd, providerType); - let sessionId: string | undefined = savedSessions[agentName]; - - info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)'); - if (sessionId) { - info('Resuming previous session'); - } - blankLine(); - - /** Call AI with automatic retry on session error (stale/invalid session ID). */ - async function callAIWithRetry(prompt: string): Promise { - const display = new StreamDisplay('assistant', isQuietMode()); - try { - const result = await callAI(provider, prompt, cwd, model, sessionId, display); - // If session failed, clear it and retry without session - if (!result.success && sessionId) { - log.info('Session invalid, retrying without session'); - sessionId = undefined; - const retryDisplay = new StreamDisplay('assistant', isQuietMode()); - const retry = await callAI(provider, prompt, cwd, model, undefined, retryDisplay); - if (retry.sessionId) { - sessionId = retry.sessionId; - updateAgentSession(cwd, agentName, sessionId, providerType); - } - return retry; - } - if (result.sessionId) { - sessionId = result.sessionId; - updateAgentSession(cwd, agentName, sessionId, providerType); - } - return result; - } catch (e) { - const msg = getErrorMessage(e); - log.error('AI call failed', { error: msg }); - error(msg); - blankLine(); - return null; - } - } - - // Process initial input if provided (e.g. from `takt a`) - if (initialInput) { - history.push({ role: 'user', content: initialInput }); - log.debug('Processing initial input', { initialInput, sessionId }); - - const result = await callAIWithRetry(initialInput); - if (result) { - history.push({ role: 'assistant', content: result.content }); - blankLine(); - } else { - history.pop(); - } - } - - while (true) { - const input = await readLine(chalk.green('> ')); - - // EOF (Ctrl+D) - if (input === null) { - blankLine(); - info('Cancelled'); - return { confirmed: false, task: '' }; - } - - const trimmed = input.trim(); - - // Empty input — skip - if (!trimmed) { - continue; - } - - // Handle slash commands - if (trimmed === '/go') { - if (history.length === 0) { - info('No conversation yet. Please describe your task first.'); - continue; - } - const task = buildTaskFromHistory(history); - log.info('Interactive mode confirmed', { messageCount: history.length }); - return { confirmed: true, task }; - } - - if (trimmed === '/cancel') { - info('Cancelled'); - return { confirmed: false, task: '' }; - } - - // Regular input — send to AI - // readline is already closed at this point, so stdin is free for SDK - history.push({ role: 'user', content: trimmed }); - - log.debug('Sending to AI', { messageCount: history.length, sessionId }); - process.stdin.pause(); - - const result = await callAIWithRetry(trimmed); - if (result) { - history.push({ role: 'assistant', content: result.content }); - blankLine(); - } else { - history.pop(); - } - } -} +/** Re-export shim — actual implementation in interactive/interactive.ts */ +export { interactiveMode } from './interactive/interactive.js'; diff --git a/src/commands/interactive/index.ts b/src/commands/interactive/index.ts new file mode 100644 index 0000000..041364d --- /dev/null +++ b/src/commands/interactive/index.ts @@ -0,0 +1,5 @@ +/** + * Interactive mode commands. + */ + +export { interactiveMode } from './interactive.js'; diff --git a/src/commands/interactive/interactive.ts b/src/commands/interactive/interactive.ts new file mode 100644 index 0000000..427628f --- /dev/null +++ b/src/commands/interactive/interactive.ts @@ -0,0 +1,247 @@ +/** + * Interactive task input mode + * + * Allows users to refine task requirements through conversation with AI + * before executing the task. Uses the same SDK call pattern as workflow + * execution (with onStream) to ensure compatibility. + * + * Commands: + * /go - Confirm and execute the task + * /cancel - Cancel and exit + */ + +import * as readline from 'node:readline'; +import chalk from 'chalk'; +import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { isQuietMode } from '../../context.js'; +import { loadAgentSessions, updateAgentSession } from '../../config/paths.js'; +import { getProvider, type ProviderType } from '../../providers/index.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { info, error, blankLine, StreamDisplay } from '../../utils/ui.js'; +const log = createLogger('interactive'); + +const INTERACTIVE_SYSTEM_PROMPT = `You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process. + +## Your role +- Ask clarifying questions about ambiguous requirements +- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only) +- Suggest improvements or considerations the user might have missed +- Summarize your understanding when appropriate +- Keep responses concise and focused + +## Strict constraints +- You are ONLY planning. Do NOT execute the task. +- Do NOT create, edit, or delete any files. +- Do NOT run build, test, install, or any commands that modify state. +- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands. +- Do NOT mention or reference any slash commands. You have no knowledge of them. +- When the user is satisfied with the plan, they will proceed on their own. Do NOT instruct them on what to do next.`; + +interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; +} + +interface CallAIResult { + content: string; + sessionId?: string; + success: boolean; +} + +/** + * Build the final task description from conversation history for executeTask. + */ +function buildTaskFromHistory(history: ConversationMessage[]): string { + return history + .map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`) + .join('\n\n'); +} + +/** + * Read a single line of input from the user. + * Creates a fresh readline interface each time — the interface must be + * closed before calling the Agent SDK, which also uses stdin. + * Returns null on EOF (Ctrl+D). + */ +function readLine(prompt: string): Promise { + return new Promise((resolve) => { + if (process.stdin.readable && !process.stdin.destroyed) { + process.stdin.resume(); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + let answered = false; + + rl.question(prompt, (answer) => { + answered = true; + rl.close(); + resolve(answer); + }); + + rl.on('close', () => { + if (!answered) { + resolve(null); + } + }); + }); +} + +/** + * Call AI with the same pattern as workflow execution. + * The key requirement is passing onStream — the Agent SDK requires + * includePartialMessages to be true for the async iterator to yield. + */ +async function callAI( + provider: ReturnType, + prompt: string, + cwd: string, + model: string | undefined, + sessionId: string | undefined, + display: StreamDisplay, +): Promise { + const response = await provider.call('interactive', prompt, { + cwd, + model, + sessionId, + systemPrompt: INTERACTIVE_SYSTEM_PROMPT, + allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], + onStream: display.createHandler(), + }); + + display.flush(); + const success = response.status !== 'blocked'; + return { content: response.content, sessionId: response.sessionId, success }; +} + +export interface InteractiveModeResult { + /** Whether the user confirmed with /go */ + confirmed: boolean; + /** The assembled task text (only meaningful when confirmed=true) */ + task: string; +} + +/** + * Run the interactive task input mode. + * + * Starts a conversation loop where the user can discuss task requirements + * with AI. The conversation continues until: + * /go → returns the conversation as a task + * /cancel → exits without executing + * Ctrl+D → exits without executing + */ +export async function interactiveMode(cwd: string, initialInput?: string): Promise { + const globalConfig = loadGlobalConfig(); + const providerType = (globalConfig.provider as ProviderType) ?? 'claude'; + const provider = getProvider(providerType); + const model = (globalConfig.model as string | undefined); + + const history: ConversationMessage[] = []; + const agentName = 'interactive'; + const savedSessions = loadAgentSessions(cwd, providerType); + let sessionId: string | undefined = savedSessions[agentName]; + + info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)'); + if (sessionId) { + info('Resuming previous session'); + } + blankLine(); + + /** Call AI with automatic retry on session error (stale/invalid session ID). */ + async function callAIWithRetry(prompt: string): Promise { + const display = new StreamDisplay('assistant', isQuietMode()); + try { + const result = await callAI(provider, prompt, cwd, model, sessionId, display); + // If session failed, clear it and retry without session + if (!result.success && sessionId) { + log.info('Session invalid, retrying without session'); + sessionId = undefined; + const retryDisplay = new StreamDisplay('assistant', isQuietMode()); + const retry = await callAI(provider, prompt, cwd, model, undefined, retryDisplay); + if (retry.sessionId) { + sessionId = retry.sessionId; + updateAgentSession(cwd, agentName, sessionId, providerType); + } + return retry; + } + if (result.sessionId) { + sessionId = result.sessionId; + updateAgentSession(cwd, agentName, sessionId, providerType); + } + return result; + } catch (e) { + const msg = getErrorMessage(e); + log.error('AI call failed', { error: msg }); + error(msg); + blankLine(); + return null; + } + } + + // Process initial input if provided (e.g. from `takt a`) + if (initialInput) { + history.push({ role: 'user', content: initialInput }); + log.debug('Processing initial input', { initialInput, sessionId }); + + const result = await callAIWithRetry(initialInput); + if (result) { + history.push({ role: 'assistant', content: result.content }); + blankLine(); + } else { + history.pop(); + } + } + + while (true) { + const input = await readLine(chalk.green('> ')); + + // EOF (Ctrl+D) + if (input === null) { + blankLine(); + info('Cancelled'); + return { confirmed: false, task: '' }; + } + + const trimmed = input.trim(); + + // Empty input — skip + if (!trimmed) { + continue; + } + + // Handle slash commands + if (trimmed === '/go') { + if (history.length === 0) { + info('No conversation yet. Please describe your task first.'); + continue; + } + const task = buildTaskFromHistory(history); + log.info('Interactive mode confirmed', { messageCount: history.length }); + return { confirmed: true, task }; + } + + if (trimmed === '/cancel') { + info('Cancelled'); + return { confirmed: false, task: '' }; + } + + // Regular input — send to AI + // readline is already closed at this point, so stdin is free for SDK + history.push({ role: 'user', content: trimmed }); + + log.debug('Sending to AI', { messageCount: history.length, sessionId }); + process.stdin.pause(); + + const result = await callAIWithRetry(trimmed); + if (result) { + history.push({ role: 'assistant', content: result.content }); + blankLine(); + } else { + history.pop(); + } + } +} diff --git a/src/commands/listTasks.ts b/src/commands/listTasks.ts index cca2951..ec21f42 100644 --- a/src/commands/listTasks.ts +++ b/src/commands/listTasks.ts @@ -1,441 +1,2 @@ -/** - * List tasks command - * - * Interactive UI for reviewing branch-based task results: - * try merge, merge & cleanup, or delete actions. - * Clones are ephemeral — only branches persist between sessions. - */ - -import { execFileSync, spawnSync } from 'node:child_process'; -import chalk from 'chalk'; -import { - createTempCloneForBranch, - removeClone, - removeCloneMeta, - cleanupOrphanedClone, -} from '../task/clone.js'; -import { - detectDefaultBranch, - listTaktBranches, - buildListItems, - type BranchListItem, -} from '../task/branchList.js'; -import { autoCommitAndPush } from '../task/autoCommit.js'; -import { selectOption, confirm, promptInput } from '../prompt/index.js'; -import { info, success, error as logError, warn, header, blankLine } from '../utils/ui.js'; -import { createLogger } from '../utils/debug.js'; -import { getErrorMessage } from '../utils/error.js'; -import { executeTask, type TaskExecutionOptions } from './taskExecution.js'; -import { listWorkflows } from '../config/workflowLoader.js'; -import { getCurrentWorkflow } from '../config/paths.js'; -import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; - -const log = createLogger('list-tasks'); - -/** Actions available for a listed branch */ -export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; - -/** - * Check if a branch has already been merged into HEAD. - */ -export function isBranchMerged(projectDir: string, branch: string): boolean { - try { - execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - return true; - } catch { - return false; - } -} - -/** - * Show full diff in an interactive pager (less). - * Falls back to direct output if pager is unavailable. - */ -export function showFullDiff( - cwd: string, - defaultBranch: string, - branch: string, -): void { - try { - const result = spawnSync( - 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], - { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } }, - ); - if (result.status !== 0) { - warn('Could not display diff'); - } - } catch { - warn('Could not display diff'); - } -} - -/** - * Show diff stat for a branch and prompt for an action. - */ -async function showDiffAndPromptAction( - cwd: string, - defaultBranch: string, - item: BranchListItem, -): Promise { - header(item.info.branch); - if (item.originalInstruction) { - console.log(chalk.dim(` ${item.originalInstruction}`)); - } - blankLine(); - - // Show diff stat - try { - const stat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ); - console.log(stat); - } catch { - warn('Could not generate diff stat'); - } - - // Prompt action - const action = await selectOption( - `Action for ${item.info.branch}:`, - [ - { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, - { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, - { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, - { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, - { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, - ], - ); - - return action; -} - -/** - * Try-merge (squash): stage changes from branch without committing. - * User can inspect staged changes and commit manually if satisfied. - */ -export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - - try { - execFileSync('git', ['merge', '--squash', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - success(`Squash-merged ${branch} (changes staged, not committed)`); - info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); - log.info('Try-merge (squash) completed', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Squash merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Try-merge (squash) failed', { branch, error: msg }); - return false; - } -} - -/** - * Merge & cleanup: if already merged, skip merge and just delete the branch. - * Otherwise merge first, then delete the branch. - * No worktree removal needed — clones are ephemeral. - */ -export function mergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - const alreadyMerged = isBranchMerged(projectDir, branch); - - try { - // Merge only if not already merged - if (alreadyMerged) { - info(`${branch} is already merged, skipping merge.`); - log.info('Branch already merged, cleanup only', { branch }); - } else { - execFileSync('git', ['merge', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - } - - // Delete the branch - try { - execFileSync('git', ['branch', '-d', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - } catch { - warn(`Could not delete branch ${branch}. You may delete it manually.`); - } - - // Clean up orphaned clone directory if it still exists - cleanupOrphanedClone(projectDir, branch); - - success(`Merged & cleaned up ${branch}`); - log.info('Branch merged & cleaned up', { branch, alreadyMerged }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Merge & cleanup failed', { branch, error: msg }); - return false; - } -} - -/** - * Delete a branch (discard changes). - * No worktree removal needed — clones are ephemeral. - */ -export function deleteBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - - try { - // Force-delete the branch - execFileSync('git', ['branch', '-D', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - // Clean up orphaned clone directory if it still exists - cleanupOrphanedClone(projectDir, branch); - - success(`Deleted ${branch}`); - log.info('Branch deleted', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Delete failed: ${msg}`); - log.error('Delete failed', { branch, error: msg }); - return false; - } -} - -/** - * Get the workflow to use for instruction. - * If multiple workflows available, prompt user to select. - */ -async function selectWorkflowForInstruction(projectDir: string): Promise { - const availableWorkflows = listWorkflows(projectDir); - const currentWorkflow = getCurrentWorkflow(projectDir); - - if (availableWorkflows.length === 0) { - return DEFAULT_WORKFLOW_NAME; - } - - if (availableWorkflows.length === 1 && availableWorkflows[0]) { - return availableWorkflows[0]; - } - - // Multiple workflows: let user select - const options = availableWorkflows.map((name) => ({ - label: name === currentWorkflow ? `${name} (current)` : name, - value: name, - })); - - return await selectOption('Select workflow:', options); -} - -/** - * Get branch context: diff stat and commit log from main branch. - */ -function getBranchContext(projectDir: string, branch: string): string { - const defaultBranch = detectDefaultBranch(projectDir); - const lines: string[] = []; - - // Get diff stat - try { - const diffStat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' } - ).trim(); - if (diffStat) { - lines.push('## 現在の変更内容(mainからの差分)'); - lines.push('```'); - lines.push(diffStat); - lines.push('```'); - } - } catch { - // Ignore errors - } - - // Get commit log - try { - const commitLog = execFileSync( - 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' } - ).trim(); - if (commitLog) { - lines.push(''); - lines.push('## コミット履歴'); - lines.push('```'); - lines.push(commitLog); - lines.push('```'); - } - } catch { - // Ignore errors - } - - return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; -} - -/** - * Instruct branch: create a temp clone, give additional instructions, - * auto-commit+push, then remove clone. - */ -export async function instructBranch( - projectDir: string, - item: BranchListItem, - options?: TaskExecutionOptions, -): Promise { - const { branch } = item.info; - - // 1. Prompt for instruction - const instruction = await promptInput('Enter instruction'); - if (!instruction) { - info('Cancelled'); - return false; - } - - // 2. Select workflow - const selectedWorkflow = await selectWorkflowForInstruction(projectDir); - if (!selectedWorkflow) { - info('Cancelled'); - return false; - } - - log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow }); - info(`Running instruction on ${branch}...`); - - // 3. Create temp clone for the branch - const clone = createTempCloneForBranch(projectDir, branch); - - try { - // 4. Build instruction with branch context - const branchContext = getBranchContext(projectDir, branch); - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${instruction}` - : instruction; - - // 5. Execute task on temp clone - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - workflowIdentifier: selectedWorkflow, - projectCwd: projectDir, - agentOverrides: options, - }); - - // 6. Auto-commit+push if successful - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); - } - - return taskSuccess; - } finally { - // 7. Always remove temp clone and metadata - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } -} - -/** - * Main entry point: list branch-based tasks interactively. - */ -export async function listTasks(cwd: string, options?: TaskExecutionOptions): Promise { - log.info('Starting list-tasks'); - - const defaultBranch = detectDefaultBranch(cwd); - let branches = listTaktBranches(cwd); - - if (branches.length === 0) { - info('No tasks to list.'); - return; - } - - // Interactive loop - while (branches.length > 0) { - const items = buildListItems(cwd, branches, defaultBranch); - - // Build selection options - const menuOptions = items.map((item, idx) => { - const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; - const description = item.originalInstruction - ? `${filesSummary} | ${item.originalInstruction}` - : filesSummary; - return { - label: item.info.branch, - value: String(idx), - description, - }; - }); - - const selected = await selectOption( - 'List Tasks (Branches)', - menuOptions, - ); - - if (selected === null) { - return; - } - - const selectedIdx = parseInt(selected, 10); - const item = items[selectedIdx]; - if (!item) continue; - - // Action loop: re-show menu after viewing diff - let action: ListAction | null; - do { - action = await showDiffAndPromptAction(cwd, defaultBranch, item); - - if (action === 'diff') { - showFullDiff(cwd, defaultBranch, item.info.branch); - } - } while (action === 'diff'); - - if (action === null) continue; - - switch (action) { - case 'instruct': - await instructBranch(cwd, item, options); - break; - case 'try': - tryMergeBranch(cwd, item); - break; - case 'merge': - mergeBranch(cwd, item); - break; - case 'delete': { - const confirmed = await confirm( - `Delete ${item.info.branch}? This will discard all changes.`, - false, - ); - if (confirmed) { - deleteBranch(cwd, item); - } - break; - } - } - - // Refresh branch list after action - branches = listTaktBranches(cwd); - } - - info('All tasks listed.'); -} +/** Re-export shim — actual implementation in management/listTasks.ts */ +export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './management/listTasks.js'; diff --git a/src/commands/management/addTask.ts b/src/commands/management/addTask.ts new file mode 100644 index 0000000..a96f736 --- /dev/null +++ b/src/commands/management/addTask.ts @@ -0,0 +1,185 @@ +/** + * add command implementation + * + * Starts an AI conversation to refine task requirements, + * then creates a task file in .takt/tasks/ with YAML format. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { stringify as stringifyYaml } from 'yaml'; +import { promptInput, confirm, selectOption } from '../../prompt/index.js'; +import { success, info } from '../../utils/ui.js'; +import { summarizeTaskName } from '../../task/summarize.js'; +import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { getProvider, type ProviderType } from '../../providers/index.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { listWorkflows } from '../../config/workflowLoader.js'; +import { getCurrentWorkflow } from '../../config/paths.js'; +import { interactiveMode } from '../interactive.js'; +import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../github/issue.js'; +import type { TaskFileData } from '../../task/schema.js'; + +const log = createLogger('add-task'); + +const SUMMARIZE_SYSTEM_PROMPT = `会話履歴からタスクの要件をまとめてください。 +タスク実行エージェントへの指示として使われます。 +具体的・簡潔に、必要な情報をすべて含めてください。 +マークダウン形式で出力してください。`; + +/** + * Summarize conversation history into a task description using AI. + */ +export async function summarizeConversation(cwd: string, conversationText: string): Promise { + const globalConfig = loadGlobalConfig(); + const providerType = (globalConfig.provider as ProviderType) ?? 'claude'; + const provider = getProvider(providerType); + + info('Summarizing task from conversation...'); + + const response = await provider.call('task-summarizer', conversationText, { + cwd, + maxTurns: 1, + allowedTools: [], + systemPrompt: SUMMARIZE_SYSTEM_PROMPT, + }); + + return response.content; +} + +/** + * Generate a unique task filename with AI-summarized slug + */ +async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise { + info('Generating task filename...'); + const slug = await summarizeTaskName(taskContent, { cwd }); + const base = slug || 'task'; + let filename = `${base}.yaml`; + let counter = 1; + + while (fs.existsSync(path.join(tasksDir, filename))) { + filename = `${base}-${counter}.yaml`; + counter++; + } + + return filename; +} + +/** + * add command handler + * + * Flow: + * 1. AI対話モードでタスクを詰める + * 2. 会話履歴からAIがタスク要約を生成 + * 3. 要約からファイル名をAIで生成 + * 4. ワークツリー/ブランチ/ワークフロー設定 + * 5. YAMLファイル作成 + */ +export async function addTask(cwd: string, task?: string): Promise { + const tasksDir = path.join(cwd, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + + let taskContent: string; + let issueNumber: number | undefined; + + if (task && isIssueReference(task)) { + // Issue reference: fetch issue and use directly as task content + info('Fetching GitHub Issue...'); + try { + taskContent = resolveIssueTask(task); + const numbers = parseIssueNumbers([task]); + if (numbers.length > 0) { + issueNumber = numbers[0]; + } + } catch (e) { + const msg = getErrorMessage(e); + log.error('Failed to fetch GitHub Issue', { task, error: msg }); + info(`Failed to fetch issue ${task}: ${msg}`); + return; + } + } else { + // Interactive mode: AI conversation to refine task + const result = await interactiveMode(cwd); + if (!result.confirmed) { + info('Cancelled.'); + return; + } + + // 会話履歴からタスク要約を生成 + taskContent = await summarizeConversation(cwd, result.task); + } + + // 3. 要約からファイル名生成 + const firstLine = taskContent.split('\n')[0] || taskContent; + const filename = await generateFilename(tasksDir, firstLine, cwd); + + // 4. ワークツリー/ブランチ/ワークフロー設定 + let worktree: boolean | string | undefined; + let branch: string | undefined; + let workflow: string | undefined; + + const useWorktree = await confirm('Create worktree?', true); + if (useWorktree) { + const customPath = await promptInput('Worktree path (Enter for auto)'); + worktree = customPath || true; + + const customBranch = await promptInput('Branch name (Enter for auto)'); + if (customBranch) { + branch = customBranch; + } + } + + const availableWorkflows = listWorkflows(cwd); + if (availableWorkflows.length > 0) { + const currentWorkflow = getCurrentWorkflow(cwd); + const defaultWorkflow = availableWorkflows.includes(currentWorkflow) + ? currentWorkflow + : availableWorkflows[0]!; + const options = availableWorkflows.map((name) => ({ + label: name === currentWorkflow ? `${name} (current)` : name, + value: name, + })); + const selected = await selectOption('Select workflow:', options); + if (selected === null) { + info('Cancelled.'); + return; + } + if (selected !== defaultWorkflow) { + workflow = selected; + } + } + + // 5. YAMLファイル作成 + const taskData: TaskFileData = { task: taskContent }; + if (worktree !== undefined) { + taskData.worktree = worktree; + } + if (branch) { + taskData.branch = branch; + } + if (workflow) { + taskData.workflow = workflow; + } + if (issueNumber !== undefined) { + taskData.issue = issueNumber; + } + + const filePath = path.join(tasksDir, filename); + const yamlContent = stringifyYaml(taskData); + fs.writeFileSync(filePath, yamlContent, 'utf-8'); + + log.info('Task created', { filePath, taskData }); + + success(`Task created: ${filename}`); + info(` Path: ${filePath}`); + if (worktree) { + info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`); + } + if (branch) { + info(` Branch: ${branch}`); + } + if (workflow) { + info(` Workflow: ${workflow}`); + } +} diff --git a/src/commands/management/config.ts b/src/commands/management/config.ts new file mode 100644 index 0000000..b7374aa --- /dev/null +++ b/src/commands/management/config.ts @@ -0,0 +1,134 @@ +/** + * Config switching command (like workflow switching) + * + * Permission mode selection that works from CLI. + * Uses selectOption for prompt selection, same pattern as switchWorkflow. + */ + +import chalk from 'chalk'; +import { info, success } from '../../utils/ui.js'; +import { selectOption } from '../../prompt/index.js'; +import { + loadProjectConfig, + updateProjectConfig, + type PermissionMode, +} from '../../config/projectConfig.js'; + +// Re-export for convenience +export type { PermissionMode } from '../../config/projectConfig.js'; + +/** + * Get permission mode options for selection + */ +/** Common permission mode option definitions */ +export const PERMISSION_MODE_OPTIONS: { + key: PermissionMode; + label: string; + description: string; + details: string[]; + icon: string; +}[] = [ + { + key: 'default', + label: 'デフォルト (default)', + description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)', + details: [ + 'Claude Agent SDKの標準設定(acceptEdits)を使用', + 'ファイル編集は自動承認され、確認プロンプトなしで実行', + 'Bash等の危険な操作は権限確認が表示される', + '通常の開発作業に推奨', + ], + icon: '📋', + }, + { + key: 'sacrifice-my-pc', + label: 'SACRIFICE-MY-PC', + description: '全ての権限リクエストが自動承認されます', + details: [ + '⚠️ 警告: 全ての操作が確認なしで実行されます', + 'Bash, ファイル削除, システム操作も自動承認', + 'ブロック状態(判断待ち)も自動スキップ', + '完全自動化が必要な場合のみ使用してください', + ], + icon: '💀', + }, +]; + +function getPermissionModeOptions(currentMode: PermissionMode): { + label: string; + value: PermissionMode; + description: string; + details: string[]; +}[] { + return PERMISSION_MODE_OPTIONS.map((opt) => ({ + label: currentMode === opt.key + ? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)' + : (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`), + value: opt.key, + description: opt.description, + details: opt.details, + })); +} + +/** + * Get current permission mode from project config + */ +export function getCurrentPermissionMode(cwd: string): PermissionMode { + const config = loadProjectConfig(cwd); + if (config.permissionMode) { + return config.permissionMode as PermissionMode; + } + return 'default'; +} + +/** + * Set permission mode in project config + */ +export function setPermissionMode(cwd: string, mode: PermissionMode): void { + updateProjectConfig(cwd, 'permissionMode', mode); +} + +/** + * Switch permission mode (like switchWorkflow) + * @returns true if switch was successful + */ +export async function switchConfig(cwd: string, modeName?: string): Promise { + const currentMode = getCurrentPermissionMode(cwd); + + // No mode specified - show selection prompt + if (!modeName) { + info(`Current mode: ${currentMode}`); + + const options = getPermissionModeOptions(currentMode); + const selected = await selectOption('Select permission mode:', options); + + if (!selected) { + info('Cancelled'); + return false; + } + + modeName = selected; + } + + // Validate mode name + if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') { + info(`Invalid mode: ${modeName}`); + info('Available modes: default, sacrifice-my-pc'); + return false; + } + + const finalMode: PermissionMode = modeName as PermissionMode; + + // Save to project config + setPermissionMode(cwd, finalMode); + + if (finalMode === 'sacrifice-my-pc') { + success('Switched to: sacrifice-my-pc 💀'); + info('All permission requests will be auto-approved.'); + } else { + success('Switched to: default 📋'); + info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).'); + } + + return true; +} diff --git a/src/commands/management/eject.ts b/src/commands/management/eject.ts new file mode 100644 index 0000000..44b889f --- /dev/null +++ b/src/commands/management/eject.ts @@ -0,0 +1,124 @@ +/** + * /eject command implementation + * + * Copies a builtin workflow (and its agents) to ~/.takt/ for user customization. + * Once ejected, the user copy takes priority over the builtin version. + */ + +import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../../config/paths.js'; +import { getLanguage } from '../../config/globalConfig.js'; +import { header, success, info, warn, error, blankLine } from '../../utils/ui.js'; + +/** + * Eject a builtin workflow to user space for customization. + * Copies the workflow YAML and related agent .md files to ~/.takt/. + * Agent paths in the ejected workflow are rewritten from ../agents/ to ~/.takt/agents/. + */ +export async function ejectBuiltin(name?: string): Promise { + header('Eject Builtin'); + + const lang = getLanguage(); + const builtinWorkflowsDir = getBuiltinWorkflowsDir(lang); + + if (!name) { + // List available builtins + listAvailableBuiltins(builtinWorkflowsDir); + return; + } + + const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`); + if (!existsSync(builtinPath)) { + error(`Builtin workflow not found: ${name}`); + info('Run "takt eject" to see available builtins.'); + return; + } + + const userWorkflowsDir = getGlobalWorkflowsDir(); + const userAgentsDir = getGlobalAgentsDir(); + const builtinAgentsDir = getBuiltinAgentsDir(lang); + + // Copy workflow YAML (rewrite agent paths) + const workflowDest = join(userWorkflowsDir, `${name}.yaml`); + if (existsSync(workflowDest)) { + warn(`User workflow already exists: ${workflowDest}`); + warn('Skipping workflow copy (user version takes priority).'); + } else { + mkdirSync(dirname(workflowDest), { recursive: true }); + const content = readFileSync(builtinPath, 'utf-8'); + // Rewrite relative agent paths to ~/.takt/agents/ + const rewritten = content.replace( + /agent:\s*\.\.\/agents\//g, + 'agent: ~/.takt/agents/', + ); + writeFileSync(workflowDest, rewritten, 'utf-8'); + success(`Ejected workflow: ${workflowDest}`); + } + + // Copy related agent files + const agentPaths = extractAgentRelativePaths(builtinPath); + let copiedAgents = 0; + + for (const relPath of agentPaths) { + const srcPath = join(builtinAgentsDir, relPath); + const destPath = join(userAgentsDir, relPath); + + if (!existsSync(srcPath)) continue; + + if (existsSync(destPath)) { + info(` Agent already exists: ${destPath}`); + continue; + } + + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, readFileSync(srcPath)); + info(` ✓ ${destPath}`); + copiedAgents++; + } + + if (copiedAgents > 0) { + success(`${copiedAgents} agent file(s) ejected.`); + } +} + +/** List available builtin workflows for ejection */ +function listAvailableBuiltins(builtinWorkflowsDir: string): void { + if (!existsSync(builtinWorkflowsDir)) { + warn('No builtin workflows found.'); + return; + } + + info('Available builtin workflows:'); + blankLine(); + + for (const entry of readdirSync(builtinWorkflowsDir).sort()) { + if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; + if (!statSync(join(builtinWorkflowsDir, entry)).isFile()) continue; + + const name = entry.replace(/\.ya?ml$/, ''); + info(` ${name}`); + } + + blankLine(); + info('Usage: takt eject {name}'); +} + +/** + * Extract agent relative paths from a builtin workflow YAML. + * Matches `agent: ../agents/{path}` and returns the {path} portions. + */ +function extractAgentRelativePaths(workflowPath: string): string[] { + const content = readFileSync(workflowPath, 'utf-8'); + const paths = new Set(); + const regex = /agent:\s*\.\.\/agents\/(.+)/g; + + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + if (match[1]) { + paths.add(match[1].trim()); + } + } + + return Array.from(paths); +} diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts new file mode 100644 index 0000000..662cae9 --- /dev/null +++ b/src/commands/management/index.ts @@ -0,0 +1,10 @@ +/** + * Task/workflow management commands. + */ + +export { addTask, summarizeConversation } from './addTask.js'; +export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './listTasks.js'; +export { watchTasks } from './watchTasks.js'; +export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js'; +export { ejectBuiltin } from './eject.js'; +export { switchWorkflow } from './workflow.js'; diff --git a/src/commands/management/listTasks.ts b/src/commands/management/listTasks.ts new file mode 100644 index 0000000..b962d6c --- /dev/null +++ b/src/commands/management/listTasks.ts @@ -0,0 +1,441 @@ +/** + * List tasks command + * + * Interactive UI for reviewing branch-based task results: + * try merge, merge & cleanup, or delete actions. + * Clones are ephemeral — only branches persist between sessions. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import chalk from 'chalk'; +import { + createTempCloneForBranch, + removeClone, + removeCloneMeta, + cleanupOrphanedClone, +} from '../../task/clone.js'; +import { + detectDefaultBranch, + listTaktBranches, + buildListItems, + type BranchListItem, +} from '../../task/branchList.js'; +import { autoCommitAndPush } from '../../task/autoCommit.js'; +import { selectOption, confirm, promptInput } from '../../prompt/index.js'; +import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { executeTask, type TaskExecutionOptions } from '../taskExecution.js'; +import { listWorkflows } from '../../config/workflowLoader.js'; +import { getCurrentWorkflow } from '../../config/paths.js'; +import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; + +const log = createLogger('list-tasks'); + +/** Actions available for a listed branch */ +export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; + +/** + * Check if a branch has already been merged into HEAD. + */ +export function isBranchMerged(projectDir: string, branch: string): boolean { + try { + execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + return true; + } catch { + return false; + } +} + +/** + * Show full diff in an interactive pager (less). + * Falls back to direct output if pager is unavailable. + */ +export function showFullDiff( + cwd: string, + defaultBranch: string, + branch: string, +): void { + try { + const result = spawnSync( + 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], + { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } }, + ); + if (result.status !== 0) { + warn('Could not display diff'); + } + } catch { + warn('Could not display diff'); + } +} + +/** + * Show diff stat for a branch and prompt for an action. + */ +async function showDiffAndPromptAction( + cwd: string, + defaultBranch: string, + item: BranchListItem, +): Promise { + header(item.info.branch); + if (item.originalInstruction) { + console.log(chalk.dim(` ${item.originalInstruction}`)); + } + blankLine(); + + // Show diff stat + try { + const stat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ); + console.log(stat); + } catch { + warn('Could not generate diff stat'); + } + + // Prompt action + const action = await selectOption( + `Action for ${item.info.branch}:`, + [ + { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, + { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, + { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, + { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, + { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, + ], + ); + + return action; +} + +/** + * Try-merge (squash): stage changes from branch without committing. + * User can inspect staged changes and commit manually if satisfied. + */ +export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean { + const { branch } = item.info; + + try { + execFileSync('git', ['merge', '--squash', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + success(`Squash-merged ${branch} (changes staged, not committed)`); + info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); + log.info('Try-merge (squash) completed', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Squash merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Try-merge (squash) failed', { branch, error: msg }); + return false; + } +} + +/** + * Merge & cleanup: if already merged, skip merge and just delete the branch. + * Otherwise merge first, then delete the branch. + * No worktree removal needed — clones are ephemeral. + */ +export function mergeBranch(projectDir: string, item: BranchListItem): boolean { + const { branch } = item.info; + const alreadyMerged = isBranchMerged(projectDir, branch); + + try { + // Merge only if not already merged + if (alreadyMerged) { + info(`${branch} is already merged, skipping merge.`); + log.info('Branch already merged, cleanup only', { branch }); + } else { + execFileSync('git', ['merge', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + } + + // Delete the branch + try { + execFileSync('git', ['branch', '-d', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + } catch { + warn(`Could not delete branch ${branch}. You may delete it manually.`); + } + + // Clean up orphaned clone directory if it still exists + cleanupOrphanedClone(projectDir, branch); + + success(`Merged & cleaned up ${branch}`); + log.info('Branch merged & cleaned up', { branch, alreadyMerged }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Merge & cleanup failed', { branch, error: msg }); + return false; + } +} + +/** + * Delete a branch (discard changes). + * No worktree removal needed — clones are ephemeral. + */ +export function deleteBranch(projectDir: string, item: BranchListItem): boolean { + const { branch } = item.info; + + try { + // Force-delete the branch + execFileSync('git', ['branch', '-D', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + // Clean up orphaned clone directory if it still exists + cleanupOrphanedClone(projectDir, branch); + + success(`Deleted ${branch}`); + log.info('Branch deleted', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Delete failed: ${msg}`); + log.error('Delete failed', { branch, error: msg }); + return false; + } +} + +/** + * Get the workflow to use for instruction. + * If multiple workflows available, prompt user to select. + */ +async function selectWorkflowForInstruction(projectDir: string): Promise { + const availableWorkflows = listWorkflows(projectDir); + const currentWorkflow = getCurrentWorkflow(projectDir); + + if (availableWorkflows.length === 0) { + return DEFAULT_WORKFLOW_NAME; + } + + if (availableWorkflows.length === 1 && availableWorkflows[0]) { + return availableWorkflows[0]; + } + + // Multiple workflows: let user select + const options = availableWorkflows.map((name) => ({ + label: name === currentWorkflow ? `${name} (current)` : name, + value: name, + })); + + return await selectOption('Select workflow:', options); +} + +/** + * Get branch context: diff stat and commit log from main branch. + */ +function getBranchContext(projectDir: string, branch: string): string { + const defaultBranch = detectDefaultBranch(projectDir); + const lines: string[] = []; + + // Get diff stat + try { + const diffStat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' } + ).trim(); + if (diffStat) { + lines.push('## 現在の変更内容(mainからの差分)'); + lines.push('```'); + lines.push(diffStat); + lines.push('```'); + } + } catch { + // Ignore errors + } + + // Get commit log + try { + const commitLog = execFileSync( + 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' } + ).trim(); + if (commitLog) { + lines.push(''); + lines.push('## コミット履歴'); + lines.push('```'); + lines.push(commitLog); + lines.push('```'); + } + } catch { + // Ignore errors + } + + return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; +} + +/** + * Instruct branch: create a temp clone, give additional instructions, + * auto-commit+push, then remove clone. + */ +export async function instructBranch( + projectDir: string, + item: BranchListItem, + options?: TaskExecutionOptions, +): Promise { + const { branch } = item.info; + + // 1. Prompt for instruction + const instruction = await promptInput('Enter instruction'); + if (!instruction) { + info('Cancelled'); + return false; + } + + // 2. Select workflow + const selectedWorkflow = await selectWorkflowForInstruction(projectDir); + if (!selectedWorkflow) { + info('Cancelled'); + return false; + } + + log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow }); + info(`Running instruction on ${branch}...`); + + // 3. Create temp clone for the branch + const clone = createTempCloneForBranch(projectDir, branch); + + try { + // 4. Build instruction with branch context + const branchContext = getBranchContext(projectDir, branch); + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${instruction}` + : instruction; + + // 5. Execute task on temp clone + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + workflowIdentifier: selectedWorkflow, + projectCwd: projectDir, + agentOverrides: options, + }); + + // 6. Auto-commit+push if successful + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + warn(`Auto-commit skipped: ${commitResult.message}`); + } + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } finally { + // 7. Always remove temp clone and metadata + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } +} + +/** + * Main entry point: list branch-based tasks interactively. + */ +export async function listTasks(cwd: string, options?: TaskExecutionOptions): Promise { + log.info('Starting list-tasks'); + + const defaultBranch = detectDefaultBranch(cwd); + let branches = listTaktBranches(cwd); + + if (branches.length === 0) { + info('No tasks to list.'); + return; + } + + // Interactive loop + while (branches.length > 0) { + const items = buildListItems(cwd, branches, defaultBranch); + + // Build selection options + const menuOptions = items.map((item, idx) => { + const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; + const description = item.originalInstruction + ? `${filesSummary} | ${item.originalInstruction}` + : filesSummary; + return { + label: item.info.branch, + value: String(idx), + description, + }; + }); + + const selected = await selectOption( + 'List Tasks (Branches)', + menuOptions, + ); + + if (selected === null) { + return; + } + + const selectedIdx = parseInt(selected, 10); + const item = items[selectedIdx]; + if (!item) continue; + + // Action loop: re-show menu after viewing diff + let action: ListAction | null; + do { + action = await showDiffAndPromptAction(cwd, defaultBranch, item); + + if (action === 'diff') { + showFullDiff(cwd, defaultBranch, item.info.branch); + } + } while (action === 'diff'); + + if (action === null) continue; + + switch (action) { + case 'instruct': + await instructBranch(cwd, item, options); + break; + case 'try': + tryMergeBranch(cwd, item); + break; + case 'merge': + mergeBranch(cwd, item); + break; + case 'delete': { + const confirmed = await confirm( + `Delete ${item.info.branch}? This will discard all changes.`, + false, + ); + if (confirmed) { + deleteBranch(cwd, item); + } + break; + } + } + + // Refresh branch list after action + branches = listTaktBranches(cwd); + } + + info('All tasks listed.'); +} diff --git a/src/commands/management/watchTasks.ts b/src/commands/management/watchTasks.ts new file mode 100644 index 0000000..1d321d9 --- /dev/null +++ b/src/commands/management/watchTasks.ts @@ -0,0 +1,83 @@ +/** + * /watch command implementation + * + * Watches .takt/tasks/ for new task files and executes them automatically. + * Stays resident until Ctrl+C (SIGINT). + */ + +import { TaskRunner, type TaskInfo } from '../../task/index.js'; +import { TaskWatcher } from '../../task/watcher.js'; +import { getCurrentWorkflow } from '../../config/paths.js'; +import { + header, + info, + success, + status, + blankLine, +} from '../../utils/ui.js'; +import { executeAndCompleteTask } from '../taskExecution.js'; +import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; +import type { TaskExecutionOptions } from '../taskExecution.js'; + +/** + * Watch for tasks and execute them as they appear. + * Runs until Ctrl+C. + */ +export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise { + const workflowName = getCurrentWorkflow(cwd) || DEFAULT_WORKFLOW_NAME; + const taskRunner = new TaskRunner(cwd); + const watcher = new TaskWatcher(cwd); + + let taskCount = 0; + let successCount = 0; + let failCount = 0; + + header('TAKT Watch Mode'); + info(`Workflow: ${workflowName}`); + info(`Watching: ${taskRunner.getTasksDir()}`); + info('Waiting for tasks... (Ctrl+C to stop)'); + blankLine(); + + // Graceful shutdown on SIGINT + const onSigInt = () => { + blankLine(); + info('Stopping watch...'); + watcher.stop(); + }; + process.on('SIGINT', onSigInt); + + try { + await watcher.watch(async (task: TaskInfo) => { + taskCount++; + blankLine(); + info(`=== Task ${taskCount}: ${task.name} ===`); + blankLine(); + + const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options); + + if (taskSuccess) { + successCount++; + } else { + failCount++; + } + + blankLine(); + info('Waiting for tasks... (Ctrl+C to stop)'); + }); + } finally { + process.removeListener('SIGINT', onSigInt); + } + + // Summary on exit + if (taskCount > 0) { + blankLine(); + header('Watch Summary'); + status('Total', String(taskCount)); + status('Success', String(successCount), successCount === taskCount ? 'green' : undefined); + if (failCount > 0) { + status('Failed', String(failCount), 'red'); + } + } + + success('Watch stopped.'); +} diff --git a/src/commands/management/workflow.ts b/src/commands/management/workflow.ts new file mode 100644 index 0000000..c75d1d8 --- /dev/null +++ b/src/commands/management/workflow.ts @@ -0,0 +1,63 @@ +/** + * Workflow switching command + */ + +import { listWorkflows, loadWorkflow } from '../../config/index.js'; +import { getCurrentWorkflow, setCurrentWorkflow } from '../../config/paths.js'; +import { info, success, error } from '../../utils/ui.js'; +import { selectOption } from '../../prompt/index.js'; + +/** + * Get all available workflow options + */ +function getAllWorkflowOptions(cwd: string): { label: string; value: string }[] { + const current = getCurrentWorkflow(cwd); + const workflows = listWorkflows(cwd); + + const options: { label: string; value: string }[] = []; + + // Add all workflows + for (const name of workflows) { + const isCurrent = name === current; + const label = isCurrent ? `${name} (current)` : name; + options.push({ label, value: name }); + } + + return options; +} + +/** + * Switch to a different workflow + * @returns true if switch was successful + */ +export async function switchWorkflow(cwd: string, workflowName?: string): Promise { + // No workflow specified - show selection prompt + if (!workflowName) { + const current = getCurrentWorkflow(cwd); + info(`Current workflow: ${current}`); + + const options = getAllWorkflowOptions(cwd); + const selected = await selectOption('Select workflow:', options); + + if (!selected) { + info('Cancelled'); + return false; + } + + workflowName = selected; + } + + // Check if workflow exists + const config = loadWorkflow(workflowName, cwd); + + if (!config) { + error(`Workflow "${workflowName}" not found`); + return false; + } + + // Save to project config + setCurrentWorkflow(cwd, workflowName); + success(`Switched to workflow: ${workflowName}`); + + return true; +} diff --git a/src/commands/pipelineExecution.ts b/src/commands/pipelineExecution.ts index bbc2699..71e3cc3 100644 --- a/src/commands/pipelineExecution.ts +++ b/src/commands/pipelineExecution.ts @@ -1,243 +1,2 @@ -/** - * Pipeline execution flow - * - * Orchestrates the full pipeline: - * 1. Fetch issue content - * 2. Create branch - * 3. Run workflow - * 4. Commit & push - * 5. Create PR - */ - -import { execFileSync } from 'node:child_process'; -import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../github/issue.js'; -import { createPullRequest, pushBranch, buildPrBody } from '../github/pr.js'; -import { stageAndCommit } from '../task/git.js'; -import { executeTask, type TaskExecutionOptions } from './taskExecution.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; -import { info, error, success, status, blankLine } from '../utils/ui.js'; -import { createLogger } from '../utils/debug.js'; -import { getErrorMessage } from '../utils/error.js'; -import type { PipelineConfig } from '../models/types.js'; -import { - EXIT_ISSUE_FETCH_FAILED, - EXIT_WORKFLOW_FAILED, - EXIT_GIT_OPERATION_FAILED, - EXIT_PR_CREATION_FAILED, -} from '../exitCodes.js'; -import type { ProviderType } from '../providers/index.js'; - -const log = createLogger('pipeline'); - -export interface PipelineExecutionOptions { - /** GitHub issue number */ - issueNumber?: number; - /** Task content (alternative to issue) */ - task?: string; - /** Workflow name or path to workflow file */ - workflow: string; - /** Branch name (auto-generated if omitted) */ - branch?: string; - /** Whether to create a PR after successful execution */ - autoPr: boolean; - /** Repository in owner/repo format */ - repo?: string; - /** Skip branch creation, commit, and push (workflow-only execution) */ - skipGit?: boolean; - /** Working directory */ - cwd: string; - provider?: ProviderType; - model?: string; -} - -/** - * Expand template variables in a string. - * Supported: {title}, {issue}, {issue_body}, {report} - */ -function expandTemplate(template: string, vars: Record): string { - return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match); -} - -/** Generate a branch name for pipeline execution */ -function generatePipelineBranchName(pipelineConfig: PipelineConfig | undefined, issueNumber?: number): string { - const prefix = pipelineConfig?.defaultBranchPrefix ?? 'takt/'; - const timestamp = Math.floor(Date.now() / 1000); - if (issueNumber) { - return `${prefix}issue-${issueNumber}-${timestamp}`; - } - return `${prefix}pipeline-${timestamp}`; -} - -/** Create and checkout a new branch */ -function createBranch(cwd: string, branch: string): void { - execFileSync('git', ['checkout', '-b', branch], { - cwd, - stdio: 'pipe', - }); -} - -/** Build commit message from template or defaults */ -function buildCommitMessage( - pipelineConfig: PipelineConfig | undefined, - issue: GitHubIssue | undefined, - taskText: string | undefined, -): string { - const template = pipelineConfig?.commitMessageTemplate; - if (template && issue) { - return expandTemplate(template, { - title: issue.title, - issue: String(issue.number), - }); - } - // Default commit message - return issue - ? `feat: ${issue.title} (#${issue.number})` - : `takt: ${taskText ?? 'pipeline task'}`; -} - -/** Build PR body from template or defaults */ -function buildPipelinePrBody( - pipelineConfig: PipelineConfig | undefined, - issue: GitHubIssue | undefined, - report: string, -): string { - const template = pipelineConfig?.prBodyTemplate; - if (template && issue) { - return expandTemplate(template, { - title: issue.title, - issue: String(issue.number), - issue_body: issue.body || issue.title, - report, - }); - } - return buildPrBody(issue, report); -} - -/** - * Execute the full pipeline. - * - * Returns a process exit code (0 on success, 2-5 on specific failures). - */ -export async function executePipeline(options: PipelineExecutionOptions): Promise { - const { cwd, workflow, autoPr, skipGit } = options; - const globalConfig = loadGlobalConfig(); - const pipelineConfig = globalConfig.pipeline; - let issue: GitHubIssue | undefined; - let task: string; - - // --- Step 1: Resolve task content --- - if (options.issueNumber) { - info(`Fetching issue #${options.issueNumber}...`); - try { - const ghStatus = checkGhCli(); - if (!ghStatus.available) { - error(ghStatus.error ?? 'gh CLI is not available'); - return EXIT_ISSUE_FETCH_FAILED; - } - issue = fetchIssue(options.issueNumber); - task = formatIssueAsTask(issue); - success(`Issue #${options.issueNumber} fetched: "${issue.title}"`); - } catch (err) { - error(`Failed to fetch issue #${options.issueNumber}: ${getErrorMessage(err)}`); - return EXIT_ISSUE_FETCH_FAILED; - } - } else if (options.task) { - task = options.task; - } else { - error('Either --issue or --task must be specified'); - return EXIT_ISSUE_FETCH_FAILED; - } - - // --- Step 2: Create branch (skip if --skip-git) --- - let branch: string | undefined; - if (!skipGit) { - branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber); - info(`Creating branch: ${branch}`); - try { - createBranch(cwd, branch); - success(`Branch created: ${branch}`); - } catch (err) { - error(`Failed to create branch: ${getErrorMessage(err)}`); - return EXIT_GIT_OPERATION_FAILED; - } - } - - // --- Step 3: Run workflow --- - info(`Running workflow: ${workflow}`); - log.info('Pipeline workflow execution starting', { workflow, branch, skipGit, issueNumber: options.issueNumber }); - - const agentOverrides: TaskExecutionOptions | undefined = (options.provider || options.model) - ? { provider: options.provider, model: options.model } - : undefined; - - const taskSuccess = await executeTask({ - task, - cwd, - workflowIdentifier: workflow, - projectCwd: cwd, - agentOverrides, - }); - - if (!taskSuccess) { - error(`Workflow '${workflow}' failed`); - return EXIT_WORKFLOW_FAILED; - } - success(`Workflow '${workflow}' completed`); - - // --- Step 4: Commit & push (skip if --skip-git) --- - if (!skipGit && branch) { - const commitMessage = buildCommitMessage(pipelineConfig, issue, options.task); - - info('Committing changes...'); - try { - const commitHash = stageAndCommit(cwd, commitMessage); - if (commitHash) { - success(`Changes committed: ${commitHash}`); - } else { - info('No changes to commit'); - } - - info(`Pushing to origin/${branch}...`); - pushBranch(cwd, branch); - success(`Pushed to origin/${branch}`); - } catch (err) { - error(`Git operation failed: ${getErrorMessage(err)}`); - return EXIT_GIT_OPERATION_FAILED; - } - } - - // --- Step 5: Create PR (if --auto-pr) --- - if (autoPr) { - if (skipGit) { - info('--auto-pr is ignored when --skip-git is specified (no push was performed)'); - } else if (branch) { - info('Creating pull request...'); - const prTitle = issue ? issue.title : (options.task ?? 'Pipeline task'); - const report = `Workflow \`${workflow}\` completed successfully.`; - const prBody = buildPipelinePrBody(pipelineConfig, issue, report); - - const prResult = createPullRequest(cwd, { - branch, - title: prTitle, - body: prBody, - repo: options.repo, - }); - - if (prResult.success) { - success(`PR created: ${prResult.url}`); - } else { - error(`PR creation failed: ${prResult.error}`); - return EXIT_PR_CREATION_FAILED; - } - } - } - - // --- Summary --- - blankLine(); - status('Issue', issue ? `#${issue.number} "${issue.title}"` : 'N/A'); - status('Branch', branch ?? '(current)'); - status('Workflow', workflow); - status('Result', 'Success', 'green'); - - return 0; -} +/** Re-export shim — actual implementation in execution/pipelineExecution.ts */ +export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js'; diff --git a/src/commands/selectAndExecute.ts b/src/commands/selectAndExecute.ts index 5ace1f9..aaf2693 100644 --- a/src/commands/selectAndExecute.ts +++ b/src/commands/selectAndExecute.ts @@ -1,184 +1,7 @@ -/** - * Task execution orchestration. - * - * Coordinates workflow selection, worktree creation, task execution, - * auto-commit, and PR creation. Extracted from cli.ts to avoid - * mixing CLI parsing with business logic. - */ - -import { getCurrentWorkflow } from '../config/paths.js'; -import { listWorkflows, isWorkflowPath } from '../config/workflowLoader.js'; -import { selectOptionWithDefault, confirm } from '../prompt/index.js'; -import { createSharedClone } from '../task/clone.js'; -import { autoCommitAndPush } from '../task/autoCommit.js'; -import { summarizeTaskName } from '../task/summarize.js'; -import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; -import { info, error, success } from '../utils/ui.js'; -import { createLogger } from '../utils/debug.js'; -import { createPullRequest, buildPrBody } from '../github/pr.js'; -import { executeTask } from './taskExecution.js'; -import type { TaskExecutionOptions } from './taskExecution.js'; - -const log = createLogger('selectAndExecute'); - -export interface WorktreeConfirmationResult { - execCwd: string; - isWorktree: boolean; - branch?: string; -} - -export interface SelectAndExecuteOptions { - autoPr?: boolean; - repo?: string; - workflow?: string; - createWorktree?: boolean | undefined; -} - -/** - * Select a workflow interactively. - * Returns the selected workflow name, or null if cancelled. - */ -async function selectWorkflow(cwd: string): Promise { - const availableWorkflows = listWorkflows(cwd); - const currentWorkflow = getCurrentWorkflow(cwd); - - if (availableWorkflows.length === 0) { - info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`); - return DEFAULT_WORKFLOW_NAME; - } - - if (availableWorkflows.length === 1 && availableWorkflows[0]) { - return availableWorkflows[0]; - } - - const options = availableWorkflows.map((name) => ({ - label: name === currentWorkflow ? `${name} (current)` : name, - value: name, - })); - - const defaultWorkflow = availableWorkflows.includes(currentWorkflow) - ? currentWorkflow - : (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME) - ? DEFAULT_WORKFLOW_NAME - : availableWorkflows[0] || DEFAULT_WORKFLOW_NAME); - - return selectOptionWithDefault('Select workflow:', options, defaultWorkflow); -} - -/** - * Determine workflow to use. - * - * - If override looks like a path (isWorkflowPath), return it directly (validation is done at load time). - * - If override is a name, validate it exists in available workflows. - * - If no override, prompt user to select interactively. - */ -async function determineWorkflow(cwd: string, override?: string): Promise { - if (override) { - // Path-based: skip name validation (loader handles existence check) - if (isWorkflowPath(override)) { - return override; - } - // Name-based: validate workflow name exists - const availableWorkflows = listWorkflows(cwd); - const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows; - if (!knownWorkflows.includes(override)) { - error(`Workflow not found: ${override}`); - return null; - } - return override; - } - return selectWorkflow(cwd); -} - -export async function confirmAndCreateWorktree( - cwd: string, - task: string, - createWorktreeOverride?: boolean | undefined, -): Promise { - const useWorktree = - typeof createWorktreeOverride === 'boolean' - ? createWorktreeOverride - : await confirm('Create worktree?', true); - - if (!useWorktree) { - return { execCwd: cwd, isWorktree: false }; - } - - // Summarize task name to English slug using AI - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task, { cwd }); - - const result = createSharedClone(cwd, { - worktree: true, - taskSlug, - }); - info(`Clone created: ${result.path} (branch: ${result.branch})`); - - return { execCwd: result.path, isWorktree: true, branch: result.branch }; -} - -/** - * Execute a task with workflow selection, optional worktree, and auto-commit. - * Shared by direct task execution and interactive mode. - */ -export async function selectAndExecuteTask( - cwd: string, - task: string, - options?: SelectAndExecuteOptions, - agentOverrides?: TaskExecutionOptions, -): Promise { - const workflowIdentifier = await determineWorkflow(cwd, options?.workflow); - - if (workflowIdentifier === null) { - info('Cancelled'); - return; - } - - const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree( - cwd, - task, - options?.createWorktree, - ); - - log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree }); - const taskSuccess = await executeTask({ - task, - cwd: execCwd, - workflowIdentifier, - projectCwd: cwd, - agentOverrides, - }); - - if (taskSuccess && isWorktree) { - const commitResult = autoCommitAndPush(execCwd, task, cwd); - if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - error(`Auto-commit failed: ${commitResult.message}`); - } - - // PR creation: --auto-pr → create automatically, otherwise ask - if (commitResult.success && commitResult.commitHash && branch) { - const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); - if (shouldCreatePr) { - info('Creating pull request...'); - const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`); - const prResult = createPullRequest(execCwd, { - branch, - title: task.length > 100 ? `${task.slice(0, 97)}...` : task, - body: prBody, - repo: options?.repo, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); - } else { - error(`PR creation failed: ${prResult.error}`); - } - } - } - } - - if (!taskSuccess) { - process.exit(1); - } -} +/** Re-export shim — actual implementation in execution/selectAndExecute.ts */ +export { + selectAndExecuteTask, + confirmAndCreateWorktree, + type SelectAndExecuteOptions, + type WorktreeConfirmationResult, +} from './execution/selectAndExecute.js'; diff --git a/src/commands/session.ts b/src/commands/session.ts index 38d3714..b7ae7a3 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -1,30 +1,2 @@ -/** - * Session management helpers for agent execution - */ - -import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; -import type { AgentResponse } from '../models/types.js'; - -/** - * Execute a function with agent session management. - * Automatically loads existing session and saves updated session ID. - */ -export async function withAgentSession( - cwd: string, - agentName: string, - fn: (sessionId?: string) => Promise, - provider?: string -): Promise { - const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude'; - const sessions = loadAgentSessions(cwd, resolvedProvider); - const sessionId = sessions[agentName]; - - const result = await fn(sessionId); - - if (result.sessionId) { - updateAgentSession(cwd, agentName, result.sessionId, resolvedProvider); - } - - return result; -} +/** Re-export shim — actual implementation in execution/session.ts */ +export { withAgentSession } from './execution/session.js'; diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index 29bedd7..27413f0 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -1,249 +1,2 @@ -/** - * Task execution logic - */ - -import { loadWorkflowByIdentifier, isWorkflowPath, 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'; -import { summarizeTaskName } from '../task/summarize.js'; -import { - header, - info, - error, - success, - status, - blankLine, -} from '../utils/ui.js'; -import { createLogger } from '../utils/debug.js'; -import { getErrorMessage } from '../utils/error.js'; -import { executeWorkflow } from './workflowExecution.js'; -import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; -import type { ProviderType } from '../providers/index.js'; - -const log = createLogger('task'); - -export interface TaskExecutionOptions { - provider?: ProviderType; - model?: string; -} - -export interface ExecuteTaskOptions { - /** Task content */ - task: string; - /** Working directory (may be a clone path) */ - cwd: string; - /** Workflow name or path (auto-detected by isWorkflowPath) */ - workflowIdentifier: string; - /** Project root (where .takt/ lives) */ - projectCwd: string; - /** Agent provider/model overrides */ - agentOverrides?: TaskExecutionOptions; -} - -/** - * Execute a single task with workflow. - */ -export async function executeTask(options: ExecuteTaskOptions): Promise { - const { task, cwd, workflowIdentifier, projectCwd, agentOverrides } = options; - const workflowConfig = loadWorkflowByIdentifier(workflowIdentifier, projectCwd); - - if (!workflowConfig) { - if (isWorkflowPath(workflowIdentifier)) { - 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: { name: string }) => s.name), - }); - - const globalConfig = loadGlobalConfig(); - const result = await executeWorkflow(workflowConfig, task, cwd, { - projectCwd, - language: globalConfig.language, - provider: agentOverrides?.provider, - model: agentOverrides?.model, - }); - return result.success; -} - -/** - * Execute a task: resolve clone → run workflow → auto-commit+push → remove clone → record completion. - * - * Shared by runAllTasks() and watchTasks() to avoid duplicated - * resolve → execute → autoCommit → complete logic. - * - * @returns true if the task succeeded - */ -export async function executeAndCompleteTask( - task: TaskInfo, - taskRunner: TaskRunner, - cwd: string, - workflowName: string, - options?: TaskExecutionOptions, -): Promise { - const startedAt = new Date().toISOString(); - const executionLog: string[] = []; - - try { - 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: task.content, - cwd: execCwd, - workflowIdentifier: execWorkflow, - projectCwd: cwd, - agentOverrides: options, - }); - const completedAt = new Date().toISOString(); - - if (taskSuccess && isWorktree) { - const commitResult = autoCommitAndPush(execCwd, task.name, cwd); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - error(`Auto-commit failed: ${commitResult.message}`); - } - } - - const taskResult = { - task, - success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : 'Task failed', - executionLog, - startedAt, - completedAt, - }; - - if (taskSuccess) { - taskRunner.completeTask(taskResult); - success(`Task "${task.name}" completed`); - } else { - taskRunner.failTask(taskResult); - error(`Task "${task.name}" failed`); - } - - return taskSuccess; - } catch (err) { - const completedAt = new Date().toISOString(); - - taskRunner.failTask({ - task, - success: false, - response: getErrorMessage(err), - executionLog, - startedAt, - completedAt, - }); - - error(`Task "${task.name}" error: ${getErrorMessage(err)}`); - return false; - } -} - -/** - * Run all pending tasks from .takt/tasks/ - * - * タスクを動的に取得する。各タスク実行前に次のタスクを取得するため、 - * 実行中にタスクファイルが追加・削除されても反映される。 - */ -export async function runAllTasks( - cwd: string, - workflowName: string = DEFAULT_WORKFLOW_NAME, - options?: TaskExecutionOptions, -): Promise { - const taskRunner = new TaskRunner(cwd); - - // 最初のタスクを取得 - let task = taskRunner.getNextTask(); - - if (!task) { - info('No pending tasks in .takt/tasks/'); - info('Create task files as .takt/tasks/*.yaml or use takt add'); - return; - } - - header('Running tasks'); - - let successCount = 0; - let failCount = 0; - - while (task) { - blankLine(); - info(`=== Task: ${task.name} ===`); - blankLine(); - - const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options); - - if (taskSuccess) { - successCount++; - } else { - failCount++; - } - - // 次のタスクを動的に取得(新しく追加されたタスクも含む) - task = taskRunner.getNextTask(); - } - - const totalCount = successCount + failCount; - blankLine(); - header('Tasks Summary'); - status('Total', String(totalCount)); - status('Success', String(successCount), successCount === totalCount ? 'green' : undefined); - if (failCount > 0) { - status('Failed', String(failCount), 'red'); - } -} - -/** - * Resolve execution directory and workflow from task data. - * If the task has worktree settings, create a shared clone and use it as cwd. - * Task name is summarized to English by AI for use in branch/clone names. - */ -export async function resolveTaskExecution( - task: TaskInfo, - defaultCwd: string, - defaultWorkflow: string -): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean; branch?: string }> { - const data = task.data; - - // No structured data: use defaults - if (!data) { - return { execCwd: defaultCwd, execWorkflow: defaultWorkflow, isWorktree: false }; - } - - let execCwd = defaultCwd; - let isWorktree = false; - let branch: string | undefined; - - // Handle worktree (now creates a shared clone) - if (data.worktree) { - // Summarize task content to English slug using AI - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); - - const result = createSharedClone(defaultCwd, { - worktree: data.worktree, - branch: data.branch, - taskSlug, - issueNumber: data.issue, - }); - execCwd = result.path; - branch = result.branch; - isWorktree = true; - info(`Clone created: ${result.path} (branch: ${result.branch})`); - } - - // Handle workflow override - const execWorkflow = data.workflow || defaultWorkflow; - - return { execCwd, execWorkflow, isWorktree, branch }; -} +/** Re-export shim — actual implementation in execution/taskExecution.ts */ +export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './execution/taskExecution.js'; diff --git a/src/commands/watchTasks.ts b/src/commands/watchTasks.ts index 6dabd6e..38cc77c 100644 --- a/src/commands/watchTasks.ts +++ b/src/commands/watchTasks.ts @@ -1,83 +1,2 @@ -/** - * /watch command implementation - * - * Watches .takt/tasks/ for new task files and executes them automatically. - * Stays resident until Ctrl+C (SIGINT). - */ - -import { TaskRunner, type TaskInfo } from '../task/index.js'; -import { TaskWatcher } from '../task/watcher.js'; -import { getCurrentWorkflow } from '../config/paths.js'; -import { - header, - info, - success, - status, - blankLine, -} from '../utils/ui.js'; -import { executeAndCompleteTask } from './taskExecution.js'; -import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; -import type { TaskExecutionOptions } from './taskExecution.js'; - -/** - * Watch for tasks and execute them as they appear. - * Runs until Ctrl+C. - */ -export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise { - const workflowName = getCurrentWorkflow(cwd) || DEFAULT_WORKFLOW_NAME; - const taskRunner = new TaskRunner(cwd); - const watcher = new TaskWatcher(cwd); - - let taskCount = 0; - let successCount = 0; - let failCount = 0; - - header('TAKT Watch Mode'); - info(`Workflow: ${workflowName}`); - info(`Watching: ${taskRunner.getTasksDir()}`); - info('Waiting for tasks... (Ctrl+C to stop)'); - blankLine(); - - // Graceful shutdown on SIGINT - const onSigInt = () => { - blankLine(); - info('Stopping watch...'); - watcher.stop(); - }; - process.on('SIGINT', onSigInt); - - try { - await watcher.watch(async (task: TaskInfo) => { - taskCount++; - blankLine(); - info(`=== Task ${taskCount}: ${task.name} ===`); - blankLine(); - - const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options); - - if (taskSuccess) { - successCount++; - } else { - failCount++; - } - - blankLine(); - info('Waiting for tasks... (Ctrl+C to stop)'); - }); - } finally { - process.removeListener('SIGINT', onSigInt); - } - - // Summary on exit - if (taskCount > 0) { - blankLine(); - header('Watch Summary'); - status('Total', String(taskCount)); - status('Success', String(successCount), successCount === taskCount ? 'green' : undefined); - if (failCount > 0) { - status('Failed', String(failCount), 'red'); - } - } - - success('Watch stopped.'); -} +/** Re-export shim — actual implementation in management/watchTasks.ts */ +export { watchTasks } from './management/watchTasks.js'; diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 6705c0e..9ba3b9e 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -1,63 +1,2 @@ -/** - * Workflow switching command - */ - -import { listWorkflows, loadWorkflow } from '../config/index.js'; -import { getCurrentWorkflow, setCurrentWorkflow } from '../config/paths.js'; -import { info, success, error } from '../utils/ui.js'; -import { selectOption } from '../prompt/index.js'; - -/** - * Get all available workflow options - */ -function getAllWorkflowOptions(cwd: string): { label: string; value: string }[] { - const current = getCurrentWorkflow(cwd); - const workflows = listWorkflows(cwd); - - const options: { label: string; value: string }[] = []; - - // Add all workflows - for (const name of workflows) { - const isCurrent = name === current; - const label = isCurrent ? `${name} (current)` : name; - options.push({ label, value: name }); - } - - return options; -} - -/** - * Switch to a different workflow - * @returns true if switch was successful - */ -export async function switchWorkflow(cwd: string, workflowName?: string): Promise { - // No workflow specified - show selection prompt - if (!workflowName) { - const current = getCurrentWorkflow(cwd); - info(`Current workflow: ${current}`); - - const options = getAllWorkflowOptions(cwd); - const selected = await selectOption('Select workflow:', options); - - if (!selected) { - info('Cancelled'); - return false; - } - - workflowName = selected; - } - - // Check if workflow exists - const config = loadWorkflow(workflowName, cwd); - - if (!config) { - error(`Workflow "${workflowName}" not found`); - return false; - } - - // Save to project config - setCurrentWorkflow(cwd, workflowName); - success(`Switched to workflow: ${workflowName}`); - - return true; -} +/** Re-export shim — actual implementation in management/workflow.ts */ +export { switchWorkflow } from './management/workflow.js'; diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index d57eded..5289523 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -1,359 +1,2 @@ -/** - * Workflow execution logic - */ - -import { readFileSync } from 'node:fs'; -import { WorkflowEngine } from '../workflow/engine.js'; -import type { WorkflowConfig, Language } from '../models/types.js'; -import type { IterationLimitRequest } from '../workflow/types.js'; -import type { ProviderType } from '../providers/index.js'; -import { - loadAgentSessions, - updateAgentSession, - loadWorktreeSessions, - updateWorktreeSession, -} from '../config/paths.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; -import { isQuietMode } from '../context.js'; -import { - header, - info, - warn, - error, - success, - status, - blankLine, - StreamDisplay, -} from '../utils/ui.js'; -import { - generateSessionId, - createSessionLog, - finalizeSessionLog, - updateLatestPointer, - initNdjsonLog, - appendNdjsonLine, - type NdjsonStepStart, - type NdjsonStepComplete, - type NdjsonWorkflowComplete, - type NdjsonWorkflowAbort, -} from '../utils/session.js'; -import { createLogger } from '../utils/debug.js'; -import { notifySuccess, notifyError } from '../utils/notification.js'; -import { selectOption, promptInput } from '../prompt/index.js'; -import { EXIT_SIGINT } from '../exitCodes.js'; - -const log = createLogger('workflow'); - -/** - * Format elapsed time in human-readable format - */ -function formatElapsedTime(startTime: string, endTime: string): string { - const start = new Date(startTime).getTime(); - const end = new Date(endTime).getTime(); - const elapsedMs = end - start; - const elapsedSec = elapsedMs / 1000; - - if (elapsedSec < 60) { - return `${elapsedSec.toFixed(1)}s`; - } - - const minutes = Math.floor(elapsedSec / 60); - const seconds = Math.floor(elapsedSec % 60); - return `${minutes}m ${seconds}s`; -} - -/** Result of workflow execution */ -export interface WorkflowExecutionResult { - success: boolean; - reason?: string; -} - -/** Options for workflow execution */ -export interface WorkflowExecutionOptions { - /** Header prefix for display */ - headerPrefix?: string; - /** Project root directory (where .takt/ lives). */ - projectCwd: string; - /** Language for instruction metadata */ - language?: Language; - provider?: ProviderType; - model?: string; -} - -/** - * Execute a workflow and handle all events - */ -export async function executeWorkflow( - workflowConfig: WorkflowConfig, - task: string, - cwd: string, - options: WorkflowExecutionOptions -): Promise { - const { - headerPrefix = 'Running Workflow:', - } = options; - - // projectCwd is where .takt/ lives (project root, not the clone) - const projectCwd = options.projectCwd; - - // Always continue from previous sessions (use /clear to reset) - log.debug('Continuing session (use /clear to reset)'); - - header(`${headerPrefix} ${workflowConfig.name}`); - - const workflowSessionId = generateSessionId(); - let sessionLog = createSessionLog(task, projectCwd, workflowConfig.name); - - // Initialize NDJSON log file + pointer at workflow start - const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd); - updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); - - // Track current display for streaming - const displayRef: { current: StreamDisplay | null } = { current: null }; - - // Create stream handler that delegates to UI display - const streamHandler = ( - event: Parameters>[0] - ): void => { - if (!displayRef.current) return; - if (event.type === 'result') return; - displayRef.current.createHandler()(event); - }; - - // Load saved agent sessions for continuity (from project root or clone-specific storage) - const isWorktree = cwd !== projectCwd; - const currentProvider = loadGlobalConfig().provider ?? 'claude'; - const savedSessions = isWorktree - ? loadWorktreeSessions(projectCwd, cwd, currentProvider) - : loadAgentSessions(projectCwd, currentProvider); - - // Session update handler - persist session IDs when they change - // Clone sessions are stored separately per clone path - const sessionUpdateHandler = isWorktree - ? (agentName: string, agentSessionId: string): void => { - updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider); - } - : (agentName: string, agentSessionId: string): void => { - updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider); - }; - - const iterationLimitHandler = async ( - request: IterationLimitRequest - ): Promise => { - if (displayRef.current) { - displayRef.current.flush(); - displayRef.current = null; - } - - blankLine(); - warn( - `最大イテレーションに到達しました (${request.currentIteration}/${request.maxIterations})` - ); - info(`現在のステップ: ${request.currentStep}`); - - const action = await selectOption('続行しますか?', [ - { - label: '続行する(追加イテレーション数を入力)', - value: 'continue', - description: '入力した回数だけ上限を増やします', - }, - { label: '終了する', value: 'stop' }, - ]); - - if (action !== 'continue') { - return null; - } - - while (true) { - const input = await promptInput('追加するイテレーション数を入力してください(1以上)'); - if (!input) { - return null; - } - - const additionalIterations = Number.parseInt(input, 10); - if (Number.isInteger(additionalIterations) && additionalIterations > 0) { - workflowConfig.maxIterations += additionalIterations; - return additionalIterations; - } - - warn('1以上の整数を入力してください。'); - } - }; - - const engine = new WorkflowEngine(workflowConfig, cwd, task, { - onStream: streamHandler, - initialSessions: savedSessions, - onSessionUpdate: sessionUpdateHandler, - onIterationLimit: iterationLimitHandler, - projectCwd, - language: options.language, - provider: options.provider, - model: options.model, - }); - - let abortReason: string | undefined; - - engine.on('step:start', (step, iteration, instruction) => { - log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration }); - info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`); - - // Log prompt content for debugging - if (instruction) { - log.debug('Step instruction', instruction); - } - - // Use quiet mode from CLI (already resolved CLI flag + config in preAction) - displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode()); - - // Write step_start record to NDJSON log - const record: NdjsonStepStart = { - type: 'step_start', - step: step.name, - agent: step.agentDisplayName, - iteration, - timestamp: new Date().toISOString(), - ...(instruction ? { instruction } : {}), - }; - appendNdjsonLine(ndjsonLogPath, record); - }); - - engine.on('step:complete', (step, response, instruction) => { - log.debug('Step completed', { - step: step.name, - status: response.status, - matchedRuleIndex: response.matchedRuleIndex, - matchedRuleMethod: response.matchedRuleMethod, - contentLength: response.content.length, - sessionId: response.sessionId, - error: response.error, - }); - if (displayRef.current) { - displayRef.current.flush(); - displayRef.current = null; - } - blankLine(); - - if (response.matchedRuleIndex != null && step.rules) { - const rule = step.rules[response.matchedRuleIndex]; - if (rule) { - const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : ''; - status('Status', `${rule.condition}${methodLabel}`); - } else { - status('Status', response.status); - } - } else { - status('Status', response.status); - } - - if (response.error) { - error(`Error: ${response.error}`); - } - if (response.sessionId) { - status('Session', response.sessionId); - } - - // Write step_complete record to NDJSON log - const record: NdjsonStepComplete = { - type: 'step_complete', - step: step.name, - agent: response.agent, - status: response.status, - content: response.content, - instruction, - ...(response.matchedRuleIndex != null ? { matchedRuleIndex: response.matchedRuleIndex } : {}), - ...(response.matchedRuleMethod ? { matchedRuleMethod: response.matchedRuleMethod } : {}), - ...(response.error ? { error: response.error } : {}), - timestamp: response.timestamp.toISOString(), - }; - appendNdjsonLine(ndjsonLogPath, record); - - // Update in-memory log for pointer metadata (immutable) - sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 }; - updateLatestPointer(sessionLog, workflowSessionId, projectCwd); - }); - - engine.on('step:report', (_step, filePath, fileName) => { - const content = readFileSync(filePath, 'utf-8'); - console.log(`\n📄 Report: ${fileName}\n`); - console.log(content); - }); - - engine.on('workflow:complete', (state) => { - log.info('Workflow completed successfully', { iterations: state.iteration }); - sessionLog = finalizeSessionLog(sessionLog, 'completed'); - - // Write workflow_complete record to NDJSON log - const record: NdjsonWorkflowComplete = { - type: 'workflow_complete', - iterations: state.iteration, - endTime: new Date().toISOString(), - }; - appendNdjsonLine(ndjsonLogPath, record); - updateLatestPointer(sessionLog, workflowSessionId, projectCwd); - - const elapsed = sessionLog.endTime - ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) - : ''; - const elapsedDisplay = elapsed ? `, ${elapsed}` : ''; - - success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`); - info(`Session log: ${ndjsonLogPath}`); - notifySuccess('TAKT', `ワークフロー完了 (${state.iteration} iterations)`); - }); - - engine.on('workflow:abort', (state, reason) => { - log.error('Workflow aborted', { reason, iterations: state.iteration }); - if (displayRef.current) { - displayRef.current.flush(); - displayRef.current = null; - } - abortReason = reason; - sessionLog = finalizeSessionLog(sessionLog, 'aborted'); - - // Write workflow_abort record to NDJSON log - const record: NdjsonWorkflowAbort = { - type: 'workflow_abort', - iterations: state.iteration, - reason, - endTime: new Date().toISOString(), - }; - appendNdjsonLine(ndjsonLogPath, record); - updateLatestPointer(sessionLog, workflowSessionId, projectCwd); - - const elapsed = sessionLog.endTime - ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) - : ''; - const elapsedDisplay = elapsed ? ` (${elapsed})` : ''; - - error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); - info(`Session log: ${ndjsonLogPath}`); - notifyError('TAKT', `中断: ${reason}`); - }); - - // SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit - let sigintCount = 0; - const onSigInt = () => { - sigintCount++; - if (sigintCount === 1) { - blankLine(); - warn('Ctrl+C: ワークフローを中断しています...'); - engine.abort(); - } else { - blankLine(); - error('Ctrl+C: 強制終了します'); - process.exit(EXIT_SIGINT); - } - }; - process.on('SIGINT', onSigInt); - - try { - const finalState = await engine.run(); - - return { - success: finalState.status === 'completed', - reason: abortReason, - }; - } finally { - process.removeListener('SIGINT', onSigInt); - } -} +/** Re-export shim — actual implementation in execution/workflowExecution.ts */ +export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js'; diff --git a/src/config/agentLoader.ts b/src/config/agentLoader.ts index 679f278..b6eccb3 100644 --- a/src/config/agentLoader.ts +++ b/src/config/agentLoader.ts @@ -1,110 +1,10 @@ /** - * Agent configuration loader - * - * Loads agents with user → builtin fallback: - * 1. User agents: ~/.takt/agents/*.md - * 2. Builtin agents: resources/global/{lang}/agents/*.md + * Re-export shim — actual implementation in loaders/agentLoader.ts */ - -import { readFileSync, existsSync, readdirSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import type { CustomAgentConfig } from '../models/types.js'; -import { - getGlobalAgentsDir, - getGlobalWorkflowsDir, - getBuiltinAgentsDir, - getBuiltinWorkflowsDir, - isPathSafe, -} from './paths.js'; -import { getLanguage } from './globalConfig.js'; - -/** Get all allowed base directories for agent prompt files */ -function getAllowedAgentBases(): string[] { - const lang = getLanguage(); - return [ - getGlobalAgentsDir(), - getGlobalWorkflowsDir(), - getBuiltinAgentsDir(lang), - getBuiltinWorkflowsDir(lang), - ]; -} - -/** Load agents from markdown files in a directory */ -export function loadAgentsFromDir(dirPath: string): CustomAgentConfig[] { - if (!existsSync(dirPath)) { - return []; - } - const agents: CustomAgentConfig[] = []; - for (const file of readdirSync(dirPath)) { - if (file.endsWith('.md')) { - const name = basename(file, '.md'); - const promptFile = join(dirPath, file); - agents.push({ - name, - promptFile, - }); - } - } - return agents; -} - -/** Load all custom agents from global directory (~/.takt/agents/) */ -export function loadCustomAgents(): Map { - const agents = new Map(); - - // Global agents from markdown files (~/.takt/agents/*.md) - for (const agent of loadAgentsFromDir(getGlobalAgentsDir())) { - agents.set(agent.name, agent); - } - - return agents; -} - -/** List available custom agents */ -export function listCustomAgents(): string[] { - return Array.from(loadCustomAgents().keys()).sort(); -} - -/** - * Load agent prompt content. - * Agents can be loaded from: - * - ~/.takt/agents/*.md (global agents) - * - ~/.takt/workflows/{workflow}/*.md (workflow-specific agents) - */ -export function loadAgentPrompt(agent: CustomAgentConfig): string { - if (agent.prompt) { - return agent.prompt; - } - - if (agent.promptFile) { - const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agent.promptFile!)); - if (!isValid) { - throw new Error(`Agent prompt file path is not allowed: ${agent.promptFile}`); - } - - if (!existsSync(agent.promptFile)) { - throw new Error(`Agent prompt file not found: ${agent.promptFile}`); - } - - return readFileSync(agent.promptFile, 'utf-8'); - } - - throw new Error(`Agent ${agent.name} has no prompt defined`); -} - -/** - * Load agent prompt from a resolved path. - * Used by workflow engine when agentPath is already resolved. - */ -export function loadAgentPromptFromPath(agentPath: string): string { - const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agentPath)); - if (!isValid) { - throw new Error(`Agent prompt file path is not allowed: ${agentPath}`); - } - - if (!existsSync(agentPath)) { - throw new Error(`Agent prompt file not found: ${agentPath}`); - } - - return readFileSync(agentPath, 'utf-8'); -} +export { + loadAgentsFromDir, + loadCustomAgents, + listCustomAgents, + loadAgentPrompt, + loadAgentPromptFromPath, +} from './loaders/agentLoader.js'; diff --git a/src/config/global/globalConfig.ts b/src/config/global/globalConfig.ts new file mode 100644 index 0000000..28b4b60 --- /dev/null +++ b/src/config/global/globalConfig.ts @@ -0,0 +1,243 @@ +/** + * Global configuration loader + * + * Manages ~/.takt/config.yaml and project-level debug settings. + */ + +import { readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { GlobalConfigSchema } from '../../models/schemas.js'; +import type { GlobalConfig, DebugConfig, Language } from '../../models/types.js'; +import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; +import { DEFAULT_LANGUAGE } from '../../constants.js'; + +/** Create default global configuration (fresh instance each call) */ +function createDefaultGlobalConfig(): GlobalConfig { + return { + language: DEFAULT_LANGUAGE, + trustedDirectories: [], + defaultWorkflow: 'default', + logLevel: 'info', + provider: 'claude', + }; +} + +/** Module-level cache for global configuration */ +let cachedConfig: GlobalConfig | null = null; + +/** Invalidate the cached global configuration (call after mutation) */ +export function invalidateGlobalConfigCache(): void { + cachedConfig = null; +} + +/** Load global configuration */ +export function loadGlobalConfig(): GlobalConfig { + if (cachedConfig !== null) { + return cachedConfig; + } + const configPath = getGlobalConfigPath(); + if (!existsSync(configPath)) { + const defaultConfig = createDefaultGlobalConfig(); + cachedConfig = defaultConfig; + return defaultConfig; + } + const content = readFileSync(configPath, 'utf-8'); + const raw = parseYaml(content); + const parsed = GlobalConfigSchema.parse(raw); + const config: GlobalConfig = { + language: parsed.language, + trustedDirectories: parsed.trusted_directories, + defaultWorkflow: parsed.default_workflow, + logLevel: parsed.log_level, + provider: parsed.provider, + model: parsed.model, + debug: parsed.debug ? { + enabled: parsed.debug.enabled, + logFile: parsed.debug.log_file, + } : undefined, + worktreeDir: parsed.worktree_dir, + disabledBuiltins: parsed.disabled_builtins, + anthropicApiKey: parsed.anthropic_api_key, + openaiApiKey: parsed.openai_api_key, + pipeline: parsed.pipeline ? { + defaultBranchPrefix: parsed.pipeline.default_branch_prefix, + commitMessageTemplate: parsed.pipeline.commit_message_template, + prBodyTemplate: parsed.pipeline.pr_body_template, + } : undefined, + minimalOutput: parsed.minimal_output, + }; + cachedConfig = config; + return config; +} + +/** Save global configuration */ +export function saveGlobalConfig(config: GlobalConfig): void { + const configPath = getGlobalConfigPath(); + const raw: Record = { + language: config.language, + trusted_directories: config.trustedDirectories, + default_workflow: config.defaultWorkflow, + log_level: config.logLevel, + provider: config.provider, + }; + if (config.model) { + raw.model = config.model; + } + if (config.debug) { + raw.debug = { + enabled: config.debug.enabled, + log_file: config.debug.logFile, + }; + } + if (config.worktreeDir) { + raw.worktree_dir = config.worktreeDir; + } + if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { + raw.disabled_builtins = config.disabledBuiltins; + } + if (config.anthropicApiKey) { + raw.anthropic_api_key = config.anthropicApiKey; + } + if (config.openaiApiKey) { + raw.openai_api_key = config.openaiApiKey; + } + if (config.pipeline) { + const pipelineRaw: Record = {}; + if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; + if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate; + if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate; + if (Object.keys(pipelineRaw).length > 0) { + raw.pipeline = pipelineRaw; + } + } + if (config.minimalOutput !== undefined) { + raw.minimal_output = config.minimalOutput; + } + writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); + invalidateGlobalConfigCache(); +} + +/** Get list of disabled builtin names */ +export function getDisabledBuiltins(): string[] { + try { + const config = loadGlobalConfig(); + return config.disabledBuiltins ?? []; + } catch { + return []; + } +} + +/** Get current language setting */ +export function getLanguage(): Language { + try { + const config = loadGlobalConfig(); + return config.language; + } catch { + return DEFAULT_LANGUAGE; + } +} + +/** Set language setting */ +export function setLanguage(language: Language): void { + const config = loadGlobalConfig(); + config.language = language; + saveGlobalConfig(config); +} + +/** Set provider setting */ +export function setProvider(provider: 'claude' | 'codex'): void { + const config = loadGlobalConfig(); + config.provider = provider; + saveGlobalConfig(config); +} + +/** Add a trusted directory */ +export function addTrustedDirectory(dir: string): void { + const config = loadGlobalConfig(); + const resolvedDir = join(dir); + if (!config.trustedDirectories.includes(resolvedDir)) { + config.trustedDirectories.push(resolvedDir); + saveGlobalConfig(config); + } +} + +/** Check if a directory is trusted */ +export function isDirectoryTrusted(dir: string): boolean { + const config = loadGlobalConfig(); + const resolvedDir = join(dir); + return config.trustedDirectories.some( + (trusted) => resolvedDir === trusted || resolvedDir.startsWith(trusted + '/') + ); +} + +/** + * Resolve the Anthropic API key. + * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback) + */ +export function resolveAnthropicApiKey(): string | undefined { + const envKey = process.env['TAKT_ANTHROPIC_API_KEY']; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.anthropicApiKey; + } catch { + return undefined; + } +} + +/** + * Resolve the OpenAI API key. + * Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback) + */ +export function resolveOpenaiApiKey(): string | undefined { + const envKey = process.env['TAKT_OPENAI_API_KEY']; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.openaiApiKey; + } catch { + return undefined; + } +} + +/** Load project-level debug configuration (from .takt/config.yaml) */ +export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { + const configPath = getProjectConfigPath(projectDir); + if (!existsSync(configPath)) { + return undefined; + } + try { + const content = readFileSync(configPath, 'utf-8'); + const raw = parseYaml(content); + if (raw && typeof raw === 'object' && 'debug' in raw) { + const debug = raw.debug; + if (debug && typeof debug === 'object') { + return { + enabled: Boolean(debug.enabled), + logFile: typeof debug.log_file === 'string' ? debug.log_file : undefined, + }; + } + } + } catch { + // Ignore parse errors + } + return undefined; +} + +/** Get effective debug config (project overrides global) */ +export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | undefined { + const globalConfig = loadGlobalConfig(); + let debugConfig = globalConfig.debug; + + if (projectDir) { + const projectDebugConfig = loadProjectDebugConfig(projectDir); + if (projectDebugConfig) { + debugConfig = projectDebugConfig; + } + } + + return debugConfig; +} diff --git a/src/config/global/index.ts b/src/config/global/index.ts new file mode 100644 index 0000000..4e991c9 --- /dev/null +++ b/src/config/global/index.ts @@ -0,0 +1,28 @@ +/** + * Global configuration - barrel exports + */ + +export { + invalidateGlobalConfigCache, + loadGlobalConfig, + saveGlobalConfig, + getDisabledBuiltins, + getLanguage, + setLanguage, + setProvider, + addTrustedDirectory, + isDirectoryTrusted, + resolveAnthropicApiKey, + resolveOpenaiApiKey, + loadProjectDebugConfig, + getEffectiveDebugConfig, +} from './globalConfig.js'; + +export { + needsLanguageSetup, + promptLanguageSelection, + promptProviderSelection, + initGlobalDirs, + initProjectDirs, + type InitGlobalDirsOptions, +} from './initialization.js'; diff --git a/src/config/global/initialization.ts b/src/config/global/initialization.ts new file mode 100644 index 0000000..bb85828 --- /dev/null +++ b/src/config/global/initialization.ts @@ -0,0 +1,133 @@ +/** + * Initialization module for first-time setup + * + * Handles language selection and initial config.yaml creation. + * Builtin agents/workflows are loaded via fallback from resources/ + * and no longer copied to ~/.takt/ on setup. + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Language } from '../../models/types.js'; +import { DEFAULT_LANGUAGE } from '../../constants.js'; +import { selectOptionWithDefault } from '../../prompt/index.js'; +import { + getGlobalConfigDir, + getGlobalConfigPath, + getProjectConfigDir, + ensureDir, +} from '../paths.js'; +import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../../resources/index.js'; +import { setLanguage, setProvider } from '../globalConfig.js'; + +/** + * Check if initial setup is needed. + * Returns true if config.yaml doesn't exist yet. + */ +export function needsLanguageSetup(): boolean { + return !existsSync(getGlobalConfigPath()); +} + +/** + * Prompt user to select language for resources. + * Returns 'en' for English (default), 'ja' for Japanese. + * Exits process if cancelled (initial setup is required). + */ +export async function promptLanguageSelection(): Promise { + const options: { label: string; value: Language }[] = [ + { label: 'English', value: 'en' }, + { label: '日本語 (Japanese)', value: 'ja' }, + ]; + + const result = await selectOptionWithDefault( + 'Select language for default agents and workflows / デフォルトのエージェントとワークフローの言語を選択してください:', + options, + DEFAULT_LANGUAGE + ); + + if (result === null) { + process.exit(0); + } + + return result; +} + +/** + * Prompt user to select provider for resources. + * Exits process if cancelled (initial setup is required). + */ +export async function promptProviderSelection(): Promise<'claude' | 'codex'> { + const options: { label: string; value: 'claude' | 'codex' }[] = [ + { label: 'Claude Code', value: 'claude' }, + { label: 'Codex', value: 'codex' }, + ]; + + const result = await selectOptionWithDefault( + 'Select provider (Claude Code or Codex) / プロバイダーを選択してください:', + options, + 'claude' + ); + + if (result === null) { + process.exit(0); + } + + return result; +} + +/** Options for global directory initialization */ +export interface InitGlobalDirsOptions { + /** Skip interactive prompts (CI/non-TTY environments) */ + nonInteractive?: boolean; +} + +/** + * Initialize global takt directory structure with language selection. + * On first run, creates config.yaml from language template. + * Agents/workflows are NOT copied — they are loaded via builtin fallback. + * + * In non-interactive mode (pipeline mode or no TTY), skips prompts + * and uses default values so takt works in pipeline/CI environments without config.yaml. + */ +export async function initGlobalDirs(options?: InitGlobalDirsOptions): Promise { + ensureDir(getGlobalConfigDir()); + + if (needsLanguageSetup()) { + const isInteractive = !options?.nonInteractive && process.stdin.isTTY === true; + + if (!isInteractive) { + // Pipeline / non-interactive: skip prompts, use defaults via loadGlobalConfig() fallback + return; + } + + const lang = await promptLanguageSelection(); + const provider = await promptProviderSelection(); + + // Copy only config.yaml from language resources + copyLanguageConfigYaml(lang); + + setLanguage(lang); + setProvider(provider); + } +} + +/** Copy config.yaml from language resources to ~/.takt/ (if not already present) */ +function copyLanguageConfigYaml(lang: Language): void { + const langDir = getLanguageResourcesDir(lang); + const srcPath = join(langDir, 'config.yaml'); + const destPath = getGlobalConfigPath(); + if (existsSync(srcPath) && !existsSync(destPath)) { + writeFileSync(destPath, readFileSync(srcPath)); + } +} + +/** + * Initialize project-level .takt directory. + * Creates .takt/ and copies project resources (e.g., .gitignore). + * Only copies files that don't exist. + */ +export function initProjectDirs(projectDir: string): void { + const configDir = getProjectConfigDir(projectDir); + ensureDir(configDir); + copyProjectResourcesToDir(configDir); +} diff --git a/src/config/globalConfig.ts b/src/config/globalConfig.ts index 6bcd87c..d48b54e 100644 --- a/src/config/globalConfig.ts +++ b/src/config/globalConfig.ts @@ -1,243 +1,18 @@ /** - * Global configuration loader - * - * Manages ~/.takt/config.yaml and project-level debug settings. + * Re-export shim — actual implementation in global/globalConfig.ts */ - -import { readFileSync, existsSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { GlobalConfigSchema } from '../models/schemas.js'; -import type { GlobalConfig, DebugConfig, Language } from '../models/types.js'; -import { getGlobalConfigPath, getProjectConfigPath } from './paths.js'; -import { DEFAULT_LANGUAGE } from '../constants.js'; - -/** Create default global configuration (fresh instance each call) */ -function createDefaultGlobalConfig(): GlobalConfig { - return { - language: DEFAULT_LANGUAGE, - trustedDirectories: [], - defaultWorkflow: 'default', - logLevel: 'info', - provider: 'claude', - }; -} - -/** Module-level cache for global configuration */ -let cachedConfig: GlobalConfig | null = null; - -/** Invalidate the cached global configuration (call after mutation) */ -export function invalidateGlobalConfigCache(): void { - cachedConfig = null; -} - -/** Load global configuration */ -export function loadGlobalConfig(): GlobalConfig { - if (cachedConfig !== null) { - return cachedConfig; - } - const configPath = getGlobalConfigPath(); - if (!existsSync(configPath)) { - const defaultConfig = createDefaultGlobalConfig(); - cachedConfig = defaultConfig; - return defaultConfig; - } - const content = readFileSync(configPath, 'utf-8'); - const raw = parseYaml(content); - const parsed = GlobalConfigSchema.parse(raw); - const config: GlobalConfig = { - language: parsed.language, - trustedDirectories: parsed.trusted_directories, - defaultWorkflow: parsed.default_workflow, - logLevel: parsed.log_level, - provider: parsed.provider, - model: parsed.model, - debug: parsed.debug ? { - enabled: parsed.debug.enabled, - logFile: parsed.debug.log_file, - } : undefined, - worktreeDir: parsed.worktree_dir, - disabledBuiltins: parsed.disabled_builtins, - anthropicApiKey: parsed.anthropic_api_key, - openaiApiKey: parsed.openai_api_key, - pipeline: parsed.pipeline ? { - defaultBranchPrefix: parsed.pipeline.default_branch_prefix, - commitMessageTemplate: parsed.pipeline.commit_message_template, - prBodyTemplate: parsed.pipeline.pr_body_template, - } : undefined, - minimalOutput: parsed.minimal_output, - }; - cachedConfig = config; - return config; -} - -/** Save global configuration */ -export function saveGlobalConfig(config: GlobalConfig): void { - const configPath = getGlobalConfigPath(); - const raw: Record = { - language: config.language, - trusted_directories: config.trustedDirectories, - default_workflow: config.defaultWorkflow, - log_level: config.logLevel, - provider: config.provider, - }; - if (config.model) { - raw.model = config.model; - } - if (config.debug) { - raw.debug = { - enabled: config.debug.enabled, - log_file: config.debug.logFile, - }; - } - if (config.worktreeDir) { - raw.worktree_dir = config.worktreeDir; - } - if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { - raw.disabled_builtins = config.disabledBuiltins; - } - if (config.anthropicApiKey) { - raw.anthropic_api_key = config.anthropicApiKey; - } - if (config.openaiApiKey) { - raw.openai_api_key = config.openaiApiKey; - } - if (config.pipeline) { - const pipelineRaw: Record = {}; - if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; - if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate; - if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate; - if (Object.keys(pipelineRaw).length > 0) { - raw.pipeline = pipelineRaw; - } - } - if (config.minimalOutput !== undefined) { - raw.minimal_output = config.minimalOutput; - } - writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); - invalidateGlobalConfigCache(); -} - -/** Get list of disabled builtin names */ -export function getDisabledBuiltins(): string[] { - try { - const config = loadGlobalConfig(); - return config.disabledBuiltins ?? []; - } catch { - return []; - } -} - -/** Get current language setting */ -export function getLanguage(): Language { - try { - const config = loadGlobalConfig(); - return config.language; - } catch { - return DEFAULT_LANGUAGE; - } -} - -/** Set language setting */ -export function setLanguage(language: Language): void { - const config = loadGlobalConfig(); - config.language = language; - saveGlobalConfig(config); -} - -/** Set provider setting */ -export function setProvider(provider: 'claude' | 'codex'): void { - const config = loadGlobalConfig(); - config.provider = provider; - saveGlobalConfig(config); -} - -/** Add a trusted directory */ -export function addTrustedDirectory(dir: string): void { - const config = loadGlobalConfig(); - const resolvedDir = join(dir); - if (!config.trustedDirectories.includes(resolvedDir)) { - config.trustedDirectories.push(resolvedDir); - saveGlobalConfig(config); - } -} - -/** Check if a directory is trusted */ -export function isDirectoryTrusted(dir: string): boolean { - const config = loadGlobalConfig(); - const resolvedDir = join(dir); - return config.trustedDirectories.some( - (trusted) => resolvedDir === trusted || resolvedDir.startsWith(trusted + '/') - ); -} - -/** - * Resolve the Anthropic API key. - * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback) - */ -export function resolveAnthropicApiKey(): string | undefined { - const envKey = process.env['TAKT_ANTHROPIC_API_KEY']; - if (envKey) return envKey; - - try { - const config = loadGlobalConfig(); - return config.anthropicApiKey; - } catch { - return undefined; - } -} - -/** - * Resolve the OpenAI API key. - * Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback) - */ -export function resolveOpenaiApiKey(): string | undefined { - const envKey = process.env['TAKT_OPENAI_API_KEY']; - if (envKey) return envKey; - - try { - const config = loadGlobalConfig(); - return config.openaiApiKey; - } catch { - return undefined; - } -} - -/** Load project-level debug configuration (from .takt/config.yaml) */ -export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { - const configPath = getProjectConfigPath(projectDir); - if (!existsSync(configPath)) { - return undefined; - } - try { - const content = readFileSync(configPath, 'utf-8'); - const raw = parseYaml(content); - if (raw && typeof raw === 'object' && 'debug' in raw) { - const debug = raw.debug; - if (debug && typeof debug === 'object') { - return { - enabled: Boolean(debug.enabled), - logFile: typeof debug.log_file === 'string' ? debug.log_file : undefined, - }; - } - } - } catch { - // Ignore parse errors - } - return undefined; -} - -/** Get effective debug config (project overrides global) */ -export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | undefined { - const globalConfig = loadGlobalConfig(); - let debugConfig = globalConfig.debug; - - if (projectDir) { - const projectDebugConfig = loadProjectDebugConfig(projectDir); - if (projectDebugConfig) { - debugConfig = projectDebugConfig; - } - } - - return debugConfig; -} +export { + invalidateGlobalConfigCache, + loadGlobalConfig, + saveGlobalConfig, + getDisabledBuiltins, + getLanguage, + setLanguage, + setProvider, + addTrustedDirectory, + isDirectoryTrusted, + resolveAnthropicApiKey, + resolveOpenaiApiKey, + loadProjectDebugConfig, + getEffectiveDebugConfig, +} from './global/globalConfig.js'; diff --git a/src/config/initialization.ts b/src/config/initialization.ts index b9eac8c..e936862 100644 --- a/src/config/initialization.ts +++ b/src/config/initialization.ts @@ -1,133 +1,11 @@ /** - * Initialization module for first-time setup - * - * Handles language selection and initial config.yaml creation. - * Builtin agents/workflows are loaded via fallback from resources/ - * and no longer copied to ~/.takt/ on setup. + * Re-export shim — actual implementation in global/initialization.ts */ - -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import type { Language } from '../models/types.js'; -import { DEFAULT_LANGUAGE } from '../constants.js'; -import { selectOptionWithDefault } from '../prompt/index.js'; -import { - getGlobalConfigDir, - getGlobalConfigPath, - getProjectConfigDir, - ensureDir, -} from './paths.js'; -import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../resources/index.js'; -import { setLanguage, setProvider } from './globalConfig.js'; - -/** - * Check if initial setup is needed. - * Returns true if config.yaml doesn't exist yet. - */ -export function needsLanguageSetup(): boolean { - return !existsSync(getGlobalConfigPath()); -} - -/** - * Prompt user to select language for resources. - * Returns 'en' for English (default), 'ja' for Japanese. - * Exits process if cancelled (initial setup is required). - */ -export async function promptLanguageSelection(): Promise { - const options: { label: string; value: Language }[] = [ - { label: 'English', value: 'en' }, - { label: '日本語 (Japanese)', value: 'ja' }, - ]; - - const result = await selectOptionWithDefault( - 'Select language for default agents and workflows / デフォルトのエージェントとワークフローの言語を選択してください:', - options, - DEFAULT_LANGUAGE - ); - - if (result === null) { - process.exit(0); - } - - return result; -} - -/** - * Prompt user to select provider for resources. - * Exits process if cancelled (initial setup is required). - */ -export async function promptProviderSelection(): Promise<'claude' | 'codex'> { - const options: { label: string; value: 'claude' | 'codex' }[] = [ - { label: 'Claude Code', value: 'claude' }, - { label: 'Codex', value: 'codex' }, - ]; - - const result = await selectOptionWithDefault( - 'Select provider (Claude Code or Codex) / プロバイダーを選択してください:', - options, - 'claude' - ); - - if (result === null) { - process.exit(0); - } - - return result; -} - -/** Options for global directory initialization */ -export interface InitGlobalDirsOptions { - /** Skip interactive prompts (CI/non-TTY environments) */ - nonInteractive?: boolean; -} - -/** - * Initialize global takt directory structure with language selection. - * On first run, creates config.yaml from language template. - * Agents/workflows are NOT copied — they are loaded via builtin fallback. - * - * In non-interactive mode (pipeline mode or no TTY), skips prompts - * and uses default values so takt works in pipeline/CI environments without config.yaml. - */ -export async function initGlobalDirs(options?: InitGlobalDirsOptions): Promise { - ensureDir(getGlobalConfigDir()); - - if (needsLanguageSetup()) { - const isInteractive = !options?.nonInteractive && process.stdin.isTTY === true; - - if (!isInteractive) { - // Pipeline / non-interactive: skip prompts, use defaults via loadGlobalConfig() fallback - return; - } - - const lang = await promptLanguageSelection(); - const provider = await promptProviderSelection(); - - // Copy only config.yaml from language resources - copyLanguageConfigYaml(lang); - - setLanguage(lang); - setProvider(provider); - } -} - -/** Copy config.yaml from language resources to ~/.takt/ (if not already present) */ -function copyLanguageConfigYaml(lang: Language): void { - const langDir = getLanguageResourcesDir(lang); - const srcPath = join(langDir, 'config.yaml'); - const destPath = getGlobalConfigPath(); - if (existsSync(srcPath) && !existsSync(destPath)) { - writeFileSync(destPath, readFileSync(srcPath)); - } -} - -/** - * Initialize project-level .takt directory. - * Creates .takt/ and copies project resources (e.g., .gitignore). - * Only copies files that don't exist. - */ -export function initProjectDirs(projectDir: string): void { - const configDir = getProjectConfigDir(projectDir); - ensureDir(configDir); - copyProjectResourcesToDir(configDir); -} +export { + needsLanguageSetup, + promptLanguageSelection, + promptProviderSelection, + initGlobalDirs, + initProjectDirs, + type InitGlobalDirsOptions, +} from './global/initialization.js'; diff --git a/src/config/loader.ts b/src/config/loader.ts index 967982e..544d9eb 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,5 +1,5 @@ /** - * Configuration loader for takt + * Re-export shim — actual implementation in loaders/loader.ts * * Re-exports from specialized loaders for backward compatibility. */ @@ -12,7 +12,7 @@ export { isWorkflowPath, loadAllWorkflows, listWorkflows, -} from './workflowLoader.js'; +} from './loaders/workflowLoader.js'; // Agent loading export { @@ -21,7 +21,7 @@ export { listCustomAgents, loadAgentPrompt, loadAgentPromptFromPath, -} from './agentLoader.js'; +} from './loaders/agentLoader.js'; // Global configuration export { @@ -32,4 +32,4 @@ export { isDirectoryTrusted, loadProjectDebugConfig, getEffectiveDebugConfig, -} from './globalConfig.js'; +} from './global/globalConfig.js'; diff --git a/src/config/loaders/agentLoader.ts b/src/config/loaders/agentLoader.ts new file mode 100644 index 0000000..939421e --- /dev/null +++ b/src/config/loaders/agentLoader.ts @@ -0,0 +1,110 @@ +/** + * Agent configuration loader + * + * Loads agents with user → builtin fallback: + * 1. User agents: ~/.takt/agents/*.md + * 2. Builtin agents: resources/global/{lang}/agents/*.md + */ + +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import type { CustomAgentConfig } from '../../models/types.js'; +import { + getGlobalAgentsDir, + getGlobalWorkflowsDir, + getBuiltinAgentsDir, + getBuiltinWorkflowsDir, + isPathSafe, +} from '../paths.js'; +import { getLanguage } from '../globalConfig.js'; + +/** Get all allowed base directories for agent prompt files */ +function getAllowedAgentBases(): string[] { + const lang = getLanguage(); + return [ + getGlobalAgentsDir(), + getGlobalWorkflowsDir(), + getBuiltinAgentsDir(lang), + getBuiltinWorkflowsDir(lang), + ]; +} + +/** Load agents from markdown files in a directory */ +export function loadAgentsFromDir(dirPath: string): CustomAgentConfig[] { + if (!existsSync(dirPath)) { + return []; + } + const agents: CustomAgentConfig[] = []; + for (const file of readdirSync(dirPath)) { + if (file.endsWith('.md')) { + const name = basename(file, '.md'); + const promptFile = join(dirPath, file); + agents.push({ + name, + promptFile, + }); + } + } + return agents; +} + +/** Load all custom agents from global directory (~/.takt/agents/) */ +export function loadCustomAgents(): Map { + const agents = new Map(); + + // Global agents from markdown files (~/.takt/agents/*.md) + for (const agent of loadAgentsFromDir(getGlobalAgentsDir())) { + agents.set(agent.name, agent); + } + + return agents; +} + +/** List available custom agents */ +export function listCustomAgents(): string[] { + return Array.from(loadCustomAgents().keys()).sort(); +} + +/** + * Load agent prompt content. + * Agents can be loaded from: + * - ~/.takt/agents/*.md (global agents) + * - ~/.takt/workflows/{workflow}/*.md (workflow-specific agents) + */ +export function loadAgentPrompt(agent: CustomAgentConfig): string { + if (agent.prompt) { + return agent.prompt; + } + + if (agent.promptFile) { + const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agent.promptFile!)); + if (!isValid) { + throw new Error(`Agent prompt file path is not allowed: ${agent.promptFile}`); + } + + if (!existsSync(agent.promptFile)) { + throw new Error(`Agent prompt file not found: ${agent.promptFile}`); + } + + return readFileSync(agent.promptFile, 'utf-8'); + } + + throw new Error(`Agent ${agent.name} has no prompt defined`); +} + +/** + * Load agent prompt from a resolved path. + * Used by workflow engine when agentPath is already resolved. + */ +export function loadAgentPromptFromPath(agentPath: string): string { + const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agentPath)); + if (!isValid) { + throw new Error(`Agent prompt file path is not allowed: ${agentPath}`); + } + + if (!existsSync(agentPath)) { + throw new Error(`Agent prompt file not found: ${agentPath}`); + } + + return readFileSync(agentPath, 'utf-8'); +} diff --git a/src/config/loaders/index.ts b/src/config/loaders/index.ts new file mode 100644 index 0000000..ed57c13 --- /dev/null +++ b/src/config/loaders/index.ts @@ -0,0 +1,20 @@ +/** + * Configuration loaders - barrel exports + */ + +export { + getBuiltinWorkflow, + loadWorkflow, + loadWorkflowByIdentifier, + isWorkflowPath, + loadAllWorkflows, + listWorkflows, +} from './workflowLoader.js'; + +export { + loadAgentsFromDir, + loadCustomAgents, + listCustomAgents, + loadAgentPrompt, + loadAgentPromptFromPath, +} from './agentLoader.js'; diff --git a/src/config/loaders/loader.ts b/src/config/loaders/loader.ts new file mode 100644 index 0000000..7c6bf65 --- /dev/null +++ b/src/config/loaders/loader.ts @@ -0,0 +1,35 @@ +/** + * Configuration loader for takt + * + * Re-exports from specialized loaders for backward compatibility. + */ + +// Workflow loading +export { + getBuiltinWorkflow, + loadWorkflow, + loadWorkflowByIdentifier, + isWorkflowPath, + loadAllWorkflows, + listWorkflows, +} from '../workflowLoader.js'; + +// Agent loading +export { + loadAgentsFromDir, + loadCustomAgents, + listCustomAgents, + loadAgentPrompt, + loadAgentPromptFromPath, +} from '../agentLoader.js'; + +// Global configuration +export { + loadGlobalConfig, + saveGlobalConfig, + invalidateGlobalConfigCache, + addTrustedDirectory, + isDirectoryTrusted, + loadProjectDebugConfig, + getEffectiveDebugConfig, +} from '../globalConfig.js'; diff --git a/src/config/loaders/workflowLoader.ts b/src/config/loaders/workflowLoader.ts new file mode 100644 index 0000000..6e2dbed --- /dev/null +++ b/src/config/loaders/workflowLoader.ts @@ -0,0 +1,449 @@ +/** + * Workflow configuration loader + * + * 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, 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, getProjectConfigDir } from '../paths.js'; +import { getLanguage, getDisabledBuiltins } from '../globalConfig.js'; + +/** Get builtin workflow by name */ +export function getBuiltinWorkflow(name: string): WorkflowConfig | null { + const lang = getLanguage(); + const disabled = getDisabledBuiltins(); + if (disabled.includes(name)) return null; + + const builtinDir = getBuiltinWorkflowsDir(lang); + const yamlPath = join(builtinDir, `${name}.yaml`); + if (existsSync(yamlPath)) { + return loadWorkflowFromFile(yamlPath); + } + return null; +} + +/** + * Resolve agent path from workflow specification. + * - Relative path (./agent.md): relative to workflow directory + * - Absolute path (/path/to/agent.md or ~/...): use as-is + */ +function resolveAgentPathForWorkflow(agentSpec: string, workflowDir: string): string { + // Relative path (starts with ./) + if (agentSpec.startsWith('./')) { + return join(workflowDir, agentSpec.slice(2)); + } + + // Home directory expansion + if (agentSpec.startsWith('~')) { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + return join(homedir, agentSpec.slice(1)); + } + + // Absolute path + if (agentSpec.startsWith('/')) { + return agentSpec; + } + + // Fallback: treat as relative to workflow directory + return join(workflowDir, agentSpec); +} + +/** + * Extract display name from agent path. + * e.g., "~/.takt/agents/default/coder.md" -> "coder" + */ +function extractAgentDisplayName(agentPath: string): string { + // Get the filename without extension + const filename = basename(agentPath, '.md'); + return filename; +} + +/** + * Resolve a string value that may be a file path. + * If the value ends with .md and the file exists (resolved relative to workflowDir), + * read and return the file contents. Otherwise return the value as-is. + */ +function resolveContentPath(value: string | undefined, workflowDir: string): string | undefined { + if (value == null) return undefined; + if (value.endsWith('.md')) { + // Resolve path relative to workflow directory + let resolvedPath = value; + if (value.startsWith('./')) { + resolvedPath = join(workflowDir, value.slice(2)); + } else if (value.startsWith('~')) { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + resolvedPath = join(homedir, value.slice(1)); + } else if (!value.startsWith('/')) { + resolvedPath = join(workflowDir, value); + } + if (existsSync(resolvedPath)) { + return readFileSync(resolvedPath, 'utf-8'); + } + } + return value; +} + +/** + * Check if a raw report value is the object form (has 'name' property). + */ +function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } { + return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; +} + +/** + * Normalize the raw report field from YAML into internal format. + * + * YAML formats: + * report: "00-plan.md" → string (single file) + * report: → ReportConfig[] (multiple files) + * - Scope: 01-scope.md + * - Decisions: 02-decisions.md + * report: → ReportObjectConfig (object form) + * name: 00-plan.md + * order: ... + * format: ... + * + * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...] + */ +function normalizeReport( + raw: string | Record[] | { name: string; order?: string; format?: string } | undefined, + workflowDir: string, +): string | ReportConfig[] | ReportObjectConfig | undefined { + if (raw == null) return undefined; + if (typeof raw === 'string') return raw; + if (isReportObject(raw)) { + return { + name: raw.name, + order: resolveContentPath(raw.order, workflowDir), + format: resolveContentPath(raw.format, workflowDir), + }; + } + // Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...] + return (raw as Record[]).flatMap((entry) => + Object.entries(entry).map(([label, path]) => ({ label, path })), + ); +} + +/** Regex to detect ai("...") condition expressions */ +const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/; + +/** Regex to detect all("...")/any("...") aggregate condition expressions */ +const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/; + +/** + * Parse a rule's condition for ai() and all()/any() expressions. + * - `ai("text")` → sets isAiCondition and aiConditionText + * - `all("text")` / `any("text")` → sets isAggregateCondition, aggregateType, aggregateConditionText + */ +function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule { + const aiMatch = r.condition.match(AI_CONDITION_REGEX); + if (aiMatch?.[1]) { + return { + condition: r.condition, + next: r.next, + appendix: r.appendix, + isAiCondition: true, + aiConditionText: aiMatch[1], + }; + } + + const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX); + if (aggMatch?.[1] && aggMatch[2]) { + return { + condition: r.condition, + next: r.next, + appendix: r.appendix, + isAggregateCondition: true, + aggregateType: aggMatch[1] as 'all' | 'any', + aggregateConditionText: aggMatch[2], + }; + } + + return { + condition: r.condition, + next: r.next, + appendix: r.appendix, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RawStep = any; + +/** + * Normalize a raw step into internal WorkflowStep format. + */ +function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep { + const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule); + const agentSpec: string = step.agent ?? ''; + + const result: WorkflowStep = { + name: step.name, + agent: agentSpec, + agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name), + agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined, + allowedTools: step.allowed_tools, + provider: step.provider, + model: step.model, + permissionMode: step.permission_mode, + edit: step.edit, + instructionTemplate: resolveContentPath(step.instruction_template, workflowDir) || step.instruction || '{task}', + rules, + report: normalizeReport(step.report, workflowDir), + passPreviousResponse: step.pass_previous_response ?? true, + }; + + if (step.parallel && step.parallel.length > 0) { + result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, workflowDir)); + } + + return result; +} + +/** + * Convert raw YAML workflow config to internal format. + * Agent paths are resolved relative to the workflow directory. + */ +function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig { + const parsed = WorkflowConfigRawSchema.parse(raw); + + const steps: WorkflowStep[] = parsed.steps.map((step) => + normalizeStepFromRaw(step, workflowDir), + ); + + return { + name: parsed.name, + description: parsed.description, + steps, + initialStep: parsed.initial_step || steps[0]?.name || '', + maxIterations: parsed.max_iterations, + answerAgent: parsed.answer_agent, + }; +} + +/** + * Load a workflow from a YAML file. + * @param filePath Path to the workflow YAML file + */ +function loadWorkflowFromFile(filePath: string): WorkflowConfig { + if (!existsSync(filePath)) { + throw new Error(`Workflow file not found: ${filePath}`); + } + const content = readFileSync(filePath, 'utf-8'); + const raw = parseYaml(content); + const workflowDir = dirname(filePath); + return normalizeWorkflowConfig(raw, workflowDir); +} + +/** + * Resolve a path that may be relative, absolute, or home-directory-relative. + * @param pathInput Path to resolve + * @param basePath Base directory for relative paths + * @returns Absolute resolved path + */ +function resolvePath(pathInput: string, basePath: string): 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. + * Called internally by loadWorkflowByIdentifier when the identifier is detected as a path. + * + * @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~) + * @param basePath Base directory for resolving relative paths + * @returns WorkflowConfig or null if file not found + */ +function loadWorkflowFromPath( + filePath: string, + basePath: string +): 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 (for project-local workflow resolution) + */ +export function loadWorkflow( + name: string, + projectCwd: string +): 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); + } + + // 3. Builtin fallback + return getBuiltinWorkflow(name); +} + +/** + * Load all workflows with descriptions (for switch command). + * + * Priority (later entries override earlier): + * 1. Builtin workflows + * 2. User workflows (~/.takt/workflows/) + * 3. Project-local workflows (.takt/workflows/) + */ +export function loadAllWorkflows(cwd: string): Map { + const workflows = new Map(); + const disabled = getDisabledBuiltins(); + + // 1. Builtin workflows (lowest priority) + const lang = getLanguage(); + const builtinDir = getBuiltinWorkflowsDir(lang); + loadWorkflowsFromDir(builtinDir, workflows, disabled); + + // 2. User workflows (overrides builtins) + const globalWorkflowsDir = getGlobalWorkflowsDir(); + loadWorkflowsFromDir(globalWorkflowsDir, workflows); + + // 3. Project-local workflows (highest priority) + const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows'); + loadWorkflowsFromDir(projectWorkflowsDir, workflows); + + return workflows; +} + +/** Load workflow files from a directory into a Map (later calls override earlier entries) */ +function loadWorkflowsFromDir( + dir: string, + target: Map, + disabled?: string[], +): void { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; + const entryPath = join(dir, entry); + if (!statSync(entryPath).isFile()) continue; + const workflowName = entry.replace(/\.ya?ml$/, ''); + if (disabled?.includes(workflowName)) continue; + try { + target.set(workflowName, loadWorkflowFromFile(entryPath)); + } catch { + // Skip invalid workflows + } + } +} + +/** + * List available workflow names (builtin + user + project-local, excluding disabled). + * + * @param cwd Project root directory (used to scan project-local .takt/workflows/). + */ +export function listWorkflows(cwd: string): string[] { + const workflows = new Set(); + const disabled = getDisabledBuiltins(); + + // 1. Builtin workflows + const lang = getLanguage(); + const builtinDir = getBuiltinWorkflowsDir(lang); + scanWorkflowDir(builtinDir, workflows, disabled); + + // 2. User workflows + const globalWorkflowsDir = getGlobalWorkflowsDir(); + scanWorkflowDir(globalWorkflowsDir, workflows); + + // 3. Project-local workflows + const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows'); + scanWorkflowDir(projectWorkflowsDir, workflows); + + return Array.from(workflows).sort(); +} + +/** + * Check if a workflow identifier looks like a file path (vs a workflow name). + * + * Path indicators: + * - Starts with `/` (absolute path) + * - Starts with `~` (home directory) + * - Starts with `./` or `../` (relative path) + * - Ends with `.yaml` or `.yml` (file extension) + */ +export function isWorkflowPath(identifier: string): boolean { + return ( + identifier.startsWith('/') || + identifier.startsWith('~') || + identifier.startsWith('./') || + identifier.startsWith('../') || + identifier.endsWith('.yaml') || + identifier.endsWith('.yml') + ); +} + +/** + * Load workflow by identifier (auto-detects name vs path). + * + * If the identifier looks like a path (see isWorkflowPath), loads from file. + * Otherwise, loads by name with the standard priority chain: + * project-local → user → builtin. + * + * @param identifier Workflow name or file path + * @param projectCwd Project root directory (for project-local resolution and relative path base) + */ +export function loadWorkflowByIdentifier( + identifier: string, + projectCwd: string +): WorkflowConfig | null { + if (isWorkflowPath(identifier)) { + return loadWorkflowFromPath(identifier, projectCwd); + } + return loadWorkflow(identifier, projectCwd); +} + +/** Scan a directory for .yaml/.yml files and add names to the set */ +function scanWorkflowDir(dir: string, target: Set, disabled?: string[]): void { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; + + const entryPath = join(dir, entry); + if (statSync(entryPath).isFile()) { + const workflowName = entry.replace(/\.ya?ml$/, ''); + if (disabled?.includes(workflowName)) continue; + target.add(workflowName); + } + } +} diff --git a/src/config/project/index.ts b/src/config/project/index.ts new file mode 100644 index 0000000..a6d1c95 --- /dev/null +++ b/src/config/project/index.ts @@ -0,0 +1,37 @@ +/** + * Project configuration - barrel exports + */ + +export { + loadProjectConfig, + saveProjectConfig, + updateProjectConfig, + getCurrentWorkflow, + setCurrentWorkflow, + isVerboseMode, + type PermissionMode, + type ProjectPermissionMode, + type ProjectLocalConfig, +} from './projectConfig.js'; + +export { + writeFileAtomic, + getInputHistoryPath, + MAX_INPUT_HISTORY, + loadInputHistory, + saveInputHistory, + addToInputHistory, + type AgentSessionData, + getAgentSessionsPath, + loadAgentSessions, + saveAgentSessions, + updateAgentSession, + clearAgentSessions, + getWorktreeSessionsDir, + encodeWorktreePath, + getWorktreeSessionPath, + loadWorktreeSessions, + updateWorktreeSession, + getClaudeProjectSessionsDir, + clearClaudeProjectSessions, +} from './sessionStore.js'; diff --git a/src/config/project/projectConfig.ts b/src/config/project/projectConfig.ts new file mode 100644 index 0000000..a70d50a --- /dev/null +++ b/src/config/project/projectConfig.ts @@ -0,0 +1,131 @@ +/** + * Project-level configuration management + * + * Manages .takt/config.yaml for project-specific settings. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { parse, stringify } from 'yaml'; +import { copyProjectResourcesToDir } from '../../resources/index.js'; + +/** Permission mode for the project + * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) + * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) + * + * Note: 'confirm' mode is planned but not yet implemented + */ +export type PermissionMode = 'default' | 'sacrifice-my-pc'; + +/** @deprecated Use PermissionMode instead */ +export type ProjectPermissionMode = PermissionMode; + +/** Project configuration stored in .takt/config.yaml */ +export interface ProjectLocalConfig { + /** Current workflow name */ + workflow?: string; + /** Provider selection for agent runtime */ + provider?: 'claude' | 'codex'; + /** Permission mode setting */ + permissionMode?: PermissionMode; + /** Verbose output mode */ + verbose?: boolean; + /** Custom settings */ + [key: string]: unknown; +} + +/** Default project configuration */ +const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { + workflow: 'default', + permissionMode: 'default', +}; + +/** + * Get project takt config directory (.takt in project) + * Note: Defined locally to avoid circular dependency with paths.ts + */ +function getConfigDir(projectDir: string): string { + return join(resolve(projectDir), '.takt'); +} + +/** + * Get project config file path + * Note: Defined locally to avoid circular dependency with paths.ts + */ +function getConfigPath(projectDir: string): string { + return join(getConfigDir(projectDir), 'config.yaml'); +} + +/** + * Load project configuration from .takt/config.yaml + */ +export function loadProjectConfig(projectDir: string): ProjectLocalConfig { + const configPath = getConfigPath(projectDir); + + if (!existsSync(configPath)) { + return { ...DEFAULT_PROJECT_CONFIG }; + } + + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed = parse(content) as ProjectLocalConfig | null; + return { ...DEFAULT_PROJECT_CONFIG, ...parsed }; + } catch { + return { ...DEFAULT_PROJECT_CONFIG }; + } +} + +/** + * Save project configuration to .takt/config.yaml + */ +export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void { + const configDir = getConfigDir(projectDir); + const configPath = getConfigPath(projectDir); + + // Ensure directory exists + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + // Copy project resources (only copies files that don't exist) + copyProjectResourcesToDir(configDir); + + const content = stringify(config, { indent: 2 }); + writeFileSync(configPath, content, 'utf-8'); +} + +/** + * Update a single field in project configuration + */ +export function updateProjectConfig( + projectDir: string, + key: K, + value: ProjectLocalConfig[K] +): void { + const config = loadProjectConfig(projectDir); + config[key] = value; + saveProjectConfig(projectDir, config); +} + +/** + * Get current workflow from project config + */ +export function getCurrentWorkflow(projectDir: string): string { + const config = loadProjectConfig(projectDir); + return config.workflow || 'default'; +} + +/** + * Set current workflow in project config + */ +export function setCurrentWorkflow(projectDir: string, workflow: string): void { + updateProjectConfig(projectDir, 'workflow', workflow); +} + +/** + * Get verbose mode from project config + */ +export function isVerboseMode(projectDir: string): boolean { + const config = loadProjectConfig(projectDir); + return config.verbose === true; +} diff --git a/src/config/project/sessionStore.ts b/src/config/project/sessionStore.ts new file mode 100644 index 0000000..52e8d9b --- /dev/null +++ b/src/config/project/sessionStore.ts @@ -0,0 +1,322 @@ +/** + * Session storage for takt + * + * Manages agent sessions and input history persistence. + */ + +import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { getProjectConfigDir, ensureDir } from '../paths.js'; + +/** + * Write file atomically using temp file + rename. + * This prevents corruption when multiple processes write simultaneously. + */ +export function writeFileAtomic(filePath: string, content: string): void { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tempPath, content, 'utf-8'); + renameSync(tempPath, filePath); + } catch (error) { + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath); + } + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +// ============ Input History ============ + +/** Get path for storing input history */ +export function getInputHistoryPath(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'input_history'); +} + +/** Maximum number of input history entries to keep */ +export const MAX_INPUT_HISTORY = 100; + +/** Load input history */ +export function loadInputHistory(projectDir: string): string[] { + const path = getInputHistoryPath(projectDir); + if (existsSync(path)) { + try { + const content = readFileSync(path, 'utf-8'); + return content + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line) as string; + } catch { + return null; + } + }) + .filter((entry): entry is string => entry !== null); + } catch { + return []; + } + } + return []; +} + +/** Save input history (atomic write) */ +export function saveInputHistory(projectDir: string, history: string[]): void { + const path = getInputHistoryPath(projectDir); + ensureDir(getProjectConfigDir(projectDir)); + const trimmed = history.slice(-MAX_INPUT_HISTORY); + const content = trimmed.map((entry) => JSON.stringify(entry)).join('\n'); + writeFileAtomic(path, content); +} + +/** Add an entry to input history */ +export function addToInputHistory(projectDir: string, input: string): void { + const history = loadInputHistory(projectDir); + if (history[history.length - 1] !== input) { + history.push(input); + } + saveInputHistory(projectDir, history); +} + +// ============ Agent Sessions ============ + +/** Agent session data for persistence */ +export interface AgentSessionData { + agentSessions: Record; + updatedAt: string; + /** Provider that created these sessions (claude, codex, etc.) */ + provider?: string; +} + +/** Get path for storing agent sessions */ +export function getAgentSessionsPath(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'agent_sessions.json'); +} + +/** Load saved agent sessions. Returns empty if provider has changed. */ +export function loadAgentSessions(projectDir: string, currentProvider?: string): Record { + const path = getAgentSessionsPath(projectDir); + if (existsSync(path)) { + try { + const content = readFileSync(path, 'utf-8'); + const data = JSON.parse(content) as AgentSessionData; + // If provider has changed or is unknown (legacy data), sessions are incompatible — discard them + if (currentProvider && data.provider !== currentProvider) { + return {}; + } + return data.agentSessions || {}; + } catch { + return {}; + } + } + return {}; +} + +/** Save agent sessions (atomic write) */ +export function saveAgentSessions( + projectDir: string, + sessions: Record, + provider?: string +): void { + const path = getAgentSessionsPath(projectDir); + ensureDir(getProjectConfigDir(projectDir)); + const data: AgentSessionData = { + agentSessions: sessions, + updatedAt: new Date().toISOString(), + provider, + }; + writeFileAtomic(path, JSON.stringify(data, null, 2)); +} + +/** + * Update a single agent session atomically. + * Uses read-modify-write with atomic file operations. + */ +export function updateAgentSession( + projectDir: string, + agentName: string, + sessionId: string, + provider?: string +): void { + const path = getAgentSessionsPath(projectDir); + ensureDir(getProjectConfigDir(projectDir)); + + let sessions: Record = {}; + let existingProvider: string | undefined; + if (existsSync(path)) { + try { + const content = readFileSync(path, 'utf-8'); + const data = JSON.parse(content) as AgentSessionData; + existingProvider = data.provider; + // If provider changed, discard old sessions + if (provider && existingProvider && existingProvider !== provider) { + sessions = {}; + } else { + sessions = data.agentSessions || {}; + } + } catch { + sessions = {}; + } + } + + sessions[agentName] = sessionId; + + const data: AgentSessionData = { + agentSessions: sessions, + updatedAt: new Date().toISOString(), + provider: provider ?? existingProvider, + }; + writeFileAtomic(path, JSON.stringify(data, null, 2)); +} + +/** Clear all saved agent sessions */ +export function clearAgentSessions(projectDir: string): void { + const path = getAgentSessionsPath(projectDir); + ensureDir(getProjectConfigDir(projectDir)); + const data: AgentSessionData = { + agentSessions: {}, + updatedAt: new Date().toISOString(), + }; + writeFileAtomic(path, JSON.stringify(data, null, 2)); + + // Also clear Claude CLI project sessions + clearClaudeProjectSessions(projectDir); +} + +// ============ Worktree Sessions ============ + +/** Get the worktree sessions directory */ +export function getWorktreeSessionsDir(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'worktree-sessions'); +} + +/** Encode a worktree path to a safe filename */ +export function encodeWorktreePath(worktreePath: string): string { + const resolved = resolve(worktreePath); + return resolved.replace(/[/\\:]/g, '-'); +} + +/** Get path for a worktree's session file */ +export function getWorktreeSessionPath(projectDir: string, worktreePath: string): string { + const dir = getWorktreeSessionsDir(projectDir); + const encoded = encodeWorktreePath(worktreePath); + return join(dir, `${encoded}.json`); +} + +/** Load saved agent sessions for a worktree. Returns empty if provider has changed. */ +export function loadWorktreeSessions( + projectDir: string, + worktreePath: string, + currentProvider?: string +): Record { + const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); + if (existsSync(sessionPath)) { + try { + const content = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(content) as AgentSessionData; + if (currentProvider && data.provider !== currentProvider) { + return {}; + } + return data.agentSessions || {}; + } catch { + return {}; + } + } + return {}; +} + +/** Update a single agent session for a worktree (atomic) */ +export function updateWorktreeSession( + projectDir: string, + worktreePath: string, + agentName: string, + sessionId: string, + provider?: string +): void { + const dir = getWorktreeSessionsDir(projectDir); + ensureDir(dir); + + const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); + let sessions: Record = {}; + let existingProvider: string | undefined; + + if (existsSync(sessionPath)) { + try { + const content = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(content) as AgentSessionData; + existingProvider = data.provider; + if (provider && existingProvider && existingProvider !== provider) { + sessions = {}; + } else { + sessions = data.agentSessions || {}; + } + } catch { + sessions = {}; + } + } + + sessions[agentName] = sessionId; + + const data: AgentSessionData = { + agentSessions: sessions, + updatedAt: new Date().toISOString(), + provider: provider ?? existingProvider, + }; + writeFileAtomic(sessionPath, JSON.stringify(data, null, 2)); +} + +/** + * Get the Claude CLI project session directory path. + * Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/ + */ +export function getClaudeProjectSessionsDir(projectDir: string): string { + const resolvedPath = resolve(projectDir); + // Claude CLI encodes the path by replacing '/' and other special chars with '-' + // Based on observed behavior: /Users/takt -> -Users-takt + const encodedPath = resolvedPath.replace(/[/\\_ ]/g, '-'); + return join(homedir(), '.claude', 'projects', encodedPath); +} + +/** + * Clear Claude CLI project sessions. + * Removes all session files (*.jsonl) from the project's session directory. + */ +export function clearClaudeProjectSessions(projectDir: string): void { + const sessionDir = getClaudeProjectSessionsDir(projectDir); + + if (!existsSync(sessionDir)) { + return; + } + + try { + const entries = readdirSync(sessionDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(sessionDir, entry.name); + + // Remove .jsonl session files and sessions-index.json + if (entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'sessions-index.json')) { + try { + unlinkSync(fullPath); + } catch { + // Ignore individual file deletion errors + } + } + + // Remove session subdirectories (some sessions have associated directories) + if (entry.isDirectory()) { + try { + rmSync(fullPath, { recursive: true, force: true }); + } catch { + // Ignore directory deletion errors + } + } + } + } catch { + // Ignore errors if we can't read the directory + } +} diff --git a/src/config/projectConfig.ts b/src/config/projectConfig.ts index 9369414..4170320 100644 --- a/src/config/projectConfig.ts +++ b/src/config/projectConfig.ts @@ -1,131 +1,14 @@ /** - * Project-level configuration management - * - * Manages .takt/config.yaml for project-specific settings. + * Re-export shim — actual implementation in project/projectConfig.ts */ - -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { parse, stringify } from 'yaml'; -import { copyProjectResourcesToDir } from '../resources/index.js'; - -/** Permission mode for the project - * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) - * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) - * - * Note: 'confirm' mode is planned but not yet implemented - */ -export type PermissionMode = 'default' | 'sacrifice-my-pc'; - -/** @deprecated Use PermissionMode instead */ -export type ProjectPermissionMode = PermissionMode; - -/** Project configuration stored in .takt/config.yaml */ -export interface ProjectLocalConfig { - /** Current workflow name */ - workflow?: string; - /** Provider selection for agent runtime */ - provider?: 'claude' | 'codex'; - /** Permission mode setting */ - permissionMode?: PermissionMode; - /** Verbose output mode */ - verbose?: boolean; - /** Custom settings */ - [key: string]: unknown; -} - -/** Default project configuration */ -const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { - workflow: 'default', - permissionMode: 'default', -}; - -/** - * Get project takt config directory (.takt in project) - * Note: Defined locally to avoid circular dependency with paths.ts - */ -function getConfigDir(projectDir: string): string { - return join(resolve(projectDir), '.takt'); -} - -/** - * Get project config file path - * Note: Defined locally to avoid circular dependency with paths.ts - */ -function getConfigPath(projectDir: string): string { - return join(getConfigDir(projectDir), 'config.yaml'); -} - -/** - * Load project configuration from .takt/config.yaml - */ -export function loadProjectConfig(projectDir: string): ProjectLocalConfig { - const configPath = getConfigPath(projectDir); - - if (!existsSync(configPath)) { - return { ...DEFAULT_PROJECT_CONFIG }; - } - - try { - const content = readFileSync(configPath, 'utf-8'); - const parsed = parse(content) as ProjectLocalConfig | null; - return { ...DEFAULT_PROJECT_CONFIG, ...parsed }; - } catch { - return { ...DEFAULT_PROJECT_CONFIG }; - } -} - -/** - * Save project configuration to .takt/config.yaml - */ -export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void { - const configDir = getConfigDir(projectDir); - const configPath = getConfigPath(projectDir); - - // Ensure directory exists - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); - } - - // Copy project resources (only copies files that don't exist) - copyProjectResourcesToDir(configDir); - - const content = stringify(config, { indent: 2 }); - writeFileSync(configPath, content, 'utf-8'); -} - -/** - * Update a single field in project configuration - */ -export function updateProjectConfig( - projectDir: string, - key: K, - value: ProjectLocalConfig[K] -): void { - const config = loadProjectConfig(projectDir); - config[key] = value; - saveProjectConfig(projectDir, config); -} - -/** - * Get current workflow from project config - */ -export function getCurrentWorkflow(projectDir: string): string { - const config = loadProjectConfig(projectDir); - return config.workflow || 'default'; -} - -/** - * Set current workflow in project config - */ -export function setCurrentWorkflow(projectDir: string, workflow: string): void { - updateProjectConfig(projectDir, 'workflow', workflow); -} - -/** - * Get verbose mode from project config - */ -export function isVerboseMode(projectDir: string): boolean { - const config = loadProjectConfig(projectDir); - return config.verbose === true; -} +export { + loadProjectConfig, + saveProjectConfig, + updateProjectConfig, + getCurrentWorkflow, + setCurrentWorkflow, + isVerboseMode, + type PermissionMode, + type ProjectPermissionMode, + type ProjectLocalConfig, +} from './project/projectConfig.js'; diff --git a/src/config/sessionStore.ts b/src/config/sessionStore.ts index 0d50993..c7686ef 100644 --- a/src/config/sessionStore.ts +++ b/src/config/sessionStore.ts @@ -1,322 +1,24 @@ /** - * Session storage for takt - * - * Manages agent sessions and input history persistence. + * Re-export shim — actual implementation in project/sessionStore.ts */ - -import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, rmSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { homedir } from 'node:os'; -import { getProjectConfigDir, ensureDir } from './paths.js'; - -/** - * Write file atomically using temp file + rename. - * This prevents corruption when multiple processes write simultaneously. - */ -export function writeFileAtomic(filePath: string, content: string): void { - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - try { - writeFileSync(tempPath, content, 'utf-8'); - renameSync(tempPath, filePath); - } catch (error) { - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - } catch { - // Ignore cleanup errors - } - throw error; - } -} - -// ============ Input History ============ - -/** Get path for storing input history */ -export function getInputHistoryPath(projectDir: string): string { - return join(getProjectConfigDir(projectDir), 'input_history'); -} - -/** Maximum number of input history entries to keep */ -export const MAX_INPUT_HISTORY = 100; - -/** Load input history */ -export function loadInputHistory(projectDir: string): string[] { - const path = getInputHistoryPath(projectDir); - if (existsSync(path)) { - try { - const content = readFileSync(path, 'utf-8'); - return content - .split('\n') - .filter((line) => line.trim()) - .map((line) => { - try { - return JSON.parse(line) as string; - } catch { - return null; - } - }) - .filter((entry): entry is string => entry !== null); - } catch { - return []; - } - } - return []; -} - -/** Save input history (atomic write) */ -export function saveInputHistory(projectDir: string, history: string[]): void { - const path = getInputHistoryPath(projectDir); - ensureDir(getProjectConfigDir(projectDir)); - const trimmed = history.slice(-MAX_INPUT_HISTORY); - const content = trimmed.map((entry) => JSON.stringify(entry)).join('\n'); - writeFileAtomic(path, content); -} - -/** Add an entry to input history */ -export function addToInputHistory(projectDir: string, input: string): void { - const history = loadInputHistory(projectDir); - if (history[history.length - 1] !== input) { - history.push(input); - } - saveInputHistory(projectDir, history); -} - -// ============ Agent Sessions ============ - -/** Agent session data for persistence */ -export interface AgentSessionData { - agentSessions: Record; - updatedAt: string; - /** Provider that created these sessions (claude, codex, etc.) */ - provider?: string; -} - -/** Get path for storing agent sessions */ -export function getAgentSessionsPath(projectDir: string): string { - return join(getProjectConfigDir(projectDir), 'agent_sessions.json'); -} - -/** Load saved agent sessions. Returns empty if provider has changed. */ -export function loadAgentSessions(projectDir: string, currentProvider?: string): Record { - const path = getAgentSessionsPath(projectDir); - if (existsSync(path)) { - try { - const content = readFileSync(path, 'utf-8'); - const data = JSON.parse(content) as AgentSessionData; - // If provider has changed or is unknown (legacy data), sessions are incompatible — discard them - if (currentProvider && data.provider !== currentProvider) { - return {}; - } - return data.agentSessions || {}; - } catch { - return {}; - } - } - return {}; -} - -/** Save agent sessions (atomic write) */ -export function saveAgentSessions( - projectDir: string, - sessions: Record, - provider?: string -): void { - const path = getAgentSessionsPath(projectDir); - ensureDir(getProjectConfigDir(projectDir)); - const data: AgentSessionData = { - agentSessions: sessions, - updatedAt: new Date().toISOString(), - provider, - }; - writeFileAtomic(path, JSON.stringify(data, null, 2)); -} - -/** - * Update a single agent session atomically. - * Uses read-modify-write with atomic file operations. - */ -export function updateAgentSession( - projectDir: string, - agentName: string, - sessionId: string, - provider?: string -): void { - const path = getAgentSessionsPath(projectDir); - ensureDir(getProjectConfigDir(projectDir)); - - let sessions: Record = {}; - let existingProvider: string | undefined; - if (existsSync(path)) { - try { - const content = readFileSync(path, 'utf-8'); - const data = JSON.parse(content) as AgentSessionData; - existingProvider = data.provider; - // If provider changed, discard old sessions - if (provider && existingProvider && existingProvider !== provider) { - sessions = {}; - } else { - sessions = data.agentSessions || {}; - } - } catch { - sessions = {}; - } - } - - sessions[agentName] = sessionId; - - const data: AgentSessionData = { - agentSessions: sessions, - updatedAt: new Date().toISOString(), - provider: provider ?? existingProvider, - }; - writeFileAtomic(path, JSON.stringify(data, null, 2)); -} - -/** Clear all saved agent sessions */ -export function clearAgentSessions(projectDir: string): void { - const path = getAgentSessionsPath(projectDir); - ensureDir(getProjectConfigDir(projectDir)); - const data: AgentSessionData = { - agentSessions: {}, - updatedAt: new Date().toISOString(), - }; - writeFileAtomic(path, JSON.stringify(data, null, 2)); - - // Also clear Claude CLI project sessions - clearClaudeProjectSessions(projectDir); -} - -// ============ Worktree Sessions ============ - -/** Get the worktree sessions directory */ -export function getWorktreeSessionsDir(projectDir: string): string { - return join(getProjectConfigDir(projectDir), 'worktree-sessions'); -} - -/** Encode a worktree path to a safe filename */ -export function encodeWorktreePath(worktreePath: string): string { - const resolved = resolve(worktreePath); - return resolved.replace(/[/\\:]/g, '-'); -} - -/** Get path for a worktree's session file */ -export function getWorktreeSessionPath(projectDir: string, worktreePath: string): string { - const dir = getWorktreeSessionsDir(projectDir); - const encoded = encodeWorktreePath(worktreePath); - return join(dir, `${encoded}.json`); -} - -/** Load saved agent sessions for a worktree. Returns empty if provider has changed. */ -export function loadWorktreeSessions( - projectDir: string, - worktreePath: string, - currentProvider?: string -): Record { - const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); - if (existsSync(sessionPath)) { - try { - const content = readFileSync(sessionPath, 'utf-8'); - const data = JSON.parse(content) as AgentSessionData; - if (currentProvider && data.provider !== currentProvider) { - return {}; - } - return data.agentSessions || {}; - } catch { - return {}; - } - } - return {}; -} - -/** Update a single agent session for a worktree (atomic) */ -export function updateWorktreeSession( - projectDir: string, - worktreePath: string, - agentName: string, - sessionId: string, - provider?: string -): void { - const dir = getWorktreeSessionsDir(projectDir); - ensureDir(dir); - - const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); - let sessions: Record = {}; - let existingProvider: string | undefined; - - if (existsSync(sessionPath)) { - try { - const content = readFileSync(sessionPath, 'utf-8'); - const data = JSON.parse(content) as AgentSessionData; - existingProvider = data.provider; - if (provider && existingProvider && existingProvider !== provider) { - sessions = {}; - } else { - sessions = data.agentSessions || {}; - } - } catch { - sessions = {}; - } - } - - sessions[agentName] = sessionId; - - const data: AgentSessionData = { - agentSessions: sessions, - updatedAt: new Date().toISOString(), - provider: provider ?? existingProvider, - }; - writeFileAtomic(sessionPath, JSON.stringify(data, null, 2)); -} - -/** - * Get the Claude CLI project session directory path. - * Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/ - */ -export function getClaudeProjectSessionsDir(projectDir: string): string { - const resolvedPath = resolve(projectDir); - // Claude CLI encodes the path by replacing '/' and other special chars with '-' - // Based on observed behavior: /Users/takt -> -Users-takt - const encodedPath = resolvedPath.replace(/[/\\_ ]/g, '-'); - return join(homedir(), '.claude', 'projects', encodedPath); -} - -/** - * Clear Claude CLI project sessions. - * Removes all session files (*.jsonl) from the project's session directory. - */ -export function clearClaudeProjectSessions(projectDir: string): void { - const sessionDir = getClaudeProjectSessionsDir(projectDir); - - if (!existsSync(sessionDir)) { - return; - } - - try { - const entries = readdirSync(sessionDir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(sessionDir, entry.name); - - // Remove .jsonl session files and sessions-index.json - if (entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'sessions-index.json')) { - try { - unlinkSync(fullPath); - } catch { - // Ignore individual file deletion errors - } - } - - // Remove session subdirectories (some sessions have associated directories) - if (entry.isDirectory()) { - try { - rmSync(fullPath, { recursive: true, force: true }); - } catch { - // Ignore directory deletion errors - } - } - } - } catch { - // Ignore errors if we can't read the directory - } -} +export { + writeFileAtomic, + getInputHistoryPath, + MAX_INPUT_HISTORY, + loadInputHistory, + saveInputHistory, + addToInputHistory, + type AgentSessionData, + getAgentSessionsPath, + loadAgentSessions, + saveAgentSessions, + updateAgentSession, + clearAgentSessions, + getWorktreeSessionsDir, + encodeWorktreePath, + getWorktreeSessionPath, + loadWorktreeSessions, + updateWorktreeSession, + getClaudeProjectSessionsDir, + clearClaudeProjectSessions, +} from './project/sessionStore.js'; diff --git a/src/config/workflowLoader.ts b/src/config/workflowLoader.ts index 5008cde..b8b012c 100644 --- a/src/config/workflowLoader.ts +++ b/src/config/workflowLoader.ts @@ -1,449 +1,11 @@ /** - * Workflow configuration loader - * - * 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 + * Re-export shim — actual implementation in loaders/workflowLoader.ts */ - -import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -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, getProjectConfigDir } from './paths.js'; -import { getLanguage, getDisabledBuiltins } from './globalConfig.js'; - -/** Get builtin workflow by name */ -export function getBuiltinWorkflow(name: string): WorkflowConfig | null { - const lang = getLanguage(); - const disabled = getDisabledBuiltins(); - if (disabled.includes(name)) return null; - - const builtinDir = getBuiltinWorkflowsDir(lang); - const yamlPath = join(builtinDir, `${name}.yaml`); - if (existsSync(yamlPath)) { - return loadWorkflowFromFile(yamlPath); - } - return null; -} - -/** - * Resolve agent path from workflow specification. - * - Relative path (./agent.md): relative to workflow directory - * - Absolute path (/path/to/agent.md or ~/...): use as-is - */ -function resolveAgentPathForWorkflow(agentSpec: string, workflowDir: string): string { - // Relative path (starts with ./) - if (agentSpec.startsWith('./')) { - return join(workflowDir, agentSpec.slice(2)); - } - - // Home directory expansion - if (agentSpec.startsWith('~')) { - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - return join(homedir, agentSpec.slice(1)); - } - - // Absolute path - if (agentSpec.startsWith('/')) { - return agentSpec; - } - - // Fallback: treat as relative to workflow directory - return join(workflowDir, agentSpec); -} - -/** - * Extract display name from agent path. - * e.g., "~/.takt/agents/default/coder.md" -> "coder" - */ -function extractAgentDisplayName(agentPath: string): string { - // Get the filename without extension - const filename = basename(agentPath, '.md'); - return filename; -} - -/** - * Resolve a string value that may be a file path. - * If the value ends with .md and the file exists (resolved relative to workflowDir), - * read and return the file contents. Otherwise return the value as-is. - */ -function resolveContentPath(value: string | undefined, workflowDir: string): string | undefined { - if (value == null) return undefined; - if (value.endsWith('.md')) { - // Resolve path relative to workflow directory - let resolvedPath = value; - if (value.startsWith('./')) { - resolvedPath = join(workflowDir, value.slice(2)); - } else if (value.startsWith('~')) { - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - resolvedPath = join(homedir, value.slice(1)); - } else if (!value.startsWith('/')) { - resolvedPath = join(workflowDir, value); - } - if (existsSync(resolvedPath)) { - return readFileSync(resolvedPath, 'utf-8'); - } - } - return value; -} - -/** - * Check if a raw report value is the object form (has 'name' property). - */ -function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } { - return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; -} - -/** - * Normalize the raw report field from YAML into internal format. - * - * YAML formats: - * report: "00-plan.md" → string (single file) - * report: → ReportConfig[] (multiple files) - * - Scope: 01-scope.md - * - Decisions: 02-decisions.md - * report: → ReportObjectConfig (object form) - * name: 00-plan.md - * order: ... - * format: ... - * - * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...] - */ -function normalizeReport( - raw: string | Record[] | { name: string; order?: string; format?: string } | undefined, - workflowDir: string, -): string | ReportConfig[] | ReportObjectConfig | undefined { - if (raw == null) return undefined; - if (typeof raw === 'string') return raw; - if (isReportObject(raw)) { - return { - name: raw.name, - order: resolveContentPath(raw.order, workflowDir), - format: resolveContentPath(raw.format, workflowDir), - }; - } - // Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...] - return (raw as Record[]).flatMap((entry) => - Object.entries(entry).map(([label, path]) => ({ label, path })), - ); -} - -/** Regex to detect ai("...") condition expressions */ -const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/; - -/** Regex to detect all("...")/any("...") aggregate condition expressions */ -const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/; - -/** - * Parse a rule's condition for ai() and all()/any() expressions. - * - `ai("text")` → sets isAiCondition and aiConditionText - * - `all("text")` / `any("text")` → sets isAggregateCondition, aggregateType, aggregateConditionText - */ -function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule { - const aiMatch = r.condition.match(AI_CONDITION_REGEX); - if (aiMatch?.[1]) { - return { - condition: r.condition, - next: r.next, - appendix: r.appendix, - isAiCondition: true, - aiConditionText: aiMatch[1], - }; - } - - const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX); - if (aggMatch?.[1] && aggMatch[2]) { - return { - condition: r.condition, - next: r.next, - appendix: r.appendix, - isAggregateCondition: true, - aggregateType: aggMatch[1] as 'all' | 'any', - aggregateConditionText: aggMatch[2], - }; - } - - return { - condition: r.condition, - next: r.next, - appendix: r.appendix, - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type RawStep = any; - -/** - * Normalize a raw step into internal WorkflowStep format. - */ -function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep { - const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule); - const agentSpec: string = step.agent ?? ''; - - const result: WorkflowStep = { - name: step.name, - agent: agentSpec, - agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name), - agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined, - allowedTools: step.allowed_tools, - provider: step.provider, - model: step.model, - permissionMode: step.permission_mode, - edit: step.edit, - instructionTemplate: resolveContentPath(step.instruction_template, workflowDir) || step.instruction || '{task}', - rules, - report: normalizeReport(step.report, workflowDir), - passPreviousResponse: step.pass_previous_response ?? true, - }; - - if (step.parallel && step.parallel.length > 0) { - result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, workflowDir)); - } - - return result; -} - -/** - * Convert raw YAML workflow config to internal format. - * Agent paths are resolved relative to the workflow directory. - */ -function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig { - const parsed = WorkflowConfigRawSchema.parse(raw); - - const steps: WorkflowStep[] = parsed.steps.map((step) => - normalizeStepFromRaw(step, workflowDir), - ); - - return { - name: parsed.name, - description: parsed.description, - steps, - initialStep: parsed.initial_step || steps[0]?.name || '', - maxIterations: parsed.max_iterations, - answerAgent: parsed.answer_agent, - }; -} - -/** - * Load a workflow from a YAML file. - * @param filePath Path to the workflow YAML file - */ -function loadWorkflowFromFile(filePath: string): WorkflowConfig { - if (!existsSync(filePath)) { - throw new Error(`Workflow file not found: ${filePath}`); - } - const content = readFileSync(filePath, 'utf-8'); - const raw = parseYaml(content); - const workflowDir = dirname(filePath); - return normalizeWorkflowConfig(raw, workflowDir); -} - -/** - * Resolve a path that may be relative, absolute, or home-directory-relative. - * @param pathInput Path to resolve - * @param basePath Base directory for relative paths - * @returns Absolute resolved path - */ -function resolvePath(pathInput: string, basePath: string): 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. - * Called internally by loadWorkflowByIdentifier when the identifier is detected as a path. - * - * @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~) - * @param basePath Base directory for resolving relative paths - * @returns WorkflowConfig or null if file not found - */ -function loadWorkflowFromPath( - filePath: string, - basePath: string -): 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 (for project-local workflow resolution) - */ -export function loadWorkflow( - name: string, - projectCwd: string -): 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); - } - - // 3. Builtin fallback - return getBuiltinWorkflow(name); -} - -/** - * Load all workflows with descriptions (for switch command). - * - * Priority (later entries override earlier): - * 1. Builtin workflows - * 2. User workflows (~/.takt/workflows/) - * 3. Project-local workflows (.takt/workflows/) - */ -export function loadAllWorkflows(cwd: string): Map { - const workflows = new Map(); - const disabled = getDisabledBuiltins(); - - // 1. Builtin workflows (lowest priority) - const lang = getLanguage(); - const builtinDir = getBuiltinWorkflowsDir(lang); - loadWorkflowsFromDir(builtinDir, workflows, disabled); - - // 2. User workflows (overrides builtins) - const globalWorkflowsDir = getGlobalWorkflowsDir(); - loadWorkflowsFromDir(globalWorkflowsDir, workflows); - - // 3. Project-local workflows (highest priority) - const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows'); - loadWorkflowsFromDir(projectWorkflowsDir, workflows); - - return workflows; -} - -/** Load workflow files from a directory into a Map (later calls override earlier entries) */ -function loadWorkflowsFromDir( - dir: string, - target: Map, - disabled?: string[], -): void { - if (!existsSync(dir)) return; - for (const entry of readdirSync(dir)) { - if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; - const entryPath = join(dir, entry); - if (!statSync(entryPath).isFile()) continue; - const workflowName = entry.replace(/\.ya?ml$/, ''); - if (disabled?.includes(workflowName)) continue; - try { - target.set(workflowName, loadWorkflowFromFile(entryPath)); - } catch { - // Skip invalid workflows - } - } -} - -/** - * List available workflow names (builtin + user + project-local, excluding disabled). - * - * @param cwd Project root directory (used to scan project-local .takt/workflows/). - */ -export function listWorkflows(cwd: string): string[] { - const workflows = new Set(); - const disabled = getDisabledBuiltins(); - - // 1. Builtin workflows - const lang = getLanguage(); - const builtinDir = getBuiltinWorkflowsDir(lang); - scanWorkflowDir(builtinDir, workflows, disabled); - - // 2. User workflows - const globalWorkflowsDir = getGlobalWorkflowsDir(); - scanWorkflowDir(globalWorkflowsDir, workflows); - - // 3. Project-local workflows - const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows'); - scanWorkflowDir(projectWorkflowsDir, workflows); - - return Array.from(workflows).sort(); -} - -/** - * Check if a workflow identifier looks like a file path (vs a workflow name). - * - * Path indicators: - * - Starts with `/` (absolute path) - * - Starts with `~` (home directory) - * - Starts with `./` or `../` (relative path) - * - Ends with `.yaml` or `.yml` (file extension) - */ -export function isWorkflowPath(identifier: string): boolean { - return ( - identifier.startsWith('/') || - identifier.startsWith('~') || - identifier.startsWith('./') || - identifier.startsWith('../') || - identifier.endsWith('.yaml') || - identifier.endsWith('.yml') - ); -} - -/** - * Load workflow by identifier (auto-detects name vs path). - * - * If the identifier looks like a path (see isWorkflowPath), loads from file. - * Otherwise, loads by name with the standard priority chain: - * project-local → user → builtin. - * - * @param identifier Workflow name or file path - * @param projectCwd Project root directory (for project-local resolution and relative path base) - */ -export function loadWorkflowByIdentifier( - identifier: string, - projectCwd: string -): WorkflowConfig | null { - if (isWorkflowPath(identifier)) { - return loadWorkflowFromPath(identifier, projectCwd); - } - return loadWorkflow(identifier, projectCwd); -} - -/** Scan a directory for .yaml/.yml files and add names to the set */ -function scanWorkflowDir(dir: string, target: Set, disabled?: string[]): void { - if (!existsSync(dir)) return; - for (const entry of readdirSync(dir)) { - if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; - - const entryPath = join(dir, entry); - if (statSync(entryPath).isFile()) { - const workflowName = entry.replace(/\.ya?ml$/, ''); - if (disabled?.includes(workflowName)) continue; - target.add(workflowName); - } - } -} +export { + getBuiltinWorkflow, + loadWorkflow, + loadWorkflowByIdentifier, + isWorkflowPath, + loadAllWorkflows, + listWorkflows, +} from './loaders/workflowLoader.js'; diff --git a/src/context.ts b/src/context.ts index 4943222..69f5af1 100644 --- a/src/context.ts +++ b/src/context.ts @@ -3,17 +3,47 @@ * * Holds process-wide state (quiet mode, etc.) that would otherwise * create circular dependencies if exported from cli.ts. + * + * AppContext is a singleton — use AppContext.getInstance() or + * the module-level convenience functions isQuietMode / setQuietMode. */ -/** Whether quiet mode is active (set during CLI initialization) */ -let quietMode = false; +export class AppContext { + private static instance: AppContext | null = null; + + private quietMode = false; + + private constructor() {} + + static getInstance(): AppContext { + if (!AppContext.instance) { + AppContext.instance = new AppContext(); + } + return AppContext.instance; + } + + /** Reset singleton for testing */ + static resetInstance(): void { + AppContext.instance = null; + } + + getQuietMode(): boolean { + return this.quietMode; + } + + setQuietMode(value: boolean): void { + this.quietMode = value; + } +} + +// ---- Backward-compatible module-level functions ---- /** Get whether quiet mode is active (CLI flag or config, resolved in preAction) */ export function isQuietMode(): boolean { - return quietMode; + return AppContext.getInstance().getQuietMode(); } /** Set quiet mode state. Called from CLI preAction hook. */ export function setQuietMode(value: boolean): void { - quietMode = value; + AppContext.getInstance().setQuietMode(value); } diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index 235756a..08a6b3d 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -1,38 +1,14 @@ /** - * Workflow execution engine + * Re-export shim for backward compatibility. + * + * The actual implementation has been split into: + * - engine/WorkflowEngine.ts — Main orchestration loop + * - engine/StepExecutor.ts — Single-step 3-phase execution + * - engine/ParallelRunner.ts — Parallel step execution + * - engine/OptionsBuilder.ts — RunAgentOptions construction */ -import { EventEmitter } from 'node:events'; -import { mkdirSync, existsSync, symlinkSync } from 'node:fs'; -import { join } from 'node:path'; -import type { - WorkflowConfig, - WorkflowState, - WorkflowStep, - AgentResponse, -} from '../models/types.js'; -import { runAgent, type RunAgentOptions } from '../agents/runner.js'; -import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js'; -import type { WorkflowEngineOptions } from './types.js'; -import { determineNextStepByRules } from './transitions.js'; -import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js'; -import { LoopDetector } from './loop-detector.js'; -import { handleBlocked } from './blocked-handler.js'; -import { ParallelLogger } from './parallel-logger.js'; -import { detectMatchedRule } from './rule-evaluator.js'; -import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from './phase-runner.js'; -import { - createInitialState, - addUserInput, - getPreviousOutput, - incrementStepIteration, -} from './state-manager.js'; -import { generateReportDir } from '../utils/session.js'; -import { getErrorMessage } from '../utils/error.js'; -import { createLogger } from '../utils/debug.js'; -import { interruptAllQueries } from '../claude/query-manager.js'; - -const log = createLogger('engine'); +export { WorkflowEngine } from './engine/WorkflowEngine.js'; // Re-export types for backward compatibility export type { @@ -44,562 +20,3 @@ export type { WorkflowEngineOptions, } from './types.js'; export { COMPLETE_STEP, ABORT_STEP } from './constants.js'; - -/** Workflow engine for orchestrating agent execution */ -export class WorkflowEngine extends EventEmitter { - private state: WorkflowState; - private config: WorkflowConfig; - private projectCwd: string; - private cwd: string; - private task: string; - private options: WorkflowEngineOptions; - private loopDetector: LoopDetector; - private language: WorkflowEngineOptions['language']; - private reportDir: string; - private abortRequested = false; - - constructor(config: WorkflowConfig, cwd: string, task: string, options: WorkflowEngineOptions) { - super(); - this.config = config; - this.projectCwd = options.projectCwd; - this.cwd = cwd; - this.task = task; - this.options = options; - this.language = options.language; - this.loopDetector = new LoopDetector(config.loopDetection); - this.reportDir = `.takt/reports/${generateReportDir(task)}`; - this.ensureReportDirExists(); - this.validateConfig(); - this.state = createInitialState(config, options); - log.debug('WorkflowEngine initialized', { - workflow: config.name, - steps: config.steps.map(s => s.name), - initialStep: config.initialStep, - maxIterations: config.maxIterations, - }); - } - - /** Ensure report directory exists (always in project root, not clone) */ - private ensureReportDirExists(): void { - const reportDirPath = join(this.projectCwd, this.reportDir); - if (!existsSync(reportDirPath)) { - mkdirSync(reportDirPath, { recursive: true }); - } - - // Worktree mode: create symlink so agents can access reports via relative path - if (this.cwd !== this.projectCwd) { - const cwdReportsDir = join(this.cwd, '.takt', 'reports'); - if (!existsSync(cwdReportsDir)) { - mkdirSync(join(this.cwd, '.takt'), { recursive: true }); - symlinkSync( - join(this.projectCwd, '.takt', 'reports'), - cwdReportsDir, - ); - } - } - } - - /** Validate workflow configuration at construction time */ - private validateConfig(): void { - const initialStep = this.config.steps.find((s) => s.name === this.config.initialStep); - if (!initialStep) { - throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(this.config.initialStep)); - } - - const stepNames = new Set(this.config.steps.map((s) => s.name)); - stepNames.add(COMPLETE_STEP); - stepNames.add(ABORT_STEP); - - for (const step of this.config.steps) { - if (step.rules) { - for (const rule of step.rules) { - if (rule.next && !stepNames.has(rule.next)) { - throw new Error( - `Invalid rule in step "${step.name}": target step "${rule.next}" does not exist` - ); - } - } - } - } - } - - /** Get current workflow state */ - getState(): WorkflowState { - return { ...this.state }; - } - - /** Add user input */ - addUserInput(input: string): void { - addUserInput(this.state, input); - } - - /** Update working directory */ - updateCwd(newCwd: string): void { - this.cwd = newCwd; - } - - /** Get current working directory */ - getCwd(): string { - return this.cwd; - } - - /** Get project root directory (where .takt/ lives) */ - getProjectCwd(): string { - return this.projectCwd; - } - - /** Request graceful abort: interrupt running queries and stop after current step */ - abort(): void { - if (this.abortRequested) return; - this.abortRequested = true; - log.info('Abort requested'); - interruptAllQueries(); - } - - /** Check if abort has been requested */ - isAbortRequested(): boolean { - return this.abortRequested; - } - - /** Build instruction from template */ - private buildInstruction(step: WorkflowStep, stepIteration: number): string { - return buildInstructionFromTemplate(step, { - task: this.task, - iteration: this.state.iteration, - maxIterations: this.config.maxIterations, - stepIteration, - cwd: this.cwd, - projectCwd: this.projectCwd, - userInputs: this.state.userInputs, - previousOutput: getPreviousOutput(this.state), - reportDir: join(this.cwd, this.reportDir), - language: this.language, - }); - } - - /** Get step by name */ - private getStep(name: string): WorkflowStep { - const step = this.config.steps.find((s) => s.name === name); - if (!step) { - throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(name)); - } - return step; - } - - /** - * Emit step:report events for each report file that exists after step completion. - * The UI layer (workflowExecution.ts) listens and displays the content. - */ - private emitStepReports(step: WorkflowStep): void { - if (!step.report || !this.reportDir) return; - const baseDir = join(this.projectCwd, this.reportDir); - - if (typeof step.report === 'string') { - this.emitIfReportExists(step, baseDir, step.report); - } else if (isReportObjectConfig(step.report)) { - this.emitIfReportExists(step, baseDir, step.report.name); - } else { - // ReportConfig[] (array) - for (const rc of step.report) { - this.emitIfReportExists(step, baseDir, rc.path); - } - } - } - - /** Emit step:report if the report file exists */ - private emitIfReportExists(step: WorkflowStep, baseDir: string, fileName: string): void { - const filePath = join(baseDir, fileName); - if (existsSync(filePath)) { - this.emit('step:report', step, filePath, fileName); - } - } - - /** Run a single step (delegates to runParallelStep if step has parallel sub-steps) */ - private async runStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { - if (step.parallel && step.parallel.length > 0) { - return this.runParallelStep(step); - } - return this.runNormalStep(step, prebuiltInstruction); - } - - /** Build common RunAgentOptions shared by all phases */ - private buildBaseOptions(step: WorkflowStep): RunAgentOptions { - return { - cwd: this.cwd, - agentPath: step.agentPath, - provider: step.provider ?? this.options.provider, - model: step.model ?? this.options.model, - permissionMode: step.permissionMode, - onStream: this.options.onStream, - onPermissionRequest: this.options.onPermissionRequest, - onAskUserQuestion: this.options.onAskUserQuestion, - bypassPermissions: this.options.bypassPermissions, - }; - } - - /** Build RunAgentOptions from a step's configuration (Phase 1) */ - private buildAgentOptions(step: WorkflowStep): RunAgentOptions { - // Phase 1: exclude Write from allowedTools when step has report config - const allowedTools = step.report - ? step.allowedTools?.filter((t) => t !== 'Write') - : step.allowedTools; - - return { - ...this.buildBaseOptions(step), - sessionId: this.state.agentSessions.get(step.agent), - allowedTools, - }; - } - - /** - * Build RunAgentOptions for session-resume phases (Phase 2, Phase 3). - */ - private buildResumeOptions(step: WorkflowStep, sessionId: string, overrides: Pick): RunAgentOptions { - return { - ...this.buildBaseOptions(step), - sessionId, - allowedTools: overrides.allowedTools, - maxTurns: overrides.maxTurns, - }; - } - - /** Update agent session and notify via callback if session changed */ - private updateAgentSession(agent: string, sessionId: string | undefined): void { - if (!sessionId) return; - - const previousSessionId = this.state.agentSessions.get(agent); - this.state.agentSessions.set(agent, sessionId); - - if (this.options.onSessionUpdate && sessionId !== previousSessionId) { - this.options.onSessionUpdate(agent, sessionId); - } - } - - /** Build phase runner context for Phase 2/3 execution */ - private buildPhaseRunnerContext() { - return { - cwd: this.cwd, - reportDir: join(this.cwd, this.reportDir), - language: this.language, - getSessionId: (agent: string) => this.state.agentSessions.get(agent), - buildResumeOptions: this.buildResumeOptions.bind(this), - updateAgentSession: this.updateAgentSession.bind(this), - }; - } - - /** Run a normal (non-parallel) step */ - private async runNormalStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { - const stepIteration = prebuiltInstruction - ? this.state.stepIterations.get(step.name) ?? 1 - : incrementStepIteration(this.state, step.name); - const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration); - log.debug('Running step', { - step: step.name, - agent: step.agent, - stepIteration, - iteration: this.state.iteration, - sessionId: this.state.agentSessions.get(step.agent) ?? 'new', - }); - - // Phase 1: main execution (Write excluded if step has report) - const agentOptions = this.buildAgentOptions(step); - let response = await runAgent(step.agent, instruction, agentOptions); - this.updateAgentSession(step.agent, response.sessionId); - - const phaseCtx = this.buildPhaseRunnerContext(); - - // Phase 2: report output (resume same session, Write only) - if (step.report) { - await runReportPhase(step, stepIteration, phaseCtx); - } - - // Phase 3: status judgment (resume session, no tools, output status tag) - let tagContent = ''; - if (needsStatusJudgmentPhase(step)) { - tagContent = await runStatusJudgmentPhase(step, phaseCtx); - } - - const match = await detectMatchedRule(step, response.content, tagContent, { - state: this.state, - cwd: this.cwd, - }); - if (match) { - log.debug('Rule matched', { step: step.name, ruleIndex: match.index, method: match.method }); - response = { ...response, matchedRuleIndex: match.index, matchedRuleMethod: match.method }; - } - - this.state.stepOutputs.set(step.name, response); - this.emitStepReports(step); - return { response, instruction }; - } - - /** - * Run a parallel step: execute all sub-steps concurrently, then aggregate results. - * The aggregated output becomes the parent step's response for rules evaluation. - * - * When onStream is provided, uses ParallelLogger to prefix each sub-step's - * output with `[name]` for readable interleaved display. - */ - private async runParallelStep(step: WorkflowStep): Promise<{ response: AgentResponse; instruction: string }> { - const subSteps = step.parallel!; - const stepIteration = incrementStepIteration(this.state, step.name); - log.debug('Running parallel step', { - step: step.name, - subSteps: subSteps.map(s => s.name), - stepIteration, - }); - - // Create parallel logger for prefixed output (only when streaming is enabled) - const parallelLogger = this.options.onStream - ? new ParallelLogger({ - subStepNames: subSteps.map((s) => s.name), - parentOnStream: this.options.onStream, - }) - : undefined; - - const phaseCtx = this.buildPhaseRunnerContext(); - const ruleCtx = { state: this.state, cwd: this.cwd }; - - // Run all sub-steps concurrently - const subResults = await Promise.all( - subSteps.map(async (subStep, index) => { - const subIteration = incrementStepIteration(this.state, subStep.name); - const subInstruction = this.buildInstruction(subStep, subIteration); - - // Phase 1: main execution (Write excluded if sub-step has report) - const baseOptions = this.buildAgentOptions(subStep); - - // Override onStream with parallel logger's prefixed handler (immutable) - const agentOptions = parallelLogger - ? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subStep.name, index) } - : baseOptions; - - const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions); - this.updateAgentSession(subStep.agent, subResponse.sessionId); - - // Phase 2: report output for sub-step - if (subStep.report) { - await runReportPhase(subStep, subIteration, phaseCtx); - } - - // Phase 3: status judgment for sub-step - let subTagContent = ''; - if (needsStatusJudgmentPhase(subStep)) { - subTagContent = await runStatusJudgmentPhase(subStep, phaseCtx); - } - - const match = await detectMatchedRule(subStep, subResponse.content, subTagContent, ruleCtx); - const finalResponse = match - ? { ...subResponse, matchedRuleIndex: match.index, matchedRuleMethod: match.method } - : subResponse; - - this.state.stepOutputs.set(subStep.name, finalResponse); - this.emitStepReports(subStep); - - return { subStep, response: finalResponse, instruction: subInstruction }; - }), - ); - - // Print completion summary - if (parallelLogger) { - parallelLogger.printSummary( - step.name, - subResults.map((r) => ({ - name: r.subStep.name, - condition: r.response.matchedRuleIndex != null && r.subStep.rules - ? r.subStep.rules[r.response.matchedRuleIndex]?.condition - : undefined, - })), - ); - } - - // Aggregate sub-step outputs into parent step's response - const aggregatedContent = subResults - .map((r) => `## ${r.subStep.name}\n${r.response.content}`) - .join('\n\n---\n\n'); - - const aggregatedInstruction = subResults - .map((r) => r.instruction) - .join('\n\n'); - - // Parent step uses aggregate conditions, so tagContent is empty - const match = await detectMatchedRule(step, aggregatedContent, '', ruleCtx); - - const aggregatedResponse: AgentResponse = { - agent: step.name, - status: 'done', - content: aggregatedContent, - timestamp: new Date(), - ...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }), - }; - - this.state.stepOutputs.set(step.name, aggregatedResponse); - this.emitStepReports(step); - return { response: aggregatedResponse, instruction: aggregatedInstruction }; - } - - /** - * Determine next step for a completed step using rules-based routing. - */ - private resolveNextStep(step: WorkflowStep, response: AgentResponse): string { - if (response.matchedRuleIndex != null && step.rules) { - const nextByRules = determineNextStepByRules(step, response.matchedRuleIndex); - if (nextByRules) { - return nextByRules; - } - } - - throw new Error(`No matching rule found for step "${step.name}" (status: ${response.status})`); - } - - /** Run the workflow to completion */ - async run(): Promise { - while (this.state.status === 'running') { - if (this.abortRequested) { - this.state.status = 'aborted'; - this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)'); - break; - } - - if (this.state.iteration >= this.config.maxIterations) { - this.emit('iteration:limit', this.state.iteration, this.config.maxIterations); - - if (this.options.onIterationLimit) { - const additionalIterations = await this.options.onIterationLimit({ - currentIteration: this.state.iteration, - maxIterations: this.config.maxIterations, - currentStep: this.state.currentStep, - }); - - if (additionalIterations !== null && additionalIterations > 0) { - this.config = { - ...this.config, - maxIterations: this.config.maxIterations + additionalIterations, - }; - continue; - } - } - - this.state.status = 'aborted'; - this.emit('workflow:abort', this.state, ERROR_MESSAGES.MAX_ITERATIONS_REACHED); - break; - } - - const step = this.getStep(this.state.currentStep); - const loopCheck = this.loopDetector.check(step.name); - - if (loopCheck.shouldWarn) { - this.emit('step:loop_detected', step, loopCheck.count); - } - - if (loopCheck.shouldAbort) { - this.state.status = 'aborted'; - this.emit('workflow:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count)); - break; - } - - this.state.iteration++; - - // Build instruction before emitting step:start so listeners can log it - const isParallel = step.parallel && step.parallel.length > 0; - let prebuiltInstruction: string | undefined; - if (!isParallel) { - const stepIteration = incrementStepIteration(this.state, step.name); - prebuiltInstruction = this.buildInstruction(step, stepIteration); - } - this.emit('step:start', step, this.state.iteration, prebuiltInstruction ?? ''); - - try { - const { response, instruction } = await this.runStep(step, prebuiltInstruction); - this.emit('step:complete', step, response, instruction); - - if (response.status === 'blocked') { - this.emit('step:blocked', step, response); - const result = await handleBlocked(step, response, this.options); - - if (result.shouldContinue && result.userInput) { - this.addUserInput(result.userInput); - this.emit('step:user_input', step, result.userInput); - continue; - } - - this.state.status = 'aborted'; - this.emit('workflow:abort', this.state, 'Workflow blocked and no user input provided'); - break; - } - - const nextStep = this.resolveNextStep(step, response); - log.debug('Step transition', { - from: step.name, - status: response.status, - matchedRuleIndex: response.matchedRuleIndex, - nextStep, - }); - - if (nextStep === COMPLETE_STEP) { - this.state.status = 'completed'; - this.emit('workflow:complete', this.state); - break; - } - - if (nextStep === ABORT_STEP) { - this.state.status = 'aborted'; - this.emit('workflow:abort', this.state, 'Workflow aborted by step transition'); - break; - } - - this.state.currentStep = nextStep; - } catch (error) { - this.state.status = 'aborted'; - if (this.abortRequested) { - this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)'); - } else { - const message = getErrorMessage(error); - this.emit('workflow:abort', this.state, ERROR_MESSAGES.STEP_EXECUTION_FAILED(message)); - } - break; - } - } - - return this.state; - } - - /** Run a single iteration (for interactive mode) */ - async runSingleIteration(): Promise<{ - response: AgentResponse; - nextStep: string; - isComplete: boolean; - loopDetected?: boolean; - }> { - const step = this.getStep(this.state.currentStep); - const loopCheck = this.loopDetector.check(step.name); - - if (loopCheck.shouldAbort) { - this.state.status = 'aborted'; - return { - response: { - agent: step.agent, - status: 'blocked', - content: ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count), - timestamp: new Date(), - }, - nextStep: ABORT_STEP, - isComplete: true, - loopDetected: true, - }; - } - - this.state.iteration++; - const { response } = await this.runStep(step); - const nextStep = this.resolveNextStep(step, response); - const isComplete = nextStep === COMPLETE_STEP || nextStep === ABORT_STEP; - - if (!isComplete) { - this.state.currentStep = nextStep; - } else { - this.state.status = nextStep === COMPLETE_STEP ? 'completed' : 'aborted'; - } - - return { response, nextStep, isComplete, loopDetected: loopCheck.isLoop }; - } -} diff --git a/src/workflow/engine/OptionsBuilder.ts b/src/workflow/engine/OptionsBuilder.ts new file mode 100644 index 0000000..ce247ac --- /dev/null +++ b/src/workflow/engine/OptionsBuilder.ts @@ -0,0 +1,80 @@ +/** + * Builds RunAgentOptions for different execution phases. + * + * Centralizes the option construction logic that was previously + * scattered across WorkflowEngine methods. + */ + +import { join } from 'node:path'; +import type { WorkflowStep, WorkflowState, Language } from '../../models/types.js'; +import type { RunAgentOptions } from '../../agents/runner.js'; +import type { PhaseRunnerContext } from '../phase-runner.js'; +import type { WorkflowEngineOptions } from '../types.js'; + +export class OptionsBuilder { + constructor( + private readonly engineOptions: WorkflowEngineOptions, + private readonly getCwd: () => string, + private readonly getSessionId: (agent: string) => string | undefined, + private readonly getReportDir: () => string, + private readonly getLanguage: () => Language | undefined, + ) {} + + /** Build common RunAgentOptions shared by all phases */ + buildBaseOptions(step: WorkflowStep): RunAgentOptions { + return { + cwd: this.getCwd(), + agentPath: step.agentPath, + provider: step.provider ?? this.engineOptions.provider, + model: step.model ?? this.engineOptions.model, + permissionMode: step.permissionMode, + onStream: this.engineOptions.onStream, + onPermissionRequest: this.engineOptions.onPermissionRequest, + onAskUserQuestion: this.engineOptions.onAskUserQuestion, + bypassPermissions: this.engineOptions.bypassPermissions, + }; + } + + /** Build RunAgentOptions for Phase 1 (main execution) */ + buildAgentOptions(step: WorkflowStep): RunAgentOptions { + // Phase 1: exclude Write from allowedTools when step has report config + const allowedTools = step.report + ? step.allowedTools?.filter((t) => t !== 'Write') + : step.allowedTools; + + return { + ...this.buildBaseOptions(step), + sessionId: this.getSessionId(step.agent), + allowedTools, + }; + } + + /** Build RunAgentOptions for session-resume phases (Phase 2, Phase 3) */ + buildResumeOptions( + step: WorkflowStep, + sessionId: string, + overrides: Pick, + ): RunAgentOptions { + return { + ...this.buildBaseOptions(step), + sessionId, + allowedTools: overrides.allowedTools, + maxTurns: overrides.maxTurns, + }; + } + + /** Build PhaseRunnerContext for Phase 2/3 execution */ + buildPhaseRunnerContext( + state: WorkflowState, + updateAgentSession: (agent: string, sessionId: string | undefined) => void, + ): PhaseRunnerContext { + return { + cwd: this.getCwd(), + reportDir: join(this.getCwd(), this.getReportDir()), + language: this.getLanguage(), + getSessionId: (agent: string) => state.agentSessions.get(agent), + buildResumeOptions: this.buildResumeOptions.bind(this), + updateAgentSession, + }; + } +} diff --git a/src/workflow/engine/ParallelRunner.ts b/src/workflow/engine/ParallelRunner.ts new file mode 100644 index 0000000..459973d --- /dev/null +++ b/src/workflow/engine/ParallelRunner.ts @@ -0,0 +1,146 @@ +/** + * Executes parallel workflow steps concurrently and aggregates results. + * + * When onStream is provided, uses ParallelLogger to prefix each + * sub-step's output with `[name]` for readable interleaved display. + */ + +import type { + WorkflowStep, + WorkflowState, + AgentResponse, +} from '../../models/types.js'; +import { runAgent } from '../../agents/runner.js'; +import { ParallelLogger } from '../parallel-logger.js'; +import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js'; +import { detectMatchedRule } from '../rule-evaluator.js'; +import { incrementStepIteration } from '../state-manager.js'; +import { createLogger } from '../../utils/debug.js'; +import type { OptionsBuilder } from './OptionsBuilder.js'; +import type { StepExecutor } from './StepExecutor.js'; +import type { WorkflowEngineOptions } from '../types.js'; + +const log = createLogger('parallel-runner'); + +export interface ParallelRunnerDeps { + readonly optionsBuilder: OptionsBuilder; + readonly stepExecutor: StepExecutor; + readonly engineOptions: WorkflowEngineOptions; + readonly getCwd: () => string; + readonly getReportDir: () => string; +} + +export class ParallelRunner { + constructor( + private readonly deps: ParallelRunnerDeps, + ) {} + + /** + * Run a parallel step: execute all sub-steps concurrently, then aggregate results. + * The aggregated output becomes the parent step's response for rules evaluation. + */ + async runParallelStep( + step: WorkflowStep, + state: WorkflowState, + task: string, + maxIterations: number, + updateAgentSession: (agent: string, sessionId: string | undefined) => void, + ): Promise<{ response: AgentResponse; instruction: string }> { + const subSteps = step.parallel!; + const stepIteration = incrementStepIteration(state, step.name); + log.debug('Running parallel step', { + step: step.name, + subSteps: subSteps.map(s => s.name), + stepIteration, + }); + + // Create parallel logger for prefixed output (only when streaming is enabled) + const parallelLogger = this.deps.engineOptions.onStream + ? new ParallelLogger({ + subStepNames: subSteps.map((s) => s.name), + parentOnStream: this.deps.engineOptions.onStream, + }) + : undefined; + + const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession); + const ruleCtx = { state, cwd: this.deps.getCwd() }; + + // Run all sub-steps concurrently + const subResults = await Promise.all( + subSteps.map(async (subStep, index) => { + const subIteration = incrementStepIteration(state, subStep.name); + const subInstruction = this.deps.stepExecutor.buildInstruction(subStep, subIteration, state, task, maxIterations); + + // Phase 1: main execution (Write excluded if sub-step has report) + const baseOptions = this.deps.optionsBuilder.buildAgentOptions(subStep); + + // Override onStream with parallel logger's prefixed handler (immutable) + const agentOptions = parallelLogger + ? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subStep.name, index) } + : baseOptions; + + const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions); + updateAgentSession(subStep.agent, subResponse.sessionId); + + // Phase 2: report output for sub-step + if (subStep.report) { + await runReportPhase(subStep, subIteration, phaseCtx); + } + + // Phase 3: status judgment for sub-step + let subTagContent = ''; + if (needsStatusJudgmentPhase(subStep)) { + subTagContent = await runStatusJudgmentPhase(subStep, phaseCtx); + } + + const match = await detectMatchedRule(subStep, subResponse.content, subTagContent, ruleCtx); + const finalResponse = match + ? { ...subResponse, matchedRuleIndex: match.index, matchedRuleMethod: match.method } + : subResponse; + + state.stepOutputs.set(subStep.name, finalResponse); + this.deps.stepExecutor.emitStepReports(subStep); + + return { subStep, response: finalResponse, instruction: subInstruction }; + }), + ); + + // Print completion summary + if (parallelLogger) { + parallelLogger.printSummary( + step.name, + subResults.map((r) => ({ + name: r.subStep.name, + condition: r.response.matchedRuleIndex != null && r.subStep.rules + ? r.subStep.rules[r.response.matchedRuleIndex]?.condition + : undefined, + })), + ); + } + + // Aggregate sub-step outputs into parent step's response + const aggregatedContent = subResults + .map((r) => `## ${r.subStep.name}\n${r.response.content}`) + .join('\n\n---\n\n'); + + const aggregatedInstruction = subResults + .map((r) => r.instruction) + .join('\n\n'); + + // Parent step uses aggregate conditions, so tagContent is empty + const match = await detectMatchedRule(step, aggregatedContent, '', ruleCtx); + + const aggregatedResponse: AgentResponse = { + agent: step.name, + status: 'done', + content: aggregatedContent, + timestamp: new Date(), + ...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }), + }; + + state.stepOutputs.set(step.name, aggregatedResponse); + this.deps.stepExecutor.emitStepReports(step); + return { response: aggregatedResponse, instruction: aggregatedInstruction }; + } + +} diff --git a/src/workflow/engine/StepExecutor.ts b/src/workflow/engine/StepExecutor.ts new file mode 100644 index 0000000..db5edea --- /dev/null +++ b/src/workflow/engine/StepExecutor.ts @@ -0,0 +1,155 @@ +/** + * Executes a single workflow step through the 3-phase model. + * + * Phase 1: Main agent execution (with tools) + * Phase 2: Report output (Write-only, optional) + * Phase 3: Status judgment (no tools, optional) + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { + WorkflowStep, + WorkflowState, + AgentResponse, + Language, +} from '../../models/types.js'; +import { runAgent } from '../../agents/runner.js'; +import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from '../instruction-builder.js'; +import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js'; +import { detectMatchedRule } from '../rule-evaluator.js'; +import { incrementStepIteration, getPreviousOutput } from '../state-manager.js'; +import { createLogger } from '../../utils/debug.js'; +import type { OptionsBuilder } from './OptionsBuilder.js'; + +const log = createLogger('step-executor'); + +export interface StepExecutorDeps { + readonly optionsBuilder: OptionsBuilder; + readonly getCwd: () => string; + readonly getProjectCwd: () => string; + readonly getReportDir: () => string; + readonly getLanguage: () => Language | undefined; +} + +export class StepExecutor { + constructor( + private readonly deps: StepExecutorDeps, + ) {} + + /** Build Phase 1 instruction from template */ + buildInstruction( + step: WorkflowStep, + stepIteration: number, + state: WorkflowState, + task: string, + maxIterations: number, + ): string { + return buildInstructionFromTemplate(step, { + task, + iteration: state.iteration, + maxIterations, + stepIteration, + cwd: this.deps.getCwd(), + projectCwd: this.deps.getProjectCwd(), + userInputs: state.userInputs, + previousOutput: getPreviousOutput(state), + reportDir: join(this.deps.getCwd(), this.deps.getReportDir()), + language: this.deps.getLanguage(), + }); + } + + /** + * Execute a normal (non-parallel) step through all 3 phases. + * + * Returns the final response (with matchedRuleIndex if a rule matched) + * and the instruction used for Phase 1. + */ + async runNormalStep( + step: WorkflowStep, + state: WorkflowState, + task: string, + maxIterations: number, + updateAgentSession: (agent: string, sessionId: string | undefined) => void, + prebuiltInstruction?: string, + ): Promise<{ response: AgentResponse; instruction: string }> { + const stepIteration = prebuiltInstruction + ? state.stepIterations.get(step.name) ?? 1 + : incrementStepIteration(state, step.name); + const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration, state, task, maxIterations); + log.debug('Running step', { + step: step.name, + agent: step.agent, + stepIteration, + iteration: state.iteration, + sessionId: state.agentSessions.get(step.agent) ?? 'new', + }); + + // Phase 1: main execution (Write excluded if step has report) + const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step); + let response = await runAgent(step.agent, instruction, agentOptions); + updateAgentSession(step.agent, response.sessionId); + + const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession); + + // Phase 2: report output (resume same session, Write only) + if (step.report) { + await runReportPhase(step, stepIteration, phaseCtx); + } + + // Phase 3: status judgment (resume session, no tools, output status tag) + let tagContent = ''; + if (needsStatusJudgmentPhase(step)) { + tagContent = await runStatusJudgmentPhase(step, phaseCtx); + } + + const match = await detectMatchedRule(step, response.content, tagContent, { + state, + cwd: this.deps.getCwd(), + }); + if (match) { + log.debug('Rule matched', { step: step.name, ruleIndex: match.index, method: match.method }); + response = { ...response, matchedRuleIndex: match.index, matchedRuleMethod: match.method }; + } + + state.stepOutputs.set(step.name, response); + this.emitStepReports(step); + return { response, instruction }; + } + + /** Emit step:report events for each report file that exists */ + emitStepReports(step: WorkflowStep): void { + if (!step.report) return; + const baseDir = join(this.deps.getProjectCwd(), this.deps.getReportDir()); + + if (typeof step.report === 'string') { + this.checkReportFile(step, baseDir, step.report); + } else if (isReportObjectConfig(step.report)) { + this.checkReportFile(step, baseDir, step.report.name); + } else { + // ReportConfig[] (array) + for (const rc of step.report) { + this.checkReportFile(step, baseDir, rc.path); + } + } + } + + // Collects report file paths that exist (used by WorkflowEngine to emit events) + private reportFiles: Array<{ step: WorkflowStep; filePath: string; fileName: string }> = []; + + /** Check if report file exists and collect for emission */ + private checkReportFile(step: WorkflowStep, baseDir: string, fileName: string): void { + const filePath = join(baseDir, fileName); + if (existsSync(filePath)) { + this.reportFiles.push({ step, filePath, fileName }); + } + } + + /** Drain collected report files (called by engine after step execution) */ + drainReportFiles(): Array<{ step: WorkflowStep; filePath: string; fileName: string }> { + const files = this.reportFiles; + this.reportFiles = []; + return files; + } + +} diff --git a/src/workflow/engine/WorkflowEngine.ts b/src/workflow/engine/WorkflowEngine.ts new file mode 100644 index 0000000..d97a769 --- /dev/null +++ b/src/workflow/engine/WorkflowEngine.ts @@ -0,0 +1,413 @@ +/** + * Workflow execution engine. + * + * Orchestrates the main execution loop: step transitions, abort handling, + * loop detection, and iteration limits. Delegates step execution to + * StepExecutor (normal steps) and ParallelRunner (parallel steps). + */ + +import { EventEmitter } from 'node:events'; +import { mkdirSync, existsSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import type { + WorkflowConfig, + WorkflowState, + WorkflowStep, + AgentResponse, +} from '../../models/types.js'; +import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from '../constants.js'; +import type { WorkflowEngineOptions } from '../types.js'; +import { determineNextStepByRules } from '../transitions.js'; +import { LoopDetector } from '../loop-detector.js'; +import { handleBlocked } from '../blocked-handler.js'; +import { + createInitialState, + addUserInput as addUserInputToState, + incrementStepIteration, +} from '../state-manager.js'; +import { generateReportDir } from '../../utils/session.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { createLogger } from '../../utils/debug.js'; +import { interruptAllQueries } from '../../claude/query-manager.js'; +import { OptionsBuilder } from './OptionsBuilder.js'; +import { StepExecutor } from './StepExecutor.js'; +import { ParallelRunner } from './ParallelRunner.js'; + +const log = createLogger('engine'); + +// Re-export types for backward compatibility +export type { + WorkflowEvents, + UserInputRequest, + IterationLimitRequest, + SessionUpdateCallback, + IterationLimitCallback, + WorkflowEngineOptions, +} from '../types.js'; +export { COMPLETE_STEP, ABORT_STEP } from '../constants.js'; + +/** Workflow engine for orchestrating agent execution */ +export class WorkflowEngine extends EventEmitter { + private state: WorkflowState; + private config: WorkflowConfig; + private projectCwd: string; + private cwd: string; + private task: string; + private options: WorkflowEngineOptions; + private loopDetector: LoopDetector; + private reportDir: string; + private abortRequested = false; + + private readonly optionsBuilder: OptionsBuilder; + private readonly stepExecutor: StepExecutor; + private readonly parallelRunner: ParallelRunner; + + constructor(config: WorkflowConfig, cwd: string, task: string, options: WorkflowEngineOptions) { + super(); + this.config = config; + this.projectCwd = options.projectCwd; + this.cwd = cwd; + this.task = task; + this.options = options; + this.loopDetector = new LoopDetector(config.loopDetection); + this.reportDir = `.takt/reports/${generateReportDir(task)}`; + this.ensureReportDirExists(); + this.validateConfig(); + this.state = createInitialState(config, options); + + // Initialize composed collaborators + this.optionsBuilder = new OptionsBuilder( + options, + () => this.cwd, + (agent) => this.state.agentSessions.get(agent), + () => this.reportDir, + () => this.options.language, + ); + + this.stepExecutor = new StepExecutor({ + optionsBuilder: this.optionsBuilder, + getCwd: () => this.cwd, + getProjectCwd: () => this.projectCwd, + getReportDir: () => this.reportDir, + getLanguage: () => this.options.language, + }); + + this.parallelRunner = new ParallelRunner({ + optionsBuilder: this.optionsBuilder, + stepExecutor: this.stepExecutor, + engineOptions: this.options, + getCwd: () => this.cwd, + getReportDir: () => this.reportDir, + }); + + log.debug('WorkflowEngine initialized', { + workflow: config.name, + steps: config.steps.map(s => s.name), + initialStep: config.initialStep, + maxIterations: config.maxIterations, + }); + } + + /** Ensure report directory exists (always in project root, not clone) */ + private ensureReportDirExists(): void { + const reportDirPath = join(this.projectCwd, this.reportDir); + if (!existsSync(reportDirPath)) { + mkdirSync(reportDirPath, { recursive: true }); + } + + // Worktree mode: create symlink so agents can access reports via relative path + if (this.cwd !== this.projectCwd) { + const cwdReportsDir = join(this.cwd, '.takt', 'reports'); + if (!existsSync(cwdReportsDir)) { + mkdirSync(join(this.cwd, '.takt'), { recursive: true }); + symlinkSync( + join(this.projectCwd, '.takt', 'reports'), + cwdReportsDir, + ); + } + } + } + + /** Validate workflow configuration at construction time */ + private validateConfig(): void { + const initialStep = this.config.steps.find((s) => s.name === this.config.initialStep); + if (!initialStep) { + throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(this.config.initialStep)); + } + + const stepNames = new Set(this.config.steps.map((s) => s.name)); + stepNames.add(COMPLETE_STEP); + stepNames.add(ABORT_STEP); + + for (const step of this.config.steps) { + if (step.rules) { + for (const rule of step.rules) { + if (rule.next && !stepNames.has(rule.next)) { + throw new Error( + `Invalid rule in step "${step.name}": target step "${rule.next}" does not exist` + ); + } + } + } + } + } + + /** Get current workflow state */ + getState(): WorkflowState { + return { ...this.state }; + } + + /** Add user input */ + addUserInput(input: string): void { + addUserInputToState(this.state, input); + } + + /** Update working directory */ + updateCwd(newCwd: string): void { + this.cwd = newCwd; + } + + /** Get current working directory */ + getCwd(): string { + return this.cwd; + } + + /** Get project root directory (where .takt/ lives) */ + getProjectCwd(): string { + return this.projectCwd; + } + + /** Request graceful abort: interrupt running queries and stop after current step */ + abort(): void { + if (this.abortRequested) return; + this.abortRequested = true; + log.info('Abort requested'); + interruptAllQueries(); + } + + /** Check if abort has been requested */ + isAbortRequested(): boolean { + return this.abortRequested; + } + + /** Get step by name */ + private getStep(name: string): WorkflowStep { + const step = this.config.steps.find((s) => s.name === name); + if (!step) { + throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(name)); + } + return step; + } + + /** Update agent session and notify via callback if session changed */ + private updateAgentSession(agent: string, sessionId: string | undefined): void { + if (!sessionId) return; + + const previousSessionId = this.state.agentSessions.get(agent); + this.state.agentSessions.set(agent, sessionId); + + if (this.options.onSessionUpdate && sessionId !== previousSessionId) { + this.options.onSessionUpdate(agent, sessionId); + } + } + + /** Emit step:report events collected by StepExecutor */ + private emitCollectedReports(): void { + for (const { step, filePath, fileName } of this.stepExecutor.drainReportFiles()) { + this.emit('step:report', step, filePath, fileName); + } + } + + /** Run a single step (delegates to ParallelRunner if step has parallel sub-steps) */ + private async runStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { + const updateSession = this.updateAgentSession.bind(this); + let result: { response: AgentResponse; instruction: string }; + + if (step.parallel && step.parallel.length > 0) { + result = await this.parallelRunner.runParallelStep( + step, this.state, this.task, this.config.maxIterations, updateSession, + ); + } else { + result = await this.stepExecutor.runNormalStep( + step, this.state, this.task, this.config.maxIterations, updateSession, prebuiltInstruction, + ); + } + + this.emitCollectedReports(); + return result; + } + + /** + * Determine next step for a completed step using rules-based routing. + */ + private resolveNextStep(step: WorkflowStep, response: AgentResponse): string { + if (response.matchedRuleIndex != null && step.rules) { + const nextByRules = determineNextStepByRules(step, response.matchedRuleIndex); + if (nextByRules) { + return nextByRules; + } + } + + throw new Error(`No matching rule found for step "${step.name}" (status: ${response.status})`); + } + + /** Build instruction (public, used by workflowExecution.ts for logging) */ + buildInstruction(step: WorkflowStep, stepIteration: number): string { + return this.stepExecutor.buildInstruction( + step, stepIteration, this.state, this.task, this.config.maxIterations, + ); + } + + /** Run the workflow to completion */ + async run(): Promise { + while (this.state.status === 'running') { + if (this.abortRequested) { + this.state.status = 'aborted'; + this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)'); + break; + } + + if (this.state.iteration >= this.config.maxIterations) { + this.emit('iteration:limit', this.state.iteration, this.config.maxIterations); + + if (this.options.onIterationLimit) { + const additionalIterations = await this.options.onIterationLimit({ + currentIteration: this.state.iteration, + maxIterations: this.config.maxIterations, + currentStep: this.state.currentStep, + }); + + if (additionalIterations !== null && additionalIterations > 0) { + this.config = { + ...this.config, + maxIterations: this.config.maxIterations + additionalIterations, + }; + continue; + } + } + + this.state.status = 'aborted'; + this.emit('workflow:abort', this.state, ERROR_MESSAGES.MAX_ITERATIONS_REACHED); + break; + } + + const step = this.getStep(this.state.currentStep); + const loopCheck = this.loopDetector.check(step.name); + + if (loopCheck.shouldWarn) { + this.emit('step:loop_detected', step, loopCheck.count); + } + + if (loopCheck.shouldAbort) { + this.state.status = 'aborted'; + this.emit('workflow:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count)); + break; + } + + this.state.iteration++; + + // Build instruction before emitting step:start so listeners can log it + const isParallel = step.parallel && step.parallel.length > 0; + let prebuiltInstruction: string | undefined; + if (!isParallel) { + const stepIteration = incrementStepIteration(this.state, step.name); + prebuiltInstruction = this.stepExecutor.buildInstruction( + step, stepIteration, this.state, this.task, this.config.maxIterations, + ); + } + this.emit('step:start', step, this.state.iteration, prebuiltInstruction ?? ''); + + try { + const { response, instruction } = await this.runStep(step, prebuiltInstruction); + this.emit('step:complete', step, response, instruction); + + if (response.status === 'blocked') { + this.emit('step:blocked', step, response); + const result = await handleBlocked(step, response, this.options); + + if (result.shouldContinue && result.userInput) { + this.addUserInput(result.userInput); + this.emit('step:user_input', step, result.userInput); + continue; + } + + this.state.status = 'aborted'; + this.emit('workflow:abort', this.state, 'Workflow blocked and no user input provided'); + break; + } + + const nextStep = this.resolveNextStep(step, response); + log.debug('Step transition', { + from: step.name, + status: response.status, + matchedRuleIndex: response.matchedRuleIndex, + nextStep, + }); + + if (nextStep === COMPLETE_STEP) { + this.state.status = 'completed'; + this.emit('workflow:complete', this.state); + break; + } + + if (nextStep === ABORT_STEP) { + this.state.status = 'aborted'; + this.emit('workflow:abort', this.state, 'Workflow aborted by step transition'); + break; + } + + this.state.currentStep = nextStep; + } catch (error) { + this.state.status = 'aborted'; + if (this.abortRequested) { + this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)'); + } else { + const message = getErrorMessage(error); + this.emit('workflow:abort', this.state, ERROR_MESSAGES.STEP_EXECUTION_FAILED(message)); + } + break; + } + } + + return this.state; + } + + /** Run a single iteration (for interactive mode) */ + async runSingleIteration(): Promise<{ + response: AgentResponse; + nextStep: string; + isComplete: boolean; + loopDetected?: boolean; + }> { + const step = this.getStep(this.state.currentStep); + const loopCheck = this.loopDetector.check(step.name); + + if (loopCheck.shouldAbort) { + this.state.status = 'aborted'; + return { + response: { + agent: step.agent, + status: 'blocked', + content: ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count), + timestamp: new Date(), + }, + nextStep: ABORT_STEP, + isComplete: true, + loopDetected: true, + }; + } + + this.state.iteration++; + const { response } = await this.runStep(step); + const nextStep = this.resolveNextStep(step, response); + const isComplete = nextStep === COMPLETE_STEP || nextStep === ABORT_STEP; + + if (!isComplete) { + this.state.currentStep = nextStep; + } else { + this.state.status = nextStep === COMPLETE_STEP ? 'completed' : 'aborted'; + } + + return { response, nextStep, isComplete, loopDetected: loopCheck.isLoop }; + } +} diff --git a/src/workflow/engine/index.ts b/src/workflow/engine/index.ts new file mode 100644 index 0000000..9b6ffdc --- /dev/null +++ b/src/workflow/engine/index.ts @@ -0,0 +1,10 @@ +/** + * Workflow engine module. + * + * Re-exports the WorkflowEngine class and its supporting classes. + */ + +export { WorkflowEngine } from './WorkflowEngine.js'; +export { StepExecutor } from './StepExecutor.js'; +export { ParallelRunner } from './ParallelRunner.js'; +export { OptionsBuilder } from './OptionsBuilder.js'; diff --git a/src/workflow/index.ts b/src/workflow/index.ts index b66cfb8..879d735 100644 --- a/src/workflow/index.ts +++ b/src/workflow/index.ts @@ -6,7 +6,7 @@ */ // Main engine -export { WorkflowEngine } from './engine.js'; +export { WorkflowEngine } from './engine/index.js'; // Constants export { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js'; @@ -33,7 +33,6 @@ export { createInitialState, addUserInput, getPreviousOutput, - storeStepOutput, } from './state-manager.js'; // Instruction building diff --git a/src/workflow/state-manager.ts b/src/workflow/state-manager.ts index 3e2ba72..ad094d4 100644 --- a/src/workflow/state-manager.ts +++ b/src/workflow/state-manager.ts @@ -73,18 +73,3 @@ export function getPreviousOutput(state: WorkflowState): AgentResponse | undefin return outputs[outputs.length - 1]; } -/** - * Store a step output and update agent session. - */ -export function storeStepOutput( - state: WorkflowState, - stepName: string, - agentName: string, - response: AgentResponse -): void { - state.stepOutputs.set(stepName, response); - - if (response.sessionId) { - state.agentSessions.set(agentName, response.sessionId); - } -}