From b98c3d4f19000adca873827a82b3bc1865133861 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:16:05 +0900 Subject: [PATCH] =?UTF-8?q?worktree=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/global/en/agents/default/planner.md | 23 +++ resources/global/ja/agents/default/planner.md | 23 +++ src/commands/workflowExecution.ts | 47 +++++- src/utils/index.ts | 1 + src/utils/session.ts | 22 +++ src/utils/worktree.ts | 154 ++++++++++++++++++ src/workflow/engine.ts | 54 +++--- src/workflow/types.ts | 2 + 8 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 src/utils/worktree.ts diff --git a/resources/global/en/agents/default/planner.md b/resources/global/en/agents/default/planner.md index 70d3888..d2ae5b5 100644 --- a/resources/global/en/agents/default/planner.md +++ b/resources/global/en/agents/default/planner.md @@ -55,6 +55,27 @@ Determine the implementation direction: | Analysis complete | `[PLANNER:DONE]` | | Insufficient info | `[PLANNER:BLOCKED]` | +### DONE Output Structure + +``` +[PLANNER:DONE] + +worktree: + baseBranch: {base branch name} + branchName: {new branch name} +``` + +**baseBranch criteria:** +- New feature: `main` or `master` +- Existing feature modification: related feature branch (use `main` if unknown) +- Bug fix: relevant branch (use `main` if unknown) + +**branchName naming convention:** +- Feature addition: `add-{feature-name}` (e.g., `add-user-authentication`) +- Fix: `fix-{issue}` (e.g., `fix-login-error`) +- Refactor: `refactor-{target}` (e.g., `refactor-api-client`) +- Use lowercase English with hyphens + ### BLOCKED Output Structure ``` @@ -65,6 +86,8 @@ Clarifications needed: - {Question 2} ``` +**Note:** Do not output worktree settings when BLOCKED. + ## Important **Keep analysis simple.** Overly detailed plans are unnecessary. Provide enough direction for Coder to proceed with implementation. diff --git a/resources/global/ja/agents/default/planner.md b/resources/global/ja/agents/default/planner.md index 4241f97..bcd911a 100644 --- a/resources/global/ja/agents/default/planner.md +++ b/resources/global/ja/agents/default/planner.md @@ -55,6 +55,27 @@ | 分析完了 | `[PLANNER:DONE]` | | 情報不足 | `[PLANNER:BLOCKED]` | +### DONE時の出力構造 + +``` +[PLANNER:DONE] + +worktree: + baseBranch: {元ブランチ名} + branchName: {新ブランチ名} +``` + +**baseBranch判断基準:** +- 新機能開発: `main` または `master` +- 既存機能の修正: 関連するfeatureブランチ(不明な場合は `main`) +- バグ修正: 該当するブランチ(不明な場合は `main`) + +**branchName命名規則:** +- 機能追加: `add-{feature-name}` (例: `add-user-authentication`) +- 修正: `fix-{issue}` (例: `fix-login-error`) +- リファクタ: `refactor-{target}` (例: `refactor-api-client`) +- 英語・小文字・ハイフン区切りで記述 + ### BLOCKED時の出力構造 ``` @@ -65,6 +86,8 @@ - {質問2} ``` +**注意:** BLOCKEDの場合、worktree設定は出力しない。 + ## 重要 **シンプルに分析する。** 過度に詳細な計画は不要。Coderが実装を進められる程度の方向性を示す。 diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index df6b1fd..cb7a67c 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -22,6 +22,7 @@ import { } from '../utils/session.js'; import { createLogger } from '../utils/debug.js'; import { notifySuccess, notifyError } from '../utils/notification.js'; +import { createWorktree, type WorktreeInfo, type WorktreeConfig } from '../utils/worktree.js'; const log = createLogger('workflow'); @@ -29,6 +30,8 @@ const log = createLogger('workflow'); export interface WorkflowExecutionResult { success: boolean; reason?: string; + /** Worktree information if worktree mode was used */ + worktree?: WorktreeInfo; } /** Options for workflow execution */ @@ -41,6 +44,10 @@ export interface WorkflowExecutionOptions { /** * Execute a workflow and handle all events + * + * Worktree creation is determined by Planner: + * - If Planner outputs [PLANNER:DONE] with worktree config, a worktree is created + * - If Planner outputs [PLANNER:BLOCKED], no worktree is created */ export async function executeWorkflow( workflowConfig: WorkflowConfig, @@ -48,7 +55,13 @@ export async function executeWorkflow( cwd: string, options: WorkflowExecutionOptions = {} ): Promise { - const { resumeSession = false, headerPrefix = 'Running Workflow:' } = options; + const { + resumeSession = false, + headerPrefix = 'Running Workflow:', + } = options; + + // Worktree info will be set when Planner emits worktree config + let worktreeInfo: WorktreeInfo | undefined; // Clear previous sessions if not resuming if (!resumeSession) { @@ -59,6 +72,7 @@ export async function executeWorkflow( } header(`${headerPrefix} ${workflowConfig.name}${resumeSession ? ' (resuming)' : ''}`); + const workflowSessionId = generateSessionId(); const sessionLog = createSessionLog(task, cwd, workflowConfig.name); @@ -78,6 +92,7 @@ export async function executeWorkflow( const savedSessions = loadAgentSessions(cwd); // Session update handler - persist session IDs when they change + // Always use original cwd for .takt data (案C: worktreeはコード作業専用) const sessionUpdateHandler = (agentName: string, agentSessionId: string): void => { updateAgentSession(cwd, agentName, agentSessionId); }; @@ -111,12 +126,36 @@ export async function executeWorkflow( addToSessionLog(sessionLog, step.name, response); }); + // Handle worktree config from Planner + engine.on('planner:worktree_config', (config: WorktreeConfig) => { + log.info('Planner provided worktree config', config); + try { + info(`Creating worktree for branch: ${config.branchName}`); + worktreeInfo = createWorktree(cwd, config.branchName, config.baseBranch); + success(`Worktree created: ${worktreeInfo.path}`); + info(`Base branch: ${worktreeInfo.baseBranch}`); + info(`Working in worktree: ${worktreeInfo.path}`); + + // Update engine's cwd to worktree path for remaining steps + engine.updateCwd(worktreeInfo.path); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + error(`Failed to create worktree: ${errorMessage}`); + // Continue without worktree - don't abort the workflow + } + }); + engine.on('workflow:complete', (state) => { log.info('Workflow completed successfully', { iterations: state.iteration }); finalizeSessionLog(sessionLog, 'completed'); + // Save log to original cwd so user can find it easily const logPath = saveSessionLog(sessionLog, workflowSessionId, cwd); success(`Workflow completed (${state.iteration} iterations)`); info(`Session log: ${logPath}`); + if (worktreeInfo) { + info(`Worktree preserved at: ${worktreeInfo.path}`); + info(`Branch: ${worktreeInfo.branch}`); + } notifySuccess('TAKT', `ワークフロー完了 (${state.iteration} iterations)`); }); @@ -128,9 +167,14 @@ export async function executeWorkflow( } abortReason = reason; finalizeSessionLog(sessionLog, 'aborted'); + // Save log to original cwd so user can find it easily const logPath = saveSessionLog(sessionLog, workflowSessionId, cwd); error(`Workflow aborted after ${state.iteration} iterations: ${reason}`); info(`Session log: ${logPath}`); + if (worktreeInfo) { + info(`Worktree preserved at: ${worktreeInfo.path}`); + info(`Branch: ${worktreeInfo.branch}`); + } notifyError('TAKT', `中断: ${reason}`); }); @@ -139,5 +183,6 @@ export async function executeWorkflow( return { success: finalState.status === 'completed', reason: abortReason, + worktree: worktreeInfo, }; } diff --git a/src/utils/index.ts b/src/utils/index.ts index fc41310..5cd2b56 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './ui.js'; export * from './session.js'; export * from './debug.js'; +export * from './worktree.js'; diff --git a/src/utils/session.ts b/src/utils/session.ts index c990bc7..d776539 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -32,6 +32,28 @@ export function generateSessionId(): string { return `${timestamp}-${random}`; } +/** + * Generate report directory name from task and timestamp. + * Format: YYYYMMDD-HHMMSS-task-summary + */ +export function generateReportDir(task: string): string { + const now = new Date(); + const timestamp = now.toISOString() + .replace(/[-:T]/g, '') + .slice(0, 14) + .replace(/(\d{8})(\d{6})/, '$1-$2'); + + // Extract first 30 chars of task, sanitize for directory name + const summary = task + .slice(0, 30) + .toLowerCase() + .replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'task'; + + return `${timestamp}-${summary}`; +} + /** Create a new session log */ export function createSessionLog( task: string, diff --git a/src/utils/worktree.ts b/src/utils/worktree.ts new file mode 100644 index 0000000..a1ba9f9 --- /dev/null +++ b/src/utils/worktree.ts @@ -0,0 +1,154 @@ +/** + * Git worktree management utilities for takt + */ + +import { execFileSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { existsSync, mkdirSync } from 'node:fs'; +import { createLogger } from './debug.js'; + +const log = createLogger('worktree'); + +export interface WorktreeInfo { + path: string; + branch: string; + baseBranch: string; +} + +/** Worktree configuration from Planner output */ +export interface WorktreeConfig { + baseBranch: string; + branchName: string; +} + +/** + * Parse worktree configuration from Planner output + */ +export function parseWorktreeConfig(content: string): WorktreeConfig | null { + // Match worktree: block with baseBranch and branchName + const worktreeMatch = content.match(/worktree:\s*\n\s*baseBranch:\s*(\S+)\s*\n\s*branchName:\s*(\S+)/); + if (worktreeMatch && worktreeMatch[1] && worktreeMatch[2]) { + return { + baseBranch: worktreeMatch[1], + branchName: worktreeMatch[2], + }; + } + return null; +} + +/** + * Generate a timestamp string for worktree directory + */ +export function generateTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}${month}${day}-${hours}${minutes}${seconds}`; +} + +/** + * Sanitize branch name for use in directory name + */ +export function sanitizeBranchName(branchName: string): string { + return branchName + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 50); +} + +/** + * Get the worktrees directory path + */ +export function getWorktreesDir(cwd: string): string { + return join(resolve(cwd), '.takt', 'worktrees'); +} + +/** + * Generate worktree path + */ +export function getWorktreePath(cwd: string, timestamp: string, branchName: string): string { + const sanitizedBranch = sanitizeBranchName(branchName); + return join(getWorktreesDir(cwd), `${timestamp}-${sanitizedBranch}`); +} + +/** + * Create a new git worktree with a new branch + * @param cwd - Current working directory + * @param branchName - Name of the new branch to create + * @param baseBranch - Base branch to create the worktree from (required, determined by Planner) + */ +export function createWorktree( + cwd: string, + branchName: string, + baseBranch: string +): WorktreeInfo { + const timestamp = generateTimestamp(); + const worktreePath = getWorktreePath(cwd, timestamp, branchName); + + // Ensure worktrees directory exists + const worktreesDir = getWorktreesDir(cwd); + if (!existsSync(worktreesDir)) { + mkdirSync(worktreesDir, { recursive: true }); + } + + log.info('Creating worktree', { path: worktreePath, branch: branchName, baseBranch }); + + // Fetch latest from origin + try { + execFileSync('git', ['fetch', 'origin'], { cwd, stdio: 'pipe' }); + } catch { + log.debug('Failed to fetch from origin, continuing with local state'); + } + + // Create worktree with new branch (using execFileSync to prevent command injection) + try { + const baseRef = `origin/${baseBranch}`; + execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], { + cwd, + stdio: 'pipe', + }); + } catch (e) { + // If origin/base doesn't exist, try local base + log.debug('Failed to create from origin, trying local branch', { error: e }); + execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseBranch], { + cwd, + stdio: 'pipe', + }); + } + + log.info('Worktree created successfully', { path: worktreePath }); + + return { + path: worktreePath, + branch: branchName, + baseBranch, + }; +} + +/** + * Remove a worktree + */ +export function removeWorktree(cwd: string, worktreePath: string): void { + log.info('Removing worktree', { path: worktreePath }); + execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], { cwd, stdio: 'pipe' }); +} + +/** + * List all worktrees + */ +export function listWorktrees(cwd: string): string[] { + const output = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd, encoding: 'utf-8' }); + const paths: string[] = []; + for (const line of output.split('\n')) { + if (line.startsWith('worktree ')) { + paths.push(line.slice('worktree '.length)); + } + } + return paths; +} diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index e646fef..4c40138 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -23,6 +23,8 @@ import { addUserInput, getPreviousOutput, } from './state-manager.js'; +import { parseWorktreeConfig } from '../utils/worktree.js'; +import { generateReportDir } from '../utils/session.js'; // Re-export types for backward compatibility export type { @@ -33,34 +35,14 @@ export type { IterationLimitCallback, WorkflowEngineOptions, } from './types.js'; +export type { WorktreeConfig } from '../utils/worktree.js'; export { COMPLETE_STEP, ABORT_STEP } from './constants.js'; -/** - * Generate report directory name from task and timestamp. - * Format: YYYYMMDD-HHMMSS-task-summary - */ -function generateReportDir(task: string): string { - const now = new Date(); - const timestamp = now.toISOString() - .replace(/[-:T]/g, '') - .slice(0, 14) - .replace(/(\d{8})(\d{6})/, '$1-$2'); - - // Extract first 30 chars of task, sanitize for directory name - const summary = task - .slice(0, 30) - .toLowerCase() - .replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-') - .replace(/^-+|-+$/g, '') - || 'task'; - - return `${timestamp}-${summary}`; -} - /** Workflow engine for orchestrating agent execution */ export class WorkflowEngine extends EventEmitter { private state: WorkflowState; private config: WorkflowConfig; + private originalCwd: string; private cwd: string; private task: string; private options: WorkflowEngineOptions; @@ -70,6 +52,7 @@ export class WorkflowEngine extends EventEmitter { constructor(config: WorkflowConfig, cwd: string, task: string, options: WorkflowEngineOptions = {}) { super(); this.config = config; + this.originalCwd = cwd; this.cwd = cwd; this.task = task; this.options = options; @@ -80,9 +63,9 @@ export class WorkflowEngine extends EventEmitter { this.state = createInitialState(config, options); } - /** Ensure report directory exists */ + /** Ensure report directory exists (always in original cwd) */ private ensureReportDirExists(): void { - const reportDirPath = join(this.cwd, '.takt', 'reports', this.reportDir); + const reportDirPath = join(this.originalCwd, '.takt', 'reports', this.reportDir); if (!existsSync(reportDirPath)) { mkdirSync(reportDirPath, { recursive: true }); } @@ -120,6 +103,21 @@ export class WorkflowEngine extends EventEmitter { addUserInput(this.state, input); } + /** Update working directory (used after worktree creation) */ + updateCwd(newCwd: string): void { + this.cwd = newCwd; + } + + /** Get current working directory (may be worktree path) */ + getCwd(): string { + return this.cwd; + } + + /** Get original working directory (for .takt data) */ + getOriginalCwd(): string { + return this.originalCwd; + } + /** Build instruction from template */ private buildInstruction(step: WorkflowStep): string { return buildInstructionFromTemplate(step, { @@ -219,6 +217,14 @@ export class WorkflowEngine extends EventEmitter { const response = await this.runStep(step); this.emit('step:complete', step, response); + // Check for worktree config in Planner output (when DONE) + if (step.name === 'plan' && response.status === 'done') { + const worktreeConfig = parseWorktreeConfig(response.content); + if (worktreeConfig) { + this.emit('planner:worktree_config', worktreeConfig); + } + } + if (response.status === 'blocked') { this.emit('step:blocked', step, response); const result = await handleBlocked(step, response, this.options); diff --git a/src/workflow/types.ts b/src/workflow/types.ts index baa4781..fc29b6d 100644 --- a/src/workflow/types.ts +++ b/src/workflow/types.ts @@ -8,6 +8,7 @@ import type { WorkflowStep, AgentResponse, WorkflowState } from '../models/types.js'; import type { StreamCallback } from '../agents/runner.js'; import type { PermissionHandler, AskUserQuestionHandler } from '../claude/process.js'; +import type { WorktreeConfig } from '../utils/worktree.js'; /** Events emitted by workflow engine */ export interface WorkflowEvents { @@ -19,6 +20,7 @@ export interface WorkflowEvents { 'workflow:abort': (state: WorkflowState, reason: string) => void; 'iteration:limit': (iteration: number, maxIterations: number) => void; 'step:loop_detected': (step: WorkflowStep, consecutiveCount: number) => void; + 'planner:worktree_config': (config: WorktreeConfig) => void; } /** User input request for blocked state */