セッション情報を残す、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
| 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

View File

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

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

View File

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

View File

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

View File

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

View File

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