worktree対応

This commit is contained in:
nrslib 2026-01-26 11:16:05 +09:00
parent 3d3e970497
commit b98c3d4f19
8 changed files with 301 additions and 25 deletions

View File

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

View File

@ -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が実装を進められる程度の方向性を示す。

View File

@ -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<WorkflowExecutionResult> {
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,
};
}

View File

@ -5,3 +5,4 @@
export * from './ui.js';
export * from './session.js';
export * from './debug.js';
export * from './worktree.js';

View File

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

154
src/utils/worktree.ts Normal file
View File

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

View File

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

View File

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