構造化一歩目
This commit is contained in:
parent
482fa51266
commit
fc55bb2e0c
@ -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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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';
|
||||
|
||||
@ -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<boolean> {
|
||||
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';
|
||||
|
||||
@ -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<void> {
|
||||
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<string>();
|
||||
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';
|
||||
|
||||
14
src/commands/execution/index.ts
Normal file
14
src/commands/execution/index.ts
Normal file
@ -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';
|
||||
243
src/commands/execution/pipelineExecution.ts
Normal file
243
src/commands/execution/pipelineExecution.ts
Normal file
@ -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, string>): 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<number> {
|
||||
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;
|
||||
}
|
||||
184
src/commands/execution/selectAndExecute.ts
Normal file
184
src/commands/execution/selectAndExecute.ts
Normal file
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<WorktreeConfirmationResult> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
30
src/commands/execution/session.ts
Normal file
30
src/commands/execution/session.ts
Normal file
@ -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<AgentResponse>,
|
||||
provider?: string
|
||||
): Promise<AgentResponse> {
|
||||
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;
|
||||
}
|
||||
249
src/commands/execution/taskExecution.ts
Normal file
249
src/commands/execution/taskExecution.ts
Normal file
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
359
src/commands/execution/workflowExecution.ts
Normal file
359
src/commands/execution/workflowExecution.ts
Normal file
@ -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<WorkflowExecutionResult> {
|
||||
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<ReturnType<StreamDisplay['createHandler']>>[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<number | null> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<string | null> {
|
||||
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<typeof getProvider>,
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
model: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
display: StreamDisplay,
|
||||
): Promise<CallAIResult> {
|
||||
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<InteractiveModeResult> {
|
||||
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<CallAIResult | null> {
|
||||
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';
|
||||
|
||||
5
src/commands/interactive/index.ts
Normal file
5
src/commands/interactive/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Interactive mode commands.
|
||||
*/
|
||||
|
||||
export { interactiveMode } from './interactive.js';
|
||||
247
src/commands/interactive/interactive.ts
Normal file
247
src/commands/interactive/interactive.ts
Normal file
@ -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<string | null> {
|
||||
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<typeof getProvider>,
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
model: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
display: StreamDisplay,
|
||||
): Promise<CallAIResult> {
|
||||
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<InteractiveModeResult> {
|
||||
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<CallAIResult | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ListAction | null> {
|
||||
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<ListAction>(
|
||||
`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<string | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string>(
|
||||
'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';
|
||||
|
||||
185
src/commands/management/addTask.ts
Normal file
185
src/commands/management/addTask.ts
Normal file
@ -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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
134
src/commands/management/config.ts
Normal file
134
src/commands/management/config.ts
Normal file
@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
124
src/commands/management/eject.ts
Normal file
124
src/commands/management/eject.ts
Normal file
@ -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<void> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
10
src/commands/management/index.ts
Normal file
10
src/commands/management/index.ts
Normal file
@ -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';
|
||||
441
src/commands/management/listTasks.ts
Normal file
441
src/commands/management/listTasks.ts
Normal file
@ -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<ListAction | null> {
|
||||
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<ListAction>(
|
||||
`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<string | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string>(
|
||||
'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.');
|
||||
}
|
||||
83
src/commands/management/watchTasks.ts
Normal file
83
src/commands/management/watchTasks.ts
Normal file
@ -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<void> {
|
||||
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.');
|
||||
}
|
||||
63
src/commands/management/workflow.ts
Normal file
63
src/commands/management/workflow.ts
Normal file
@ -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<boolean> {
|
||||
// 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;
|
||||
}
|
||||
@ -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, string>): 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<number> {
|
||||
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';
|
||||
|
||||
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<WorktreeConfirmationResult> {
|
||||
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<void> {
|
||||
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';
|
||||
|
||||
@ -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<AgentResponse>,
|
||||
provider?: string
|
||||
): Promise<AgentResponse> {
|
||||
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';
|
||||
|
||||
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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';
|
||||
|
||||
@ -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<void> {
|
||||
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';
|
||||
|
||||
@ -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<boolean> {
|
||||
// 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';
|
||||
|
||||
@ -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<WorkflowExecutionResult> {
|
||||
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<ReturnType<StreamDisplay['createHandler']>>[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<number | null> => {
|
||||
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';
|
||||
|
||||
@ -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<string, CustomAgentConfig> {
|
||||
const agents = new Map<string, CustomAgentConfig>();
|
||||
|
||||
// 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';
|
||||
|
||||
243
src/config/global/globalConfig.ts
Normal file
243
src/config/global/globalConfig.ts
Normal file
@ -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<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
28
src/config/global/index.ts
Normal file
28
src/config/global/index.ts
Normal file
@ -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';
|
||||
133
src/config/global/initialization.ts
Normal file
133
src/config/global/initialization.ts
Normal file
@ -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<Language> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@ -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<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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';
|
||||
|
||||
@ -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<Language> {
|
||||
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<void> {
|
||||
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';
|
||||
|
||||
@ -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';
|
||||
|
||||
110
src/config/loaders/agentLoader.ts
Normal file
110
src/config/loaders/agentLoader.ts
Normal file
@ -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<string, CustomAgentConfig> {
|
||||
const agents = new Map<string, CustomAgentConfig>();
|
||||
|
||||
// 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');
|
||||
}
|
||||
20
src/config/loaders/index.ts
Normal file
20
src/config/loaders/index.ts
Normal file
@ -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';
|
||||
35
src/config/loaders/loader.ts
Normal file
35
src/config/loaders/loader.ts
Normal file
@ -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';
|
||||
449
src/config/loaders/workflowLoader.ts
Normal file
449
src/config/loaders/workflowLoader.ts
Normal file
@ -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<string, string>[] | { 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<string, string>[]).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<string, WorkflowConfig> {
|
||||
const workflows = new Map<string, WorkflowConfig>();
|
||||
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<string, WorkflowConfig>,
|
||||
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<string>();
|
||||
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<string>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/config/project/index.ts
Normal file
37
src/config/project/index.ts
Normal file
@ -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';
|
||||
131
src/config/project/projectConfig.ts
Normal file
131
src/config/project/projectConfig.ts
Normal file
@ -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<K extends keyof ProjectLocalConfig>(
|
||||
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;
|
||||
}
|
||||
322
src/config/project/sessionStore.ts
Normal file
322
src/config/project/sessionStore.ts
Normal file
@ -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<string, string>;
|
||||
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<string, string> {
|
||||
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<string, string>,
|
||||
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<string, string> = {};
|
||||
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<string, string> {
|
||||
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<string, string> = {};
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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<K extends keyof ProjectLocalConfig>(
|
||||
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';
|
||||
|
||||
@ -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<string, string>;
|
||||
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<string, string> {
|
||||
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<string, string>,
|
||||
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<string, string> = {};
|
||||
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<string, string> {
|
||||
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<string, string> = {};
|
||||
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';
|
||||
|
||||
@ -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<string, string>[] | { 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<string, string>[]).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<string, WorkflowConfig> {
|
||||
const workflows = new Map<string, WorkflowConfig>();
|
||||
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<string, WorkflowConfig>,
|
||||
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<string>();
|
||||
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<string>, 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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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, 'allowedTools' | 'maxTurns'>): 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<WorkflowState> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
80
src/workflow/engine/OptionsBuilder.ts
Normal file
80
src/workflow/engine/OptionsBuilder.ts
Normal file
@ -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, 'allowedTools' | 'maxTurns'>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
146
src/workflow/engine/ParallelRunner.ts
Normal file
146
src/workflow/engine/ParallelRunner.ts
Normal file
@ -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 };
|
||||
}
|
||||
|
||||
}
|
||||
155
src/workflow/engine/StepExecutor.ts
Normal file
155
src/workflow/engine/StepExecutor.ts
Normal file
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
413
src/workflow/engine/WorkflowEngine.ts
Normal file
413
src/workflow/engine/WorkflowEngine.ts
Normal file
@ -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<WorkflowState> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
10
src/workflow/engine/index.ts
Normal file
10
src/workflow/engine/index.ts
Normal file
@ -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';
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user