Stop using git worktree due to Claude Code SDK working at repository root

This commit is contained in:
nrslib 2026-01-29 11:24:47 +09:00
parent 83621d689e
commit 63d6932c01
22 changed files with 435 additions and 375 deletions

View File

@ -24,7 +24,7 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl
| `takt /run-tasks` | `/run` | Execute all pending tasks from `.takt/tasks/` once |
| `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
| `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) |
| `takt /review-tasks` | `/review` | Review worktree task results (try merge, merge & cleanup, or delete) |
| `takt /review-tasks` | `/review` | Review task branches (try merge, merge & cleanup, or delete) |
| `takt /switch` | | Switch workflow interactively |
| `takt /clear` | | Clear agent conversation sessions (reset state) |
| `takt /refresh-builtin` | | Update builtin resources from `resources/` to `~/.takt/` |
@ -186,13 +186,20 @@ model: opus # Default model for all steps (unless overridden)
- `improve` - Needs improvement (security concerns, quality issues)
- `always` - Unconditional transition
## Worktree Execution
## Isolated Execution (Shared Clone)
When tasks specify `worktree: true` or `worktree: "path"`, code runs in a git worktree (separate checkout). Key constraints:
When tasks specify `worktree: true` or `worktree: "path"`, code runs in a `git clone --shared` (lightweight clone with independent `.git` directory). Clones are ephemeral: created before task execution, auto-committed + pushed after success, then deleted.
- **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a worktree. The engine skips session resume when `cwd !== projectCwd`.
- **No node_modules**: Worktrees only contain tracked files. `node_modules/` is absent.
- **Dual cwd**: `cwd` = worktree path (where agents run), `projectCwd` = project root (where `.takt/` lives). Reports, logs, and session data always write to `projectCwd`.
> **Why `worktree` in YAML but `git clone --shared` internally?** The YAML field name `worktree` is retained for backward compatibility. The original implementation used `git worktree`, but git worktrees have a `.git` file containing `gitdir: /path/to/main/.git/worktrees/...`. Claude Code follows this path and recognizes the main repository as the project root, causing agents to work on main instead of the worktree. `git clone --shared` creates an independent `.git` directory that prevents this traversal.
Key constraints:
- **Independent `.git`**: Shared clones have their own `.git` directory, preventing Claude Code from traversing `gitdir:` back to the main repository.
- **Ephemeral lifecycle**: Clone is created → task runs → auto-commit + push → clone is deleted. Branches are the single source of truth.
- **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a clone. The engine skips session resume when `cwd !== projectCwd`.
- **No node_modules**: Clones only contain tracked files. `node_modules/` is absent.
- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root (where `.takt/` lives). Reports, logs, and session data always write to `projectCwd`.
- **Review**: Use `takt /review-tasks` to review branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone.
## Error Propagation

View File

@ -23,7 +23,7 @@ npm install -g takt
## Quick Start
```bash
# Run a task (will prompt for workflow selection and optional worktree creation)
# Run a task (will prompt for workflow selection and optional isolated clone)
takt "Add a login feature"
# Add a task to the queue
@ -35,7 +35,7 @@ takt /run-tasks
# Watch for tasks and auto-execute
takt /watch
# Review worktree results (merge or delete)
# Review task branches (merge or delete)
takt /review-tasks
# Switch workflow
@ -51,7 +51,7 @@ takt /switch
| `takt /run-tasks` | `/run` | Run all pending tasks from `.takt/tasks/` |
| `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
| `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) |
| `takt /review-tasks` | `/review` | Review worktree task results (try merge, merge & cleanup, or delete) |
| `takt /review-tasks` | `/review` | Review task branches (try merge, merge & cleanup, or delete) |
| `takt /switch` | | Switch workflow interactively |
| `takt /clear` | | Clear agent conversation sessions |
| `takt /refresh-builtin` | | Update builtin agents/workflows to latest version |
@ -185,7 +185,7 @@ Available Codex models:
├── agents.yaml # Custom agent definitions
├── tasks/ # Pending task files (.yaml, .md)
├── completed/ # Completed tasks with reports
├── worktrees/ # Git worktrees for isolated task execution
├── worktree-meta/ # Metadata for task branches
├── reports/ # Execution reports (auto-generated)
└── logs/ # Session logs (incremental)
├── latest.json # Pointer to current/latest session
@ -236,7 +236,7 @@ The `-r` flag preserves the agent's conversation history, allowing for natural b
When running `takt "task"`, you are prompted to:
1. **Select a workflow** - Choose from available workflows (arrow keys, ESC to cancel)
2. **Create a worktree** (optional) - Optionally run the task in an isolated git worktree
2. **Create an isolated clone** (optional) - Optionally run the task in a `git clone --shared` for isolation
This interactive flow ensures each task runs with the right workflow and isolation level.
@ -310,10 +310,10 @@ TAKT supports batch task processing through task files in `.takt/tasks/`. Both `
#### Adding Tasks with `/add-task`
```bash
# Quick add (no worktree)
# Quick add (no isolation)
takt /add-task "Add authentication feature"
# Interactive mode (prompts for worktree, branch, workflow options)
# Interactive mode (prompts for isolation, branch, workflow options)
takt /add-task
```
@ -324,7 +324,7 @@ takt /add-task
```yaml
# .takt/tasks/add-auth.yaml
task: "Add authentication feature"
worktree: true # Run in isolated git worktree
worktree: true # Run in isolated shared clone
branch: "feat/add-auth" # Branch name (auto-generated if omitted)
workflow: "default" # Workflow override (uses current if omitted)
```
@ -342,16 +342,18 @@ Requirements:
- Error handling for failed attempts
```
#### Git Worktree Isolation
#### Isolated Execution (Shared Clone)
YAML task files can specify `worktree` to run each task in an isolated git worktree, keeping the main working directory clean:
YAML task files can specify `worktree` to run each task in an isolated `git clone --shared`, keeping the main working directory clean:
- `worktree: true` - Auto-create at `.takt/worktrees/{timestamp}-{task-slug}/`
- `worktree: true` - Auto-create a shared clone in a sibling directory (or `worktree_dir` from config)
- `worktree: "/path/to/dir"` - Create at specified path
- `branch: "feat/xxx"` - Use specified branch (auto-generated as `takt/{timestamp}-{slug}` if omitted)
- Omit `worktree` - Run in current working directory (default)
When a worktree task completes successfully, TAKT automatically commits all changes (`auto-commit`). Use `takt /review-tasks` to review, try-merge, or delete completed worktree branches.
> **Note**: The YAML field is named `worktree` for backward compatibility. Internally, `git clone --shared` is used instead of `git worktree` because git worktrees have a `.git` file with `gitdir:` that points back to the main repository, causing Claude Code to recognize the main repo as the project root. Shared clones have an independent `.git` directory that avoids this issue.
Clones are ephemeral. When a task completes successfully, TAKT automatically commits all changes and pushes the branch to the main repository, then deletes the clone. Use `takt /review-tasks` to review, try-merge, or delete task branches.
#### Running Tasks with `/run-tasks`
@ -374,16 +376,17 @@ Watch mode polls `.takt/tasks/` for new task files and auto-executes them as the
- Automated workflows where tasks are added by external processes
- Long-running development sessions where tasks are queued over time
#### Reviewing Worktree Results with `/review-tasks`
#### Reviewing Task Branches with `/review-tasks`
```bash
takt /review-tasks
```
Lists all `takt/`-prefixed worktree branches with file change counts. For each branch you can:
- **Try merge** - Attempt merge into main (dry-run check, then actual merge)
- **Merge & cleanup** - Merge and remove the worktree
- **Delete** - Remove the worktree and branch without merging
Lists all `takt/`-prefixed branches with file change counts. For each branch you can:
- **Try merge** - Squash merge into main (stage changes without committing)
- **Instruct** - Give additional instructions via a temporary clone
- **Merge & cleanup** - Merge and delete the branch
- **Delete** - Delete the branch without merging
### Session Logs

View File

@ -73,10 +73,10 @@ TAKTは`.takt/tasks/`内のタスクファイルによるバッチ処理をサ
#### `/add-task` でタスクを追加
```bash
# クイック追加(worktreeなし)
# クイック追加(隔離なし)
takt /add-task "認証機能を追加"
# 対話モード(worktree、ブランチ、ワークフローオプションを指定可能)
# 対話モード(隔離実行、ブランチ、ワークフローオプションを指定可能)
takt /add-task
```
@ -87,7 +87,7 @@ takt /add-task
```yaml
# .takt/tasks/add-auth.yaml
task: "認証機能を追加する"
worktree: true # 隔離されたgit worktreeで実行
worktree: true # 隔離された共有クローンで実行
branch: "feat/add-auth" # ブランチ名(省略時は自動生成)
workflow: "default" # ワークフロー指定(省略時は現在のもの)
```
@ -105,15 +105,19 @@ workflow: "default" # ワークフロー指定(省略時は現在
- 失敗時のエラーハンドリング
```
#### Git Worktree による隔離実行
#### 共有クローンによる隔離実行
YAMLタスクファイルで`worktree`を指定すると、各タスクを隔離されたgit worktreeで実行し、メインの作業ディレクトリをクリーンに保てます:
YAMLタスクファイルで`worktree`を指定すると、各タスクを`git clone --shared`で作成した隔離クローンで実行し、メインの作業ディレクトリをクリーンに保てます:
- `worktree: true` - `.takt/worktrees/{timestamp}-{task-slug}/`自動作成
- `worktree: true` - 隣接ディレクトリ(または`worktree_dir`設定で指定した場所)に共有クローンを自動作成
- `worktree: "/path/to/dir"` - 指定パスに作成
- `branch: "feat/xxx"` - 指定ブランチを使用(省略時は`takt/{timestamp}-{slug}`で自動生成)
- `worktree`省略 - カレントディレクトリで実行(デフォルト)
> **Note**: YAMLフィールド名は後方互換のため`worktree`のままです。内部的には`git worktree`ではなく`git clone --shared`を使用しています。git worktreeの`.git`ファイルには`gitdir:`でメインリポジトリへのパスが記載されており、Claude Codeがそれを辿ってメインリポジトリをプロジェクトルートと認識してしまうためです。共有クローンは独立した`.git`ディレクトリを持つため、この問題が発生しません。
クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt /review-tasks`でブランチのレビュー・マージ・削除ができます。
#### `/run-tasks` でタスクを実行
```bash
@ -338,7 +342,7 @@ agents:
├── agents.yaml # カスタムエージェント定義
├── tasks/ # 保留中のタスクファイル(.yaml, .md
├── completed/ # 完了したタスクとレポート
├── worktrees/ # タスク隔離実行用のgit worktree
├── worktree-meta/ # タスクブランチのメタデータ
├── reports/ # 実行レポート(自動生成)
└── logs/ # セッションログ
```

View File

@ -3,6 +3,6 @@ logs/
reports/
completed/
tasks/
worktrees/
worktree-meta/
agent_sessions.json
input_history

View File

@ -1,9 +1,9 @@
/**
* Tests for autoCommitWorktree
* Tests for autoCommitAndPush
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { autoCommitWorktree } from '../task/autoCommit.js';
import { autoCommitAndPush } from '../task/autoCommit.js';
// Mock child_process.execFileSync
vi.mock('node:child_process', () => ({
@ -17,9 +17,8 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe('autoCommitWorktree', () => {
it('should create a commit when there are changes', () => {
// git add -A: no output needed
describe('autoCommitAndPush', () => {
it('should create a commit and push when there are changes', () => {
mockExecFileSync.mockImplementation((cmd, args) => {
const argsArr = args as string[];
if (argsArr[0] === 'status') {
@ -31,7 +30,7 @@ describe('autoCommitWorktree', () => {
return Buffer.from('');
});
const result = autoCommitWorktree('/tmp/worktree', 'my-task');
const result = autoCommitAndPush('/tmp/clone', 'my-task');
expect(result.success).toBe(true);
expect(result.commitHash).toBe('abc1234');
@ -41,14 +40,21 @@ describe('autoCommitWorktree', () => {
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['add', '-A'],
expect.objectContaining({ cwd: '/tmp/worktree' })
expect.objectContaining({ cwd: '/tmp/clone' })
);
// Verify commit was called with correct message (no co-author)
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['commit', '-m', 'takt: my-task'],
expect.objectContaining({ cwd: '/tmp/worktree' })
expect.objectContaining({ cwd: '/tmp/clone' })
);
// Verify push was called
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['push', 'origin', 'HEAD'],
expect.objectContaining({ cwd: '/tmp/clone' })
);
});
@ -61,7 +67,7 @@ describe('autoCommitWorktree', () => {
return Buffer.from('');
});
const result = autoCommitWorktree('/tmp/worktree', 'my-task');
const result = autoCommitAndPush('/tmp/clone', 'my-task');
expect(result.success).toBe(true);
expect(result.commitHash).toBeUndefined();
@ -71,7 +77,7 @@ describe('autoCommitWorktree', () => {
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['add', '-A'],
expect.objectContaining({ cwd: '/tmp/worktree' })
expect.objectContaining({ cwd: '/tmp/clone' })
);
// Verify commit was NOT called
@ -80,6 +86,13 @@ describe('autoCommitWorktree', () => {
['commit', '-m', expect.any(String)],
expect.anything()
);
// Verify push was NOT called
expect(mockExecFileSync).not.toHaveBeenCalledWith(
'git',
['push', 'origin', 'HEAD'],
expect.anything()
);
});
it('should return failure when git command fails', () => {
@ -87,7 +100,7 @@ describe('autoCommitWorktree', () => {
throw new Error('git error: not a git repository');
});
const result = autoCommitWorktree('/tmp/worktree', 'my-task');
const result = autoCommitAndPush('/tmp/clone', 'my-task');
expect(result.success).toBe(false);
expect(result.commitHash).toBeUndefined();
@ -107,7 +120,7 @@ describe('autoCommitWorktree', () => {
return Buffer.from('');
});
autoCommitWorktree('/tmp/worktree', 'test-task');
autoCommitAndPush('/tmp/clone', 'test-task');
// Find the commit call
const commitCall = mockExecFileSync.mock.calls.find(
@ -132,7 +145,7 @@ describe('autoCommitWorktree', () => {
return Buffer.from('');
});
autoCommitWorktree('/tmp/worktree', '認証機能を追加する');
autoCommitAndPush('/tmp/clone', '認証機能を追加する');
const commitCall = mockExecFileSync.mock.calls.find(
call => (call[1] as string[])[0] === 'commit'

View File

@ -1,5 +1,5 @@
/**
* Tests for confirmAndCreateWorktree (CLI worktree confirmation flow)
* Tests for confirmAndCreateWorktree (CLI clone confirmation flow)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
@ -11,11 +11,12 @@ vi.mock('../prompt/index.js', () => ({
}));
vi.mock('../task/worktree.js', () => ({
createWorktree: vi.fn(),
createSharedClone: vi.fn(),
removeClone: vi.fn(),
}));
vi.mock('../task/autoCommit.js', () => ({
autoCommitWorktree: vi.fn(),
autoCommitAndPush: vi.fn(),
}));
vi.mock('../task/summarize.js', () => ({
@ -76,13 +77,13 @@ vi.mock('../constants.js', () => ({
}));
import { confirm } from '../prompt/index.js';
import { createWorktree } from '../task/worktree.js';
import { createSharedClone } from '../task/worktree.js';
import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js';
import { confirmAndCreateWorktree } from '../cli.js';
const mockConfirm = vi.mocked(confirm);
const mockCreateWorktree = vi.mocked(createWorktree);
const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockInfo = vi.mocked(info);
@ -91,8 +92,8 @@ beforeEach(() => {
});
describe('confirmAndCreateWorktree', () => {
it('should return original cwd when user declines worktree creation', async () => {
// Given: user says "no" to worktree creation
it('should return original cwd when user declines clone creation', async () => {
// Given: user says "no" to clone creation
mockConfirm.mockResolvedValue(false);
// When
@ -101,16 +102,16 @@ describe('confirmAndCreateWorktree', () => {
// Then
expect(result.execCwd).toBe('/project');
expect(result.isWorktree).toBe(false);
expect(mockCreateWorktree).not.toHaveBeenCalled();
expect(mockCreateSharedClone).not.toHaveBeenCalled();
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
});
it('should create worktree and return worktree path when user confirms', async () => {
// Given: user says "yes" to worktree creation
it('should create shared clone and return clone path when user confirms', async () => {
// Given: user says "yes" to clone creation
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('fix-auth');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128T0504-fix-auth',
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128T0504-fix-auth',
branch: 'takt/20260128T0504-fix-auth',
});
@ -118,21 +119,21 @@ describe('confirmAndCreateWorktree', () => {
const result = await confirmAndCreateWorktree('/project', 'fix-auth');
// Then
expect(result.execCwd).toBe('/project/.takt/worktrees/20260128T0504-fix-auth');
expect(result.execCwd).toBe('/project/../20260128T0504-fix-auth');
expect(result.isWorktree).toBe(true);
expect(mockSummarizeTaskName).toHaveBeenCalledWith('fix-auth', { cwd: '/project' });
expect(mockCreateWorktree).toHaveBeenCalledWith('/project', {
expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', {
worktree: true,
taskSlug: 'fix-auth',
});
});
it('should display worktree info when created', async () => {
it('should display clone info when created', async () => {
// Given
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('my-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128T0504-my-task',
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128T0504-my-task',
branch: 'takt/20260128T0504-my-task',
});
@ -141,7 +142,7 @@ describe('confirmAndCreateWorktree', () => {
// Then
expect(mockInfo).toHaveBeenCalledWith(
'Worktree created: /project/.takt/worktrees/20260128T0504-my-task (branch: takt/20260128T0504-my-task)'
'Clone created: /project/../20260128T0504-my-task (branch: takt/20260128T0504-my-task)'
);
});
@ -160,8 +161,8 @@ describe('confirmAndCreateWorktree', () => {
// Given: Japanese task name, AI summarizes to English
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('add-auth');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128T0504-add-auth',
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128T0504-add-auth',
branch: 'takt/20260128T0504-add-auth',
});
@ -170,18 +171,18 @@ describe('confirmAndCreateWorktree', () => {
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' });
expect(mockCreateWorktree).toHaveBeenCalledWith('/project', {
expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', {
worktree: true,
taskSlug: 'add-auth',
});
});
it('should show generating message when creating worktree', async () => {
it('should show generating message when creating clone', async () => {
// Given
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128T0504-test-task',
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128T0504-test-task',
branch: 'takt/20260128T0504-test-task',
});

View File

@ -4,94 +4,63 @@
import { describe, it, expect, vi } from 'vitest';
import {
parseTaktWorktrees,
parseTaktBranches,
extractTaskSlug,
buildReviewItems,
type WorktreeInfo,
type BranchInfo,
} from '../task/worktree.js';
import { isBranchMerged, showFullDiff, type ReviewAction } from '../commands/reviewTasks.js';
describe('parseTaktWorktrees', () => {
it('should parse takt/ branches from porcelain output', () => {
describe('parseTaktBranches', () => {
it('should parse takt/ branches from git branch output', () => {
const output = [
'worktree /home/user/project',
'HEAD abc1234567890',
'branch refs/heads/main',
'',
'worktree /home/user/project/.takt/worktrees/20260128-fix-auth',
'HEAD def4567890abc',
'branch refs/heads/takt/20260128-fix-auth',
'',
'worktree /home/user/project/.takt/worktrees/20260128-add-search',
'HEAD 789abcdef0123',
'branch refs/heads/takt/20260128-add-search',
'takt/20260128-fix-auth def4567',
'takt/20260128-add-search 789abcd',
].join('\n');
const result = parseTaktWorktrees(output);
const result = parseTaktBranches(output);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
path: '/home/user/project/.takt/worktrees/20260128-fix-auth',
branch: 'takt/20260128-fix-auth',
commit: 'def4567890abc',
commit: 'def4567',
});
expect(result[1]).toEqual({
path: '/home/user/project/.takt/worktrees/20260128-add-search',
branch: 'takt/20260128-add-search',
commit: '789abcdef0123',
commit: '789abcd',
});
});
it('should exclude non-takt branches', () => {
const output = [
'worktree /home/user/project',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /home/user/project/.takt/worktrees/20260128-fix-auth',
'HEAD def456',
'branch refs/heads/takt/20260128-fix-auth',
'',
'worktree /tmp/other-worktree',
'HEAD 789abc',
'branch refs/heads/feature/other',
].join('\n');
const result = parseTaktWorktrees(output);
expect(result).toHaveLength(1);
expect(result[0]!.branch).toBe('takt/20260128-fix-auth');
});
it('should handle empty output', () => {
const result = parseTaktWorktrees('');
const result = parseTaktBranches('');
expect(result).toHaveLength(0);
});
it('should handle bare worktree entry (no branch line)', () => {
const output = [
'worktree /home/user/project',
'HEAD abc123',
'bare',
].join('\n');
const result = parseTaktWorktrees(output);
it('should handle output with only whitespace lines', () => {
const result = parseTaktBranches(' \n \n');
expect(result).toHaveLength(0);
});
it('should handle detached HEAD worktrees', () => {
it('should handle single branch', () => {
const output = 'takt/20260128-fix-auth abc1234';
const result = parseTaktBranches(output);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
branch: 'takt/20260128-fix-auth',
commit: 'abc1234',
});
});
it('should skip lines without space separator', () => {
const output = [
'worktree /home/user/project',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /tmp/detached',
'HEAD def456',
'detached',
'takt/20260128-fix-auth abc1234',
'malformed-line',
].join('\n');
const result = parseTaktWorktrees(output);
expect(result).toHaveLength(0);
const result = parseTaktBranches(output);
expect(result).toHaveLength(1);
});
});
@ -125,45 +94,40 @@ describe('extractTaskSlug', () => {
describe('buildReviewItems', () => {
it('should build items with correct task slug', () => {
const worktrees: WorktreeInfo[] = [
const branches: BranchInfo[] = [
{
path: '/project/.takt/worktrees/20260128-fix-auth',
branch: 'takt/20260128-fix-auth',
commit: 'abc123',
},
];
// We can't test getFilesChanged without a real git repo,
// so we test buildReviewItems' structure
const items = buildReviewItems('/project', worktrees, 'main');
const items = buildReviewItems('/project', branches, 'main');
expect(items).toHaveLength(1);
expect(items[0]!.taskSlug).toBe('fix-auth');
expect(items[0]!.info).toBe(worktrees[0]);
expect(items[0]!.info).toBe(branches[0]);
// filesChanged will be 0 since we don't have a real git repo
expect(items[0]!.filesChanged).toBe(0);
});
it('should handle multiple worktrees', () => {
const worktrees: WorktreeInfo[] = [
it('should handle multiple branches', () => {
const branches: BranchInfo[] = [
{
path: '/project/.takt/worktrees/20260128-fix-auth',
branch: 'takt/20260128-fix-auth',
commit: 'abc123',
},
{
path: '/project/.takt/worktrees/20260128-add-search',
branch: 'takt/20260128-add-search',
commit: 'def456',
},
];
const items = buildReviewItems('/project', worktrees, 'main');
const items = buildReviewItems('/project', branches, 'main');
expect(items).toHaveLength(2);
expect(items[0]!.taskSlug).toBe('fix-auth');
expect(items[1]!.taskSlug).toBe('add-search');
});
it('should handle empty worktree list', () => {
it('should handle empty branch list', () => {
const items = buildReviewItems('/project', [], 'main');
expect(items).toHaveLength(0);
});

View File

@ -15,11 +15,12 @@ vi.mock('../task/index.js', () => ({
}));
vi.mock('../task/worktree.js', () => ({
createWorktree: vi.fn(),
createSharedClone: vi.fn(),
removeClone: vi.fn(),
}));
vi.mock('../task/autoCommit.js', () => ({
autoCommitWorktree: vi.fn(),
autoCommitAndPush: vi.fn(),
}));
vi.mock('../task/summarize.js', () => ({
@ -55,13 +56,13 @@ vi.mock('../constants.js', () => ({
DEFAULT_LANGUAGE: 'en',
}));
import { createWorktree } from '../task/worktree.js';
import { createSharedClone } from '../task/worktree.js';
import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js';
import { resolveTaskExecution } from '../commands/taskExecution.js';
import type { TaskInfo } from '../task/index.js';
const mockCreateWorktree = vi.mocked(createWorktree);
const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockInfo = vi.mocked(info);
@ -88,7 +89,7 @@ describe('resolveTaskExecution', () => {
isWorktree: false,
});
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
expect(mockCreateWorktree).not.toHaveBeenCalled();
expect(mockCreateSharedClone).not.toHaveBeenCalled();
});
it('should return defaults when data has no worktree option', async () => {
@ -110,7 +111,7 @@ describe('resolveTaskExecution', () => {
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
});
it('should create worktree with AI-summarized slug when worktree option is true', async () => {
it('should create shared clone with AI-summarized slug when worktree option is true', async () => {
// Given: Task with worktree option
const task: TaskInfo = {
name: 'japanese-task',
@ -123,8 +124,8 @@ describe('resolveTaskExecution', () => {
};
mockSummarizeTaskName.mockResolvedValue('add-auth');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128T0504-add-auth',
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128T0504-add-auth',
branch: 'takt/20260128T0504-add-auth',
});
@ -133,13 +134,13 @@ describe('resolveTaskExecution', () => {
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' });
expect(mockCreateWorktree).toHaveBeenCalledWith('/project', {
expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', {
worktree: true,
branch: undefined,
taskSlug: 'add-auth',
});
expect(result).toEqual({
execCwd: '/project/.takt/worktrees/20260128T0504-add-auth',
execCwd: '/project/../20260128T0504-add-auth',
execWorkflow: 'default',
isWorktree: true,
});
@ -158,8 +159,8 @@ describe('resolveTaskExecution', () => {
};
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/test-task',
mockCreateSharedClone.mockReturnValue({
path: '/project/../test-task',
branch: 'takt/test-task',
});
@ -183,8 +184,8 @@ describe('resolveTaskExecution', () => {
};
mockSummarizeTaskName.mockResolvedValue('new-feature');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/new-feature',
mockCreateSharedClone.mockReturnValue({
path: '/project/../new-feature',
branch: 'takt/new-feature',
});
@ -214,7 +215,7 @@ describe('resolveTaskExecution', () => {
expect(result.execWorkflow).toBe('custom-workflow');
});
it('should pass branch option to createWorktree when specified', async () => {
it('should pass branch option to createSharedClone when specified', async () => {
// Given: Task with custom branch
const task: TaskInfo = {
name: 'task-with-branch',
@ -228,8 +229,8 @@ describe('resolveTaskExecution', () => {
};
mockSummarizeTaskName.mockResolvedValue('custom-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/custom-task',
mockCreateSharedClone.mockReturnValue({
path: '/project/../custom-task',
branch: 'feature/custom-branch',
});
@ -237,14 +238,14 @@ describe('resolveTaskExecution', () => {
await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockCreateWorktree).toHaveBeenCalledWith('/project', {
expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', {
worktree: true,
branch: 'feature/custom-branch',
taskSlug: 'custom-task',
});
});
it('should display worktree creation info', async () => {
it('should display clone creation info', async () => {
// Given: Task with worktree
const task: TaskInfo = {
name: 'info-task',
@ -257,8 +258,8 @@ describe('resolveTaskExecution', () => {
};
mockSummarizeTaskName.mockResolvedValue('info-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128-info-task',
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128-info-task',
branch: 'takt/20260128-info-task',
});
@ -267,7 +268,7 @@ describe('resolveTaskExecution', () => {
// Then
expect(mockInfo).toHaveBeenCalledWith(
'Worktree created: /project/.takt/worktrees/20260128-info-task (branch: takt/20260128-info-task)'
'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)'
);
});
});

View File

@ -36,8 +36,8 @@ import {
} from './commands/index.js';
import { listWorkflows } from './config/workflowLoader.js';
import { selectOptionWithDefault, confirm } from './prompt/index.js';
import { createWorktree } from './task/worktree.js';
import { autoCommitWorktree } from './task/autoCommit.js';
import { createSharedClone, removeClone } from './task/worktree.js';
import { autoCommitAndPush } from './task/autoCommit.js';
import { summarizeTaskName } from './task/summarize.js';
import { DEFAULT_WORKFLOW_NAME } from './constants.js';
@ -49,9 +49,9 @@ export interface WorktreeConfirmationResult {
}
/**
* Ask user whether to create a worktree, and create one if confirmed.
* Returns the execution directory and whether a worktree was created.
* Task name is summarized to English by AI for use in branch/worktree names.
* Ask user whether to create a shared clone, and create one if confirmed.
* Returns the execution directory and whether a clone was created.
* Task name is summarized to English by AI for use in branch/clone names.
*/
export async function confirmAndCreateWorktree(
cwd: string,
@ -67,11 +67,11 @@ export async function confirmAndCreateWorktree(
info('Generating branch name...');
const taskSlug = await summarizeTaskName(task, { cwd });
const result = createWorktree(cwd, {
const result = createSharedClone(cwd, {
worktree: true,
taskSlug,
});
info(`Worktree created: ${result.path} (branch: ${result.branch})`);
info(`Clone created: ${result.path} (branch: ${result.branch})`);
return { execCwd: result.path, isWorktree: true };
}
@ -228,14 +228,19 @@ program
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd);
if (taskSuccess && isWorktree) {
const commitResult = autoCommitWorktree(execCwd, task);
const commitResult = autoCommitAndPush(execCwd, task);
if (commitResult.success && commitResult.commitHash) {
success(`Auto-committed: ${commitResult.commitHash}`);
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
} else if (!commitResult.success) {
error(`Auto-commit failed: ${commitResult.message}`);
}
}
// Remove clone after task completion (success or failure)
if (isWorktree) {
removeClone(execCwd);
}
if (!taskSuccess) {
process.exit(1);
}

View File

@ -17,7 +17,7 @@ Usage:
takt /run-tasks (/run) Run all pending tasks from .takt/tasks/
takt /watch Watch for tasks and auto-execute (stays resident)
takt /add-task (/add) Add a new task (interactive, YAML format)
takt /review-tasks (/review) Review worktree task results (merge/delete)
takt /review-tasks (/review) Review task branches (merge/delete)
takt /switch Switch workflow interactively
takt /clear Clear agent conversation sessions (reset to initial state)
takt /refresh-builtin Overwrite builtin agents/workflows with latest version
@ -30,13 +30,13 @@ Examples:
takt /clear # Clear sessions, start fresh
takt /watch # Watch & auto-execute tasks
takt /refresh-builtin # Update builtin resources
takt /review-tasks # Review & merge worktree results
takt /review-tasks # Review & merge task branches
takt /switch
takt /run-tasks
Task files (.takt/tasks/):
.md files Plain text tasks (backward compatible)
.yaml files Structured tasks with worktree/branch/workflow options
.yaml files Structured tasks with isolation/branch/workflow options
Configuration (.takt/config.yaml):
workflow: default # Current workflow

View File

@ -1,31 +1,33 @@
/**
* Review tasks command
*
* Interactive UI for reviewing worktree-based task results:
* 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 {
removeWorktree,
detectDefaultBranch,
listTaktWorktrees,
listTaktBranches,
buildReviewItems,
type WorktreeReviewItem,
createTempCloneForBranch,
removeClone,
type BranchReviewItem,
} from '../task/worktree.js';
import { autoCommitAndPush } from '../task/autoCommit.js';
import { selectOption, confirm, promptInput } from '../prompt/index.js';
import { info, success, error as logError, warn } from '../utils/ui.js';
import { createLogger } from '../utils/debug.js';
import { executeTask } from './taskExecution.js';
import { autoCommitWorktree } from '../task/autoCommit.js';
import { listWorkflows } from '../config/workflowLoader.js';
import { getCurrentWorkflow } from '../config/paths.js';
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
const log = createLogger('review-tasks');
/** Actions available for a reviewed worktree */
/** Actions available for a reviewed branch */
export type ReviewAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
/**
@ -72,7 +74,7 @@ export function showFullDiff(
async function showDiffAndPromptAction(
cwd: string,
defaultBranch: string,
item: WorktreeReviewItem,
item: BranchReviewItem,
): Promise<ReviewAction | null> {
console.log();
console.log(chalk.bold.cyan(`=== ${item.info.branch} ===`));
@ -94,10 +96,10 @@ async function showDiffAndPromptAction(
`Action for ${item.info.branch}:`,
[
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions to modify this worktree' },
{ 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 (if needed) and remove worktree & branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, remove worktree and branch' },
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
],
);
@ -106,10 +108,9 @@ async function showDiffAndPromptAction(
/**
* Try-merge (squash): stage changes from branch without committing.
* Keeps the worktree and branch intact for further review.
* User can inspect staged changes and commit manually if satisfied.
*/
export function tryMergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem): boolean {
export function tryMergeBranch(projectDir: string, item: BranchReviewItem): boolean {
const { branch } = item.info;
try {
@ -133,18 +134,16 @@ export function tryMergeWorktreeBranch(projectDir: string, item: WorktreeReviewI
}
/**
* Merge & cleanup: if already merged, skip merge and just cleanup.
* Otherwise merge first, then cleanup (remove worktree + delete branch).
* 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 mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem): boolean {
export function mergeBranch(projectDir: string, item: BranchReviewItem): boolean {
const { branch } = item.info;
const alreadyMerged = isBranchMerged(projectDir, branch);
try {
// 1. Remove worktree (must happen before merge to unlock branch)
removeWorktree(projectDir, item.info.path);
// 2. Merge only if not already merged
// Merge only if not already merged
if (alreadyMerged) {
info(`${branch} is already merged, skipping merge.`);
log.info('Branch already merged, cleanup only', { branch });
@ -156,7 +155,7 @@ export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem
});
}
// 3. Delete the branch
// Delete the branch
try {
execFileSync('git', ['branch', '-d', branch], {
cwd: projectDir,
@ -168,7 +167,7 @@ export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem
}
success(`Merged & cleaned up ${branch}`);
log.info('Worktree merged & cleaned up', { branch, alreadyMerged });
log.info('Branch merged & cleaned up', { branch, alreadyMerged });
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@ -180,16 +179,14 @@ export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem
}
/**
* Delete a worktree and its branch (discard changes).
* Delete a branch (discard changes).
* No worktree removal needed clones are ephemeral.
*/
export function deleteWorktreeBranch(projectDir: string, item: WorktreeReviewItem): boolean {
export function deleteBranch(projectDir: string, item: BranchReviewItem): boolean {
const { branch } = item.info;
try {
// 1. Remove worktree
removeWorktree(projectDir, item.info.path);
// 2. Force-delete the branch
// Force-delete the branch
execFileSync('git', ['branch', '-D', branch], {
cwd: projectDir,
encoding: 'utf-8',
@ -197,7 +194,7 @@ export function deleteWorktreeBranch(projectDir: string, item: WorktreeReviewIte
});
success(`Deleted ${branch}`);
log.info('Worktree deleted', { branch });
log.info('Branch deleted', { branch });
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@ -233,9 +230,9 @@ async function selectWorkflowForInstruction(projectDir: string): Promise<string
}
/**
* Get worktree context: diff stat and commit log from main branch.
* Get branch context: diff stat and commit log from main branch.
*/
function getWorktreeContext(projectDir: string, branch: string): string {
function getBranchContext(projectDir: string, branch: string): string {
const defaultBranch = detectDefaultBranch(projectDir);
const lines: string[] = [];
@ -276,15 +273,14 @@ function getWorktreeContext(projectDir: string, branch: string): string {
}
/**
* Instruct worktree: give additional instructions to modify the worktree.
* Executes a task on the worktree and auto-commits if successful.
* Instruct branch: create a temp clone, give additional instructions,
* auto-commit+push, then remove clone.
*/
export async function instructWorktree(
export async function instructBranch(
projectDir: string,
item: WorktreeReviewItem,
item: BranchReviewItem,
): Promise<boolean> {
const { branch } = item.info;
const worktreePath = item.info.path;
// 1. Prompt for instruction
const instruction = await promptInput('Enter instruction');
@ -300,23 +296,27 @@ export async function instructWorktree(
return false;
}
log.info('Instructing worktree', { branch, worktreePath, workflow: selectedWorkflow });
log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow });
info(`Running instruction on ${branch}...`);
// 3. Build instruction with worktree context
const worktreeContext = getWorktreeContext(projectDir, branch);
const fullInstruction = worktreeContext
? `${worktreeContext}## 追加指示\n${instruction}`
// 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;
// 4. Execute task on worktree
const taskSuccess = await executeTask(fullInstruction, worktreePath, selectedWorkflow, projectDir);
// 5. Execute task on temp clone
const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir);
// 5. Auto-commit if successful
// 6. Auto-commit+push if successful
if (taskSuccess) {
const commitResult = autoCommitWorktree(worktreePath, item.taskSlug);
const commitResult = autoCommitAndPush(clone.path, item.taskSlug);
if (commitResult.success && commitResult.commitHash) {
info(`Auto-committed: ${commitResult.commitHash}`);
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
} else if (!commitResult.success) {
warn(`Auto-commit skipped: ${commitResult.message}`);
}
@ -328,25 +328,29 @@ export async function instructWorktree(
}
return taskSuccess;
} finally {
// 7. Always remove temp clone
removeClone(clone.path);
}
}
/**
* Main entry point: review worktree tasks interactively.
* Main entry point: review branch-based tasks interactively.
*/
export async function reviewTasks(cwd: string): Promise<void> {
log.info('Starting review-tasks');
const defaultBranch = detectDefaultBranch(cwd);
let worktrees = listTaktWorktrees(cwd);
let branches = listTaktBranches(cwd);
if (worktrees.length === 0) {
if (branches.length === 0) {
info('No tasks to review.');
return;
}
// Interactive loop
while (worktrees.length > 0) {
const items = buildReviewItems(cwd, worktrees, defaultBranch);
while (branches.length > 0) {
const items = buildReviewItems(cwd, branches, defaultBranch);
// Build selection options
const options = items.map((item, idx) => ({
@ -356,7 +360,7 @@ export async function reviewTasks(cwd: string): Promise<void> {
}));
const selected = await selectOption<string>(
'Review Tasks (Worktrees)',
'Review Tasks (Branches)',
options,
);
@ -382,13 +386,13 @@ export async function reviewTasks(cwd: string): Promise<void> {
switch (action) {
case 'instruct':
await instructWorktree(cwd, item);
await instructBranch(cwd, item);
break;
case 'try':
tryMergeWorktreeBranch(cwd, item);
tryMergeBranch(cwd, item);
break;
case 'merge':
mergeWorktreeBranch(cwd, item);
mergeBranch(cwd, item);
break;
case 'delete': {
const confirmed = await confirm(
@ -396,14 +400,14 @@ export async function reviewTasks(cwd: string): Promise<void> {
false,
);
if (confirmed) {
deleteWorktreeBranch(cwd, item);
deleteBranch(cwd, item);
}
break;
}
}
// Refresh worktree list after action
worktrees = listTaktWorktrees(cwd);
// Refresh branch list after action
branches = listTaktBranches(cwd);
}
info('All tasks reviewed.');

View File

@ -4,8 +4,8 @@
import { loadWorkflow, loadGlobalConfig } from '../config/index.js';
import { TaskRunner, type TaskInfo } from '../task/index.js';
import { createWorktree } from '../task/worktree.js';
import { autoCommitWorktree } from '../task/autoCommit.js';
import { createSharedClone, removeClone } from '../task/worktree.js';
import { autoCommitAndPush } from '../task/autoCommit.js';
import { summarizeTaskName } from '../task/summarize.js';
import {
header,
@ -24,7 +24,7 @@ const log = createLogger('task');
/**
* Execute a single task with workflow
* @param task - Task content
* @param cwd - Working directory (may be a worktree path)
* @param cwd - Working directory (may be a clone path)
* @param workflowName - Workflow to use
* @param projectCwd - Project root (where .takt/ lives). Defaults to cwd.
*/
@ -57,7 +57,7 @@ export async function executeTask(
}
/**
* Execute a task: resolve worktree run workflow auto-commit record completion.
* 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.
@ -81,14 +81,19 @@ export async function executeAndCompleteTask(
const completedAt = new Date().toISOString();
if (taskSuccess && isWorktree) {
const commitResult = autoCommitWorktree(execCwd, task.name);
const commitResult = autoCommitAndPush(execCwd, task.name);
if (commitResult.success && commitResult.commitHash) {
info(`Auto-committed: ${commitResult.commitHash}`);
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
} else if (!commitResult.success) {
error(`Auto-commit failed: ${commitResult.message}`);
}
}
// Remove clone after task completion (success or failure)
if (isWorktree) {
removeClone(execCwd);
}
const taskResult = {
task,
success: taskSuccess,
@ -179,8 +184,8 @@ export async function runAllTasks(
/**
* Resolve execution directory and workflow from task data.
* If the task has worktree settings, create a worktree and use it as cwd.
* Task name is summarized to English by AI for use in branch/worktree names.
* 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,
@ -197,20 +202,20 @@ export async function resolveTaskExecution(
let execCwd = defaultCwd;
let isWorktree = false;
// Handle worktree
// 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 = createWorktree(defaultCwd, {
const result = createSharedClone(defaultCwd, {
worktree: data.worktree,
branch: data.branch,
taskSlug,
});
execCwd = result.path;
isWorktree = true;
info(`Worktree created: ${result.path} (branch: ${result.branch})`);
info(`Clone created: ${result.path} (branch: ${result.branch})`);
}
// Handle workflow override

View File

@ -81,7 +81,7 @@ export async function executeWorkflow(
headerPrefix = 'Running Workflow:',
} = options;
// projectCwd is where .takt/ lives (project root, not worktree)
// projectCwd is where .takt/ lives (project root, not the clone)
const projectCwd = options.projectCwd ?? cwd;
// Always continue from previous sessions (use /clear to reset)
@ -108,14 +108,14 @@ export async function executeWorkflow(
displayRef.current.createHandler()(event);
};
// Load saved agent sessions for continuity (from project root or worktree-specific storage)
// Load saved agent sessions for continuity (from project root or clone-specific storage)
const isWorktree = cwd !== projectCwd;
const savedSessions = isWorktree
? loadWorktreeSessions(projectCwd, cwd)
: loadAgentSessions(projectCwd);
// Session update handler - persist session IDs when they change
// Worktree sessions are stored separately per worktree path
// Clone sessions are stored separately per clone path
const sessionUpdateHandler = isWorktree
? (agentName: string, agentSessionId: string): void => {
updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId);

View File

@ -127,7 +127,7 @@ export const GlobalConfigSchema = z.object({
provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'),
model: z.string().optional(),
debug: DebugConfigSchema.optional(),
/** Directory for worktrees. If empty, uses ../{tree-name} relative to project */
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktree_dir: z.string().optional(),
});

View File

@ -160,7 +160,7 @@ export interface GlobalConfig {
provider?: 'claude' | 'codex' | 'mock';
model?: string;
debug?: DebugConfig;
/** Directory for worktrees. If empty, uses ../{tree-name} relative to project */
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string;
}

View File

@ -1,8 +1,9 @@
/**
* Auto-commit for worktree tasks
* Auto-commit and push for clone tasks
*
* After a successful workflow completion in a worktree,
* automatically stages all changes and creates a commit.
* After a successful workflow completion in a shared clone,
* automatically stages all changes, creates a commit, and
* pushes to origin so the branch is reflected in the main repo.
* No co-author trailer is added.
*/
@ -21,29 +22,30 @@ export interface AutoCommitResult {
}
/**
* Auto-commit all changes in a worktree directory.
* Auto-commit all changes and push to origin.
*
* Steps:
* 1. Stage all changes (git add -A)
* 2. Check if there are staged changes (git status --porcelain)
* 3. If changes exist, create a commit with "takt: {taskName}"
* 4. Push to origin (git push origin HEAD)
*
* @param worktreeCwd - The worktree directory
* @param cloneCwd - The clone directory
* @param taskName - Task name used in commit message
*/
export function autoCommitWorktree(worktreeCwd: string, taskName: string): AutoCommitResult {
log.info('Auto-commit starting', { cwd: worktreeCwd, taskName });
export function autoCommitAndPush(cloneCwd: string, taskName: string): AutoCommitResult {
log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
try {
// Stage all changes
execFileSync('git', ['add', '-A'], {
cwd: worktreeCwd,
cwd: cloneCwd,
stdio: 'pipe',
});
// Check if there are staged changes
const statusOutput = execFileSync('git', ['status', '--porcelain'], {
cwd: worktreeCwd,
cwd: cloneCwd,
stdio: 'pipe',
encoding: 'utf-8',
});
@ -56,23 +58,31 @@ export function autoCommitWorktree(worktreeCwd: string, taskName: string): AutoC
// Create commit (no co-author)
const commitMessage = `takt: ${taskName}`;
execFileSync('git', ['commit', '-m', commitMessage], {
cwd: worktreeCwd,
cwd: cloneCwd,
stdio: 'pipe',
});
// Get the short commit hash
const commitHash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: worktreeCwd,
cwd: cloneCwd,
stdio: 'pipe',
encoding: 'utf-8',
}).trim();
log.info('Auto-commit created', { commitHash, message: commitMessage });
// Push to origin so the branch is reflected in the main repo
execFileSync('git', ['push', 'origin', 'HEAD'], {
cwd: cloneCwd,
stdio: 'pipe',
});
log.info('Pushed to origin');
return {
success: true,
commitHash,
message: `Committed: ${commitHash} - ${commitMessage}`,
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);

View File

@ -13,18 +13,19 @@ export { showTaskList } from './display.js';
export { TaskFileSchema, type TaskFileData } from './schema.js';
export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js';
export {
createWorktree,
removeWorktree,
createSharedClone,
removeClone,
createTempCloneForBranch,
detectDefaultBranch,
parseTaktWorktrees,
listTaktWorktrees,
listTaktBranches,
parseTaktBranches,
getFilesChanged,
extractTaskSlug,
buildReviewItems,
type WorktreeOptions,
type WorktreeResult,
type WorktreeInfo,
type WorktreeReviewItem,
type BranchInfo,
type BranchReviewItem,
} from './worktree.js';
export { autoCommitWorktree, type AutoCommitResult } from './autoCommit.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';

View File

@ -11,14 +11,14 @@ import { z } from 'zod/v4';
*
* Examples:
* task: "認証機能を追加する"
* worktree: true # .takt/worktrees/{timestamp}-{task-slug}/
* worktree: true #
* branch: "feat/add-auth" #
* workflow: "default" # current workflow
*
* worktree patterns:
* - true: create at .takt/worktrees/{timestamp}-{task-slug}/
* worktree patterns (uses git clone --shared internally):
* - true: create shared clone in sibling dir or worktree_dir
* - "/path/to/dir": create at specified path
* - omitted: no worktree (run in cwd)
* - omitted: no isolation (run in cwd)
*
* branch patterns:
* - "feat/xxx": use specified branch name

View File

@ -1,7 +1,7 @@
/**
* Task name summarization using AI or romanization
*
* Generates concise English/romaji summaries for use in branch names and worktree paths.
* Generates concise English/romaji summaries for use in branch names and clone paths.
*/
import * as wanakana from 'wanakana';

View File

@ -1,7 +1,10 @@
/**
* Git worktree management
* Git shared clone management
*
* Creates and removes git worktrees for task isolation.
* Creates and removes git shared clones for task isolation.
* Uses `git clone --shared` instead of worktrees so each clone
* has an independent .git directory, preventing Claude Code from
* traversing gitdir back to the main repository.
*/
import * as fs from 'node:fs';
@ -23,12 +26,25 @@ export interface WorktreeOptions {
}
export interface WorktreeResult {
/** Absolute path to the worktree */
/** Absolute path to the clone */
path: string;
/** Branch name used */
branch: string;
}
/** Branch info from `git branch --list` */
export interface BranchInfo {
branch: string;
commit: string;
}
/** Branch with review metadata */
export interface BranchReviewItem {
info: BranchInfo;
filesChanged: number;
taskSlug: string;
}
/**
* Generate a timestamp string for paths/branches
*/
@ -37,14 +53,14 @@ function generateTimestamp(): string {
}
/**
* Resolve the worktree path based on options and global config.
* Resolve the clone path based on options and global config.
*
* Priority:
* 1. Custom path in options.worktree (string)
* 2. worktree_dir from config.yaml (if set)
* 3. Default: ../{tree-name}
* 3. Default: ../{dir-name}
*/
function resolveWorktreePath(projectDir: string, options: WorktreeOptions): string {
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = generateTimestamp();
const slug = slugify(options.taskSlug);
const dirName = slug ? `${timestamp}-${slug}` : timestamp;
@ -96,52 +112,98 @@ function branchExists(projectDir: string, branch: string): boolean {
}
/**
* Create a git worktree for a task
* Create a git shared clone for a task.
*
* Uses `git clone --shared` to create a lightweight clone with
* an independent .git directory. Then checks out a new branch.
*
* @returns WorktreeResult with path and branch
* @throws Error if git worktree creation fails
* @throws Error if git clone creation fails
*/
export function createWorktree(projectDir: string, options: WorktreeOptions): WorktreeResult {
const worktreePath = resolveWorktreePath(projectDir, options);
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = resolveClonePath(projectDir, options);
const branch = resolveBranchName(options);
log.info('Creating worktree', { path: worktreePath, branch });
log.info('Creating shared clone', { path: clonePath, branch });
// Ensure parent directory exists
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
// Create worktree (use execFileSync to avoid shell injection)
if (branchExists(projectDir, branch)) {
execFileSync('git', ['worktree', 'add', worktreePath, branch], {
// Create shared clone
execFileSync('git', ['clone', '--shared', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
// Checkout branch
if (branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], {
cwd: clonePath,
stdio: 'pipe',
});
} else {
execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath], {
cwd: projectDir,
execFileSync('git', ['checkout', '-b', branch], {
cwd: clonePath,
stdio: 'pipe',
});
}
log.info('Worktree created', { path: worktreePath, branch });
log.info('Shared clone created', { path: clonePath, branch });
return { path: worktreePath, branch };
return { path: clonePath, branch };
}
/**
* Remove a git worktree
* Create a temporary shared clone for an existing branch.
* Used by review/instruct to work on a branch that was previously pushed.
*
* @returns WorktreeResult with path and branch
* @throws Error if git clone creation fails
*/
export function removeWorktree(projectDir: string, worktreePath: string): void {
log.info('Removing worktree', { path: worktreePath });
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = generateTimestamp();
const globalConfig = loadGlobalConfig();
let clonePath: string;
try {
execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], {
if (globalConfig.worktreeDir) {
const baseDir = path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
clonePath = path.join(baseDir, `tmp-${timestamp}`);
} else {
clonePath = path.join(projectDir, '..', `tmp-${timestamp}`);
}
log.info('Creating temp clone for branch', { path: clonePath, branch });
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--shared', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
log.info('Worktree removed', { path: worktreePath });
execFileSync('git', ['checkout', branch], {
cwd: clonePath,
stdio: 'pipe',
});
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/**
* Remove a clone directory
*/
export function removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove worktree', { path: worktreePath, error: String(err) });
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
}
@ -149,20 +211,6 @@ export function removeWorktree(projectDir: string, worktreePath: string): void {
const TAKT_BRANCH_PREFIX = 'takt/';
/** Parsed worktree entry from git worktree list */
export interface WorktreeInfo {
path: string;
branch: string;
commit: string;
}
/** Worktree with review metadata */
export interface WorktreeReviewItem {
info: WorktreeInfo;
filesChanged: number;
taskSlug: string;
}
/**
* Detect the default branch name (main or master).
* Falls back to 'main'.
@ -197,54 +245,48 @@ export function detectDefaultBranch(cwd: string): string {
}
/**
* Parse `git worktree list --porcelain` output into WorktreeInfo entries.
* Only includes worktrees on branches with the takt/ prefix.
* List all takt-managed branches.
* Uses `git branch --list 'takt/*'` instead of worktree list.
*/
export function parseTaktWorktrees(porcelainOutput: string): WorktreeInfo[] {
const entries: WorktreeInfo[] = [];
const blocks = porcelainOutput.trim().split('\n\n');
export function listTaktBranches(projectDir: string): BranchInfo[] {
try {
const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return parseTaktBranches(output);
} catch (err) {
log.error('Failed to list takt branches', { error: String(err) });
return [];
}
}
for (const block of blocks) {
const lines = block.split('\n');
let wtPath = '';
let commit = '';
let branch = '';
/**
* Parse `git branch --list` formatted output into BranchInfo entries.
*/
export function parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = [];
const lines = output.trim().split('\n');
for (const line of lines) {
if (line.startsWith('worktree ')) {
wtPath = line.slice('worktree '.length);
} else if (line.startsWith('HEAD ')) {
commit = line.slice('HEAD '.length);
} else if (line.startsWith('branch ')) {
const ref = line.slice('branch '.length);
branch = ref.replace('refs/heads/', '');
}
}
const trimmed = line.trim();
if (!trimmed) continue;
if (wtPath && branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ path: wtPath, branch, commit });
// Format: "takt/20260128-fix-auth abc1234"
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx === -1) continue;
const branch = trimmed.slice(0, spaceIdx);
const commit = trimmed.slice(spaceIdx + 1);
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ branch, commit });
}
}
return entries;
}
/**
* List all takt-managed worktrees.
*/
export function listTaktWorktrees(projectDir: string): WorktreeInfo[] {
try {
const output = execFileSync(
'git', ['worktree', 'list', '--porcelain'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return parseTaktWorktrees(output);
} catch (err) {
log.error('Failed to list worktrees', { error: String(err) });
return [];
}
}
/**
* Get the number of files changed between the default branch and a given branch.
*/
@ -262,7 +304,7 @@ export function getFilesChanged(cwd: string, defaultBranch: string, branch: stri
/**
* Extract a human-readable task slug from a takt branch name.
* e.g. "takt/20260128T032800-fix-auth" "fix-auth"
* e.g. "takt/20260128T032800-fix-auth" -> "fix-auth"
*/
export function extractTaskSlug(branch: string): string {
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
@ -272,16 +314,16 @@ export function extractTaskSlug(branch: string): string {
}
/**
* Build review items from worktree list, enriching with diff stats.
* Build review items from branch list, enriching with diff stats.
*/
export function buildReviewItems(
projectDir: string,
worktrees: WorktreeInfo[],
branches: BranchInfo[],
defaultBranch: string,
): WorktreeReviewItem[] {
return worktrees.map(wt => ({
info: wt,
filesChanged: getFilesChanged(projectDir, defaultBranch, wt.branch),
taskSlug: extractTaskSlug(wt.branch),
): BranchReviewItem[] {
return branches.map(br => ({
info: br,
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: extractTaskSlug(br.branch),
}));
}

View File

@ -73,7 +73,7 @@ export class WorkflowEngine extends EventEmitter {
});
}
/** Ensure report directory exists (always in project root, not worktree) */
/** Ensure report directory exists (always in project root, not clone) */
private ensureReportDirExists(): void {
const reportDirPath = join(this.projectCwd, '.takt', 'reports', this.reportDir);
if (!existsSync(reportDirPath)) {

View File

@ -21,7 +21,7 @@ export interface InstructionContext {
maxIterations: number;
/** Current step's iteration number (how many times this step has been executed) */
stepIteration: number;
/** Working directory (agent work dir, may be a worktree) */
/** Working directory (agent work dir, may be a clone) */
cwd: string;
/** Project root directory (where .takt/ lives). Defaults to cwd. */
projectCwd?: string;
@ -37,7 +37,7 @@ export interface InstructionContext {
/** Execution environment metadata prepended to agent instructions */
export interface ExecutionMetadata {
/** The agent's working directory (may be a worktree) */
/** The agent's working directory (may be a clone) */
readonly workingDirectory: string;
/** Language for metadata rendering */
readonly language: Language;
@ -183,7 +183,7 @@ export function buildInstruction(
// Replace .takt/reports/{report_dir} with absolute path first,
// then replace standalone {report_dir} with the directory name.
// This ensures agents always use the correct project root for reports,
// even when their cwd is a worktree.
// even when their cwd is a clone.
if (context.reportDir) {
const projectRoot = context.projectCwd ?? context.cwd;
const reportDirFullPath = join(projectRoot, '.takt', 'reports', context.reportDir);