セッション情報を残す、worktreeをプロジェクト管理外に移動

This commit is contained in:
nrslib 2026-01-28 18:23:35 +09:00
parent df22e4c33b
commit 57fd01819f
8 changed files with 322 additions and 35 deletions

View File

@ -19,16 +19,17 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl
## CLI Slash Commands ## CLI Slash Commands
| Command | Description | | Command | Alias | Description |
|---------|-------------| |---------|-------|-------------|
| `takt /run-tasks` | Execute all pending tasks from `.takt/tasks/` once | | `takt /run-tasks` | `/run` | Execute all pending tasks from `.takt/tasks/` once |
| `takt /watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) | | `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
| `takt /add-task` | Add a new task interactively (YAML format) | | `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) |
| `takt /switch` | Switch workflow interactively | | `takt /review-tasks` | `/review` | Review worktree task results (try merge, merge & cleanup, or delete) |
| `takt /clear` | Clear agent conversation sessions (reset state) | | `takt /switch` | | Switch workflow interactively |
| `takt /refresh-builtin` | Update builtin resources from `resources/` to `~/.takt/` | | `takt /clear` | | Clear agent conversation sessions (reset state) |
| `takt /help` | Show help message | | `takt /refresh-builtin` | | Update builtin resources from `resources/` to `~/.takt/` |
| `takt /config` | Display current configuration | | `takt /help` | | Show help message |
| `takt /config` | | Display current configuration |
## Architecture ## Architecture
@ -185,6 +186,18 @@ model: opus # Default model for all steps (unless overridden)
- `improve` - Needs improvement (security concerns, quality issues) - `improve` - Needs improvement (security concerns, quality issues)
- `always` - Unconditional transition - `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 ## Testing Notes
- Vitest for testing framework - Vitest for testing framework

View File

@ -23,7 +23,7 @@ npm install -g takt
## Quick Start ## Quick Start
```bash ```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" takt "Add a login feature"
# Add a task to the queue # Add a task to the queue
@ -35,24 +35,28 @@ takt /run-tasks
# Watch for tasks and auto-execute # Watch for tasks and auto-execute
takt /watch takt /watch
# Review worktree results (merge or delete)
takt /review-tasks
# Switch workflow # Switch workflow
takt /switch takt /switch
``` ```
## Commands ## Commands
| Command | Description | | Command | Alias | Description |
|---------|-------------| |---------|-------|-------------|
| `takt "task"` | Execute task with current workflow (continues session) | | `takt "task"` | | Execute task with current workflow (continues session) |
| `takt -r "task"` | Execute task, resuming previous session | | `takt -r "task"` | | Execute task, resuming previous session |
| `takt /run-tasks` | Run all pending tasks from `.takt/tasks/` | | `takt /run-tasks` | `/run` | Run all pending tasks from `.takt/tasks/` |
| `takt /watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) | | `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
| `takt /add-task` | Add a new task interactively (YAML format) | | `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) |
| `takt /switch` | Switch workflow interactively | | `takt /review-tasks` | `/review` | Review worktree task results (try merge, merge & cleanup, or delete) |
| `takt /clear` | Clear agent conversation sessions | | `takt /switch` | | Switch workflow interactively |
| `takt /refresh-builtin` | Update builtin agents/workflows to latest version | | `takt /clear` | | Clear agent conversation sessions |
| `takt /config` | Display current configuration | | `takt /refresh-builtin` | | Update builtin agents/workflows to latest version |
| `takt /help` | Show help | | `takt /config` | | Display current configuration |
| `takt /help` | | Show help |
## Workflows ## Workflows
@ -183,7 +187,10 @@ Available Codex models:
├── completed/ # Completed tasks with reports ├── completed/ # Completed tasks with reports
├── worktrees/ # Git worktrees for isolated task execution ├── worktrees/ # Git worktrees for isolated task execution
├── reports/ # Execution reports (auto-generated) ├── 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 ### 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. 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 ### Adding Custom Workflows
Create your own workflow by adding YAML files to `~/.takt/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) - `branch: "feat/xxx"` - Use specified branch (auto-generated as `takt/{timestamp}-{slug}` if omitted)
- Omit `worktree` - Run in current working directory (default) - 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` #### Running Tasks with `/run-tasks`
```bash ```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 - Automated workflows where tasks are added by external processes
- Long-running development sessions where tasks are queued over time - 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 ### Workflow Variables
Available variables in `instruction_template`: Available variables in `instruction_template`:
@ -401,7 +440,7 @@ transitions:
next_step: ABORT # End workflow with failure 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). Special next_step values: `COMPLETE` (success), `ABORT` (failure).
**Step options:** **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.) | | `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`) | | `provider` | - | Override provider for this step (`claude` or `codex`) |
| `model` | - | Override model for this step | | `model` | - | Override model for this step |
| `permission_mode` | `default` | Permission mode: `default`, `acceptEdits`, or `bypassPermissions` |
## API Usage ## API Usage

View File

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

View File

@ -21,6 +21,7 @@ import {
addToSessionLog, addToSessionLog,
finalizeSessionLog, finalizeSessionLog,
saveSessionLog, saveSessionLog,
updateLatestPointer,
} from '../utils/session.js'; } from '../utils/session.js';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';
import { notifySuccess, notifyError } from '../utils/notification.js'; import { notifySuccess, notifyError } from '../utils/notification.js';
@ -84,6 +85,10 @@ export async function executeWorkflow(
const workflowSessionId = generateSessionId(); const workflowSessionId = generateSessionId();
const sessionLog = createSessionLog(task, projectCwd, workflowConfig.name); 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 // Track current display for streaming
const displayRef: { current: StreamDisplay | null } = { current: null }; const displayRef: { current: StreamDisplay | null } = { current: null };
@ -191,6 +196,10 @@ export async function executeWorkflow(
status('Session', response.sessionId); status('Session', response.sessionId);
} }
addToSessionLog(sessionLog, step.name, response); addToSessionLog(sessionLog, step.name, response);
// Incremental save after each step
saveSessionLog(sessionLog, workflowSessionId, projectCwd);
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
}); });
engine.on('workflow:complete', (state) => { engine.on('workflow:complete', (state) => {
@ -198,6 +207,7 @@ export async function executeWorkflow(
finalizeSessionLog(sessionLog, 'completed'); finalizeSessionLog(sessionLog, 'completed');
// Save log to project root so user can find it easily // Save log to project root so user can find it easily
const logPath = saveSessionLog(sessionLog, workflowSessionId, projectCwd); const logPath = saveSessionLog(sessionLog, workflowSessionId, projectCwd);
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
const elapsed = sessionLog.endTime const elapsed = sessionLog.endTime
? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime)
@ -219,6 +229,7 @@ export async function executeWorkflow(
finalizeSessionLog(sessionLog, 'aborted'); finalizeSessionLog(sessionLog, 'aborted');
// Save log to project root so user can find it easily // Save log to project root so user can find it easily
const logPath = saveSessionLog(sessionLog, workflowSessionId, projectCwd); const logPath = saveSessionLog(sessionLog, workflowSessionId, projectCwd);
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
const elapsed = sessionLog.endTime const elapsed = sessionLog.endTime
? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime)

View File

@ -86,6 +86,7 @@ export {
// Re-export session storage functions for backward compatibility // Re-export session storage functions for backward compatibility
export { export {
writeFileAtomic,
getInputHistoryPath, getInputHistoryPath,
MAX_INPUT_HISTORY, MAX_INPUT_HISTORY,
loadInputHistory, loadInputHistory,

View File

@ -13,7 +13,7 @@ import { getProjectConfigDir, ensureDir } from './paths.js';
* Write file atomically using temp file + rename. * Write file atomically using temp file + rename.
* This prevents corruption when multiple processes write simultaneously. * 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`; const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try { try {
writeFileSync(tempPath, content, 'utf-8'); writeFileSync(tempPath, content, 'utf-8');

View File

@ -48,18 +48,16 @@ function resolveWorktreePath(projectDir: string, options: WorktreeOptions): stri
? options.worktree ? options.worktree
: path.resolve(projectDir, options.worktree); : path.resolve(projectDir, options.worktree);
if (!isPathSafe(projectDir, resolved)) {
throw new Error(`Worktree path escapes project directory: ${options.worktree}`);
}
return resolved; 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 timestamp = generateTimestamp();
const slug = slugify(options.taskSlug); const slug = slugify(options.taskSlug);
const dirName = slug ? `${timestamp}-${slug}` : timestamp; const dirName = slug ? `${timestamp}-${slug}` : timestamp;
return path.join(projectDir, '.takt', 'worktrees', dirName); return path.join(path.dirname(projectDir), dirName);
} }
/** /**

View File

@ -2,10 +2,10 @@
* Session management utilities * Session management utilities
*/ */
import { writeFileSync, existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync, copyFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { AgentResponse, WorkflowState } from '../models/types.js'; 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 */ /** Session log entry */
export interface SessionLog { export interface SessionLog {
@ -112,7 +112,7 @@ export function saveSessionLog(
const filename = `${sessionId}.json`; const filename = `${sessionId}.json`;
const filepath = join(logsDir, filename); const filepath = join(logsDir, filename);
writeFileSync(filepath, JSON.stringify(log, null, 2), 'utf-8'); writeFileAtomic(filepath, JSON.stringify(log, null, 2));
return filepath; return filepath;
} }
@ -144,6 +144,56 @@ export function loadProjectContext(projectDir: string): string {
return contextParts.join('\n\n---\n\n'); 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 */ /** Convert workflow state to session log */
export function workflowStateToSessionLog( export function workflowStateToSessionLog(
state: WorkflowState, state: WorkflowState,