worktree対応
This commit is contained in:
parent
3d3e970497
commit
b98c3d4f19
@ -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.
|
||||
|
||||
@ -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が実装を進められる程度の方向性を示す。
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,3 +5,4 @@
|
||||
export * from './ui.js';
|
||||
export * from './session.js';
|
||||
export * from './debug.js';
|
||||
export * from './worktree.js';
|
||||
|
||||
@ -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
154
src/utils/worktree.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user