構造化一歩目

This commit is contained in:
nrslib 2026-02-02 06:55:56 +09:00
parent 482fa51266
commit fc55bb2e0c
53 changed files with 4835 additions and 4334 deletions

View File

@ -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';

View File

@ -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';

View File

@ -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';

View 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';

View 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;
}

View 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);
}
}

View 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;
}

View 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 };
}

View 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);
}
}

View File

@ -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';

View File

@ -0,0 +1,5 @@
/**
* Interactive mode commands.
*/
export { interactiveMode } from './interactive.js';

View 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();
}
}
}

View File

@ -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';

View 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}`);
}
}

View 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;
}

View 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);
}

View 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';

View 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.');
}

View 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.');
}

View 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;
}

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View 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;
}

View 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';

View 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);
}

View File

@ -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';

View File

@ -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';

View File

@ -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';

View 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');
}

View 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';

View 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';

View 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);
}
}
}

View 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';

View 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;
}

View 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
}
}

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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);
}

View File

@ -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 };
}
}

View 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,
};
}
}

View 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 };
}
}

View 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;
}
}

View 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 };
}
}

View 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';

View File

@ -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

View File

@ -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);
}
}