From 57fd01819f4a33644ec278aa00b602f4ed2a4d6a Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:23:35 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E6=83=85=E5=A0=B1=E3=82=92=E6=AE=8B=E3=81=99=E3=80=81worktree?= =?UTF-8?q?=E3=82=92=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=A4=96=E3=81=AB=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 33 ++++-- README.md | 70 +++++++++--- src/__tests__/session.test.ts | 174 ++++++++++++++++++++++++++++++ src/commands/workflowExecution.ts | 11 ++ src/config/paths.ts | 1 + src/config/sessionStore.ts | 2 +- src/task/worktree.ts | 10 +- src/utils/session.ts | 56 +++++++++- 8 files changed, 322 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/session.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ec8565b..5bcf592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,16 +19,17 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl ## CLI Slash Commands -| Command | Description | -|---------|-------------| -| `takt /run-tasks` | Execute all pending tasks from `.takt/tasks/` once | -| `takt /watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) | -| `takt /add-task` | Add a new task interactively (YAML format) | -| `takt /switch` | Switch workflow interactively | -| `takt /clear` | Clear agent conversation sessions (reset state) | -| `takt /refresh-builtin` | Update builtin resources from `resources/` to `~/.takt/` | -| `takt /help` | Show help message | -| `takt /config` | Display current configuration | +| Command | Alias | Description | +|---------|-------|-------------| +| `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 /switch` | | Switch workflow interactively | +| `takt /clear` | | Clear agent conversation sessions (reset state) | +| `takt /refresh-builtin` | | Update builtin resources from `resources/` to `~/.takt/` | +| `takt /help` | | Show help message | +| `takt /config` | | Display current configuration | ## Architecture @@ -185,6 +186,18 @@ model: opus # Default model for all steps (unless overridden) - `improve` - Needs improvement (security concerns, quality issues) - `always` - Unconditional transition +## Worktree Execution + +When tasks specify `worktree: true` or `worktree: "path"`, code runs in a git worktree (separate checkout). Key constraints: + +- **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`. + +## Error Propagation + +`ClaudeResult` (from SDK) has an `error` field. This must be propagated through `AgentResponse.error` → session log history → console output. Without this, SDK failures (exit code 1, rate limits, auth errors) appear as empty `blocked` status with no diagnostic info. + ## Testing Notes - Vitest for testing framework diff --git a/README.md b/README.md index 5483430..0456b5e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ npm install -g takt ## Quick Start ```bash -# Run a task (will prompt for workflow selection) +# Run a task (will prompt for workflow selection and optional worktree creation) takt "Add a login feature" # Add a task to the queue @@ -35,24 +35,28 @@ takt /run-tasks # Watch for tasks and auto-execute takt /watch +# Review worktree results (merge or delete) +takt /review-tasks + # Switch workflow takt /switch ``` ## Commands -| Command | Description | -|---------|-------------| -| `takt "task"` | Execute task with current workflow (continues session) | -| `takt -r "task"` | Execute task, resuming previous session | -| `takt /run-tasks` | Run all pending tasks from `.takt/tasks/` | -| `takt /watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) | -| `takt /add-task` | Add a new task interactively (YAML format) | -| `takt /switch` | Switch workflow interactively | -| `takt /clear` | Clear agent conversation sessions | -| `takt /refresh-builtin` | Update builtin agents/workflows to latest version | -| `takt /config` | Display current configuration | -| `takt /help` | Show help | +| Command | Alias | Description | +|---------|-------|-------------| +| `takt "task"` | | Execute task with current workflow (continues session) | +| `takt -r "task"` | | Execute task, resuming previous session | +| `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 /switch` | | Switch workflow interactively | +| `takt /clear` | | Clear agent conversation sessions | +| `takt /refresh-builtin` | | Update builtin agents/workflows to latest version | +| `takt /config` | | Display current configuration | +| `takt /help` | | Show help | ## Workflows @@ -183,7 +187,10 @@ Available Codex models: ├── completed/ # Completed tasks with reports ├── worktrees/ # Git worktrees for isolated task execution ├── reports/ # Execution reports (auto-generated) -└── logs/ # Session logs +└── logs/ # Session logs (incremental) + ├── latest.json # Pointer to current/latest session + ├── previous.json # Pointer to previous session + └── {sessionId}.json # Full session log per workflow run ``` ### Global Configuration @@ -224,6 +231,15 @@ takt -r "The bug occurs when the password contains special characters" The `-r` flag preserves the agent's conversation history, allowing for natural back-and-forth interaction. +### Interactive Workflow + +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 + +This interactive flow ensures each task runs with the right workflow and isolation level. + ### Adding Custom Workflows Create your own workflow by adding YAML files to `~/.takt/workflows/`: @@ -335,6 +351,8 @@ YAML task files can specify `worktree` to run each task in an isolated git workt - `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. + #### Running Tasks with `/run-tasks` ```bash @@ -356,6 +374,27 @@ 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` + +```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 + +### Session Logs + +TAKT writes session logs incrementally to `.takt/logs/`. Logs are saved at workflow start, after each step, and at workflow end — so even if the process crashes mid-execution, partial logs are preserved. + +- `.takt/logs/latest.json` - Pointer to the current (or most recent) session +- `.takt/logs/previous.json` - Pointer to the previous session +- `.takt/logs/{sessionId}.json` - Full session log with step history + +Agents can read `previous.json` to pick up context from a prior run (e.g., when resuming with `takt "続けて"`). + ### Workflow Variables Available variables in `instruction_template`: @@ -401,7 +440,7 @@ transitions: next_step: ABORT # End workflow with failure ``` -Available transition conditions: `done`, `blocked`, `approved`, `rejected`, `improve`, `always`. +Available transition conditions: `done`, `blocked`, `approved`, `rejected`, `improve`, `answer`, `always`. Special next_step values: `COMPLETE` (success), `ABORT` (failure). **Step options:** @@ -413,6 +452,7 @@ Special next_step values: `COMPLETE` (success), `ABORT` (failure). | `allowed_tools` | - | List of tools the agent can use (Read, Glob, Grep, Edit, Write, Bash, etc.) | | `provider` | - | Override provider for this step (`claude` or `codex`) | | `model` | - | Override model for this step | +| `permission_mode` | `default` | Permission mode: `default`, `acceptEdits`, or `bypassPermissions` | ## API Usage diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts new file mode 100644 index 0000000..4242bb3 --- /dev/null +++ b/src/__tests__/session.test.ts @@ -0,0 +1,174 @@ +/** + * Tests for session log incremental writes and pointer management + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + createSessionLog, + saveSessionLog, + updateLatestPointer, + type LatestLogPointer, + type SessionLog, +} from '../utils/session.js'; + +/** Create a temp project directory with .takt/logs structure */ +function createTempProject(): string { + const dir = join(tmpdir(), `takt-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe('saveSessionLog (atomic)', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('should create session log file with correct content', () => { + const log = createSessionLog('test task', projectDir, 'default'); + const sessionId = 'test-session-001'; + + const filepath = saveSessionLog(log, sessionId, projectDir); + + expect(existsSync(filepath)).toBe(true); + const content = JSON.parse(readFileSync(filepath, 'utf-8')) as SessionLog; + expect(content.task).toBe('test task'); + expect(content.workflowName).toBe('default'); + expect(content.status).toBe('running'); + expect(content.iterations).toBe(0); + expect(content.history).toEqual([]); + }); + + it('should overwrite existing log file on subsequent saves', () => { + const log = createSessionLog('test task', projectDir, 'default'); + const sessionId = 'test-session-002'; + + saveSessionLog(log, sessionId, projectDir); + + log.iterations = 3; + log.status = 'completed'; + saveSessionLog(log, sessionId, projectDir); + + const filepath = join(projectDir, '.takt', 'logs', `${sessionId}.json`); + const content = JSON.parse(readFileSync(filepath, 'utf-8')) as SessionLog; + expect(content.iterations).toBe(3); + expect(content.status).toBe('completed'); + }); +}); + +describe('updateLatestPointer', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('should create latest.json with pointer data', () => { + const log = createSessionLog('my task', projectDir, 'default'); + const sessionId = 'abc-123'; + + updateLatestPointer(log, sessionId, projectDir); + + const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); + expect(existsSync(latestPath)).toBe(true); + + const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; + expect(pointer.sessionId).toBe('abc-123'); + expect(pointer.logFile).toBe('abc-123.json'); + expect(pointer.task).toBe('my task'); + expect(pointer.workflowName).toBe('default'); + expect(pointer.status).toBe('running'); + expect(pointer.iterations).toBe(0); + expect(pointer.startTime).toBeDefined(); + expect(pointer.updatedAt).toBeDefined(); + }); + + it('should not create previous.json when copyToPrevious is false', () => { + const log = createSessionLog('task', projectDir, 'wf'); + updateLatestPointer(log, 'sid-1', projectDir); + + const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); + expect(existsSync(previousPath)).toBe(false); + }); + + it('should not create previous.json when copyToPrevious is true but latest.json does not exist', () => { + const log = createSessionLog('task', projectDir, 'wf'); + updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); + + const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); + // latest.json didn't exist before this call, so previous.json should not be created + expect(existsSync(previousPath)).toBe(false); + }); + + it('should copy latest.json to previous.json when copyToPrevious is true and latest exists', () => { + const log1 = createSessionLog('first task', projectDir, 'wf1'); + updateLatestPointer(log1, 'sid-first', projectDir); + + // Simulate a second workflow starting + const log2 = createSessionLog('second task', projectDir, 'wf2'); + updateLatestPointer(log2, 'sid-second', projectDir, { copyToPrevious: true }); + + const logsDir = join(projectDir, '.takt', 'logs'); + const latest = JSON.parse(readFileSync(join(logsDir, 'latest.json'), 'utf-8')) as LatestLogPointer; + const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; + + // latest should point to second session + expect(latest.sessionId).toBe('sid-second'); + expect(latest.task).toBe('second task'); + + // previous should point to first session + expect(previous.sessionId).toBe('sid-first'); + expect(previous.task).toBe('first task'); + }); + + it('should not update previous.json on step-complete calls (no copyToPrevious)', () => { + // Workflow 1 creates latest + const log1 = createSessionLog('first', projectDir, 'wf'); + updateLatestPointer(log1, 'sid-1', projectDir); + + // Workflow 2 starts → copies latest to previous + const log2 = createSessionLog('second', projectDir, 'wf'); + updateLatestPointer(log2, 'sid-2', projectDir, { copyToPrevious: true }); + + // Step completes → updates only latest (no copyToPrevious) + log2.iterations = 1; + updateLatestPointer(log2, 'sid-2', projectDir); + + const logsDir = join(projectDir, '.takt', 'logs'); + const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; + + // previous should still point to first session + expect(previous.sessionId).toBe('sid-1'); + }); + + it('should update iterations and status in latest.json on subsequent calls', () => { + const log = createSessionLog('task', projectDir, 'wf'); + updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); + + // Simulate step completion + log.iterations = 2; + updateLatestPointer(log, 'sid-1', projectDir); + + // Simulate workflow completion + log.status = 'completed'; + log.iterations = 3; + updateLatestPointer(log, 'sid-1', projectDir); + + const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); + const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; + expect(pointer.status).toBe('completed'); + expect(pointer.iterations).toBe(3); + }); +}); diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index a8cffe0..9542a16 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -21,6 +21,7 @@ import { addToSessionLog, finalizeSessionLog, saveSessionLog, + updateLatestPointer, } from '../utils/session.js'; import { createLogger } from '../utils/debug.js'; import { notifySuccess, notifyError } from '../utils/notification.js'; @@ -84,6 +85,10 @@ export async function executeWorkflow( const workflowSessionId = generateSessionId(); const sessionLog = createSessionLog(task, projectCwd, workflowConfig.name); + // Persist initial log + pointer at workflow start (enables crash recovery) + saveSessionLog(sessionLog, workflowSessionId, projectCwd); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); + // Track current display for streaming const displayRef: { current: StreamDisplay | null } = { current: null }; @@ -191,6 +196,10 @@ export async function executeWorkflow( status('Session', response.sessionId); } addToSessionLog(sessionLog, step.name, response); + + // Incremental save after each step + saveSessionLog(sessionLog, workflowSessionId, projectCwd); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd); }); engine.on('workflow:complete', (state) => { @@ -198,6 +207,7 @@ export async function executeWorkflow( finalizeSessionLog(sessionLog, 'completed'); // Save log to project root so user can find it easily const logPath = saveSessionLog(sessionLog, workflowSessionId, projectCwd); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd); const elapsed = sessionLog.endTime ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) @@ -219,6 +229,7 @@ export async function executeWorkflow( finalizeSessionLog(sessionLog, 'aborted'); // Save log to project root so user can find it easily const logPath = saveSessionLog(sessionLog, workflowSessionId, projectCwd); + updateLatestPointer(sessionLog, workflowSessionId, projectCwd); const elapsed = sessionLog.endTime ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) diff --git a/src/config/paths.ts b/src/config/paths.ts index caccac4..389a42d 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -86,6 +86,7 @@ export { // Re-export session storage functions for backward compatibility export { + writeFileAtomic, getInputHistoryPath, MAX_INPUT_HISTORY, loadInputHistory, diff --git a/src/config/sessionStore.ts b/src/config/sessionStore.ts index d31bc92..9981ffd 100644 --- a/src/config/sessionStore.ts +++ b/src/config/sessionStore.ts @@ -13,7 +13,7 @@ import { getProjectConfigDir, ensureDir } from './paths.js'; * Write file atomically using temp file + rename. * This prevents corruption when multiple processes write simultaneously. */ -function writeFileAtomic(filePath: string, content: string): void { +export function writeFileAtomic(filePath: string, content: string): void { const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; try { writeFileSync(tempPath, content, 'utf-8'); diff --git a/src/task/worktree.ts b/src/task/worktree.ts index 59b80f8..bcf86b9 100644 --- a/src/task/worktree.ts +++ b/src/task/worktree.ts @@ -48,18 +48,16 @@ function resolveWorktreePath(projectDir: string, options: WorktreeOptions): stri ? options.worktree : path.resolve(projectDir, options.worktree); - if (!isPathSafe(projectDir, resolved)) { - throw new Error(`Worktree path escapes project directory: ${options.worktree}`); - } - return resolved; } - // worktree: true → .takt/worktrees/{timestamp}-{task-slug}/ + // worktree: true → sibling directory: ../{timestamp}-{task-slug}/ + // Worktrees MUST be outside the project directory to avoid Claude Code + // detecting the parent .git directory and writing to the main project. const timestamp = generateTimestamp(); const slug = slugify(options.taskSlug); const dirName = slug ? `${timestamp}-${slug}` : timestamp; - return path.join(projectDir, '.takt', 'worktrees', dirName); + return path.join(path.dirname(projectDir), dirName); } /** diff --git a/src/utils/session.ts b/src/utils/session.ts index 7341234..27d5b6b 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -2,10 +2,10 @@ * Session management utilities */ -import { writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, copyFileSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentResponse, WorkflowState } from '../models/types.js'; -import { getProjectLogsDir, getGlobalLogsDir, ensureDir } from '../config/paths.js'; +import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js'; /** Session log entry */ export interface SessionLog { @@ -112,7 +112,7 @@ export function saveSessionLog( const filename = `${sessionId}.json`; const filepath = join(logsDir, filename); - writeFileSync(filepath, JSON.stringify(log, null, 2), 'utf-8'); + writeFileAtomic(filepath, JSON.stringify(log, null, 2)); return filepath; } @@ -144,6 +144,56 @@ export function loadProjectContext(projectDir: string): string { return contextParts.join('\n\n---\n\n'); } +/** Pointer metadata for latest/previous log files */ +export interface LatestLogPointer { + sessionId: string; + logFile: string; + task: string; + workflowName: string; + status: SessionLog['status']; + startTime: string; + updatedAt: string; + iterations: number; +} + +/** + * Update latest.json pointer file. + * On first call (workflow start), copies existing latest.json to previous.json. + * On subsequent calls (step complete / workflow end), only overwrites latest.json. + */ +export function updateLatestPointer( + log: SessionLog, + sessionId: string, + projectDir?: string, + options?: { copyToPrevious?: boolean } +): void { + const logsDir = projectDir + ? getProjectLogsDir(projectDir) + : getGlobalLogsDir(); + ensureDir(logsDir); + + const latestPath = join(logsDir, 'latest.json'); + const previousPath = join(logsDir, 'previous.json'); + + // Copy latest → previous only when explicitly requested (workflow start) + if (options?.copyToPrevious && existsSync(latestPath)) { + copyFileSync(latestPath, previousPath); + } + + const pointer: LatestLogPointer = { + sessionId, + logFile: `${sessionId}.json`, + task: log.task, + workflowName: log.workflowName, + status: log.status, + startTime: log.startTime, + updatedAt: new Date().toISOString(), + iterations: log.iterations, + }; + + writeFileAtomic(latestPath, JSON.stringify(pointer, null, 2)); +} + /** Convert workflow state to session log */ export function workflowStateToSessionLog( state: WorkflowState,