takt: fix-report-dir-path

This commit is contained in:
nrslib 2026-02-06 12:20:18 +09:00
parent 52c927e6f1
commit c89ac4cc7a
8 changed files with 89 additions and 41 deletions

View File

@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- GitHub Actions でテストが git user 未設定により失敗する問題を修正listTasks, listNonInteractive
- worktree モードでレポートディレクトリがメインリポジトリに設定され、エージェントがメインリポジトリを汚染する問題を修正 (#113)
- `reportDir` の構築元を `projectCwd` から `cwd` に変更し、クローン内に `.takt/reports/` を生成
- `git add -f .takt/reports/` でレポートをコミットに強制追加(`.takt/` は gitignore されているため)
## [0.7.0-alpha.1] - 2026-02-06

View File

@ -404,7 +404,7 @@ Key constraints:
- **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`.
- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root. Reports write to `cwd/.takt/reports/` (clone) to prevent agents from discovering the main repository. Logs and session data write to `projectCwd`.
- **List**: Use `takt list` to list branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone.
## Error Propagation
@ -456,7 +456,7 @@ Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `d
- Report dirs are created at `.takt/reports/{timestamp}-{slug}/`
- Report files specified in `step.report` are written relative to report dir
- Report dir path is available as `{report_dir}` variable in instruction templates
- When `cwd !== projectCwd` (worktree execution), reports still write to `projectCwd/.takt/reports/`
- When `cwd !== projectCwd` (worktree execution), reports write to `cwd/.takt/reports/` (clone dir) to prevent agents from discovering the main repository path
**Session continuity across phases:**
- Agent sessions persist across Phase 1 → Phase 2 → Phase 3 for context continuity
@ -466,8 +466,9 @@ Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `d
**Worktree execution gotchas:**
- `git clone --shared` creates independent `.git` directory (not `git worktree`)
- Clone cwd ≠ project cwd: agents work in clone, but reports/logs write to project
- Clone cwd ≠ project cwd: agents work in clone, reports write to clone, logs write to project
- Session resume is skipped when `cwd !== projectCwd` to avoid cross-directory contamination
- Reports write to `cwd/.takt/reports/` (clone) to prevent agents from discovering the main repository path via instruction
- Clones are ephemeral: created → task runs → auto-commit + push → deleted
- Use `takt list` to manage task branches after clone deletion

View File

@ -152,4 +152,50 @@ describe('autoCommitAndPush', () => {
);
expect((commitCall![1] as string[])[2]).toBe('takt: 認証機能を追加する');
});
it('should force-add .takt/reports/ to include gitignored reports', () => {
mockExecFileSync.mockImplementation((cmd, args) => {
const argsArr = args as string[];
if (argsArr[0] === 'status') {
return 'M src/index.ts\n';
}
if (argsArr[0] === 'rev-parse') {
return 'abc1234\n';
}
return Buffer.from('');
});
autoCommitAndPush('/tmp/clone', 'my-task', '/project');
// Verify git add -f .takt/reports/ was called
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['add', '-f', '.takt/reports/'],
expect.objectContaining({ cwd: '/tmp/clone' })
);
});
it('should continue even if .takt/reports/ does not exist', () => {
let forceAddCalled = false;
mockExecFileSync.mockImplementation((cmd, args) => {
const argsArr = args as string[];
if (argsArr[0] === 'add' && argsArr[1] === '-f') {
forceAddCalled = true;
throw new Error('pathspec .takt/reports/ did not match any files');
}
if (argsArr[0] === 'status') {
return 'M src/index.ts\n';
}
if (argsArr[0] === 'rev-parse') {
return 'abc1234\n';
}
return Buffer.from('');
});
const result = autoCommitAndPush('/tmp/clone', 'my-task', '/project');
expect(forceAddCalled).toBe(true);
expect(result.success).toBe(true);
expect(result.commitHash).toBe('abc1234');
});
});

View File

@ -1,12 +1,12 @@
/**
* Tests for worktree environment: reportDir should use cwd (clone dir), not projectCwd.
*
* Issue #67: In worktree mode, the agent's sandbox blocks writes to projectCwd paths.
* reportDir must be resolved relative to cwd so the agent writes via the symlink.
* Issue #113: In worktree mode, reportDir must be resolved relative to cwd (clone) to
* prevent agents from discovering and editing the main repository via instruction paths.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync, mkdirSync, symlinkSync } from 'node:fs';
import { existsSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
@ -51,15 +51,11 @@ function createWorktreeDirs(): { projectCwd: string; cloneCwd: string } {
const projectCwd = join(base, 'project');
const cloneCwd = join(base, 'clone');
// Project side: real .takt/reports directory
// Project side: real .takt/reports directory (for non-worktree tests)
mkdirSync(join(projectCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true });
// Clone side: .takt directory with symlink to project's reports
mkdirSync(join(cloneCwd, '.takt'), { recursive: true });
symlinkSync(
join(projectCwd, '.takt', 'reports'),
join(cloneCwd, '.takt', 'reports'),
);
// Clone side: .takt/reports directory (reports now written directly to clone)
mkdirSync(join(cloneCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true });
return { projectCwd, cloneCwd };
}
@ -101,7 +97,7 @@ describe('PieceEngine: worktree reportDir resolution', () => {
}
});
it('should pass projectCwd-based reportDir to phase runner context in worktree mode', async () => {
it('should pass cloneCwd-based reportDir to phase runner context in worktree mode', async () => {
// Given: worktree environment where cwd !== projectCwd
const config = buildSimpleConfig();
const engine = new PieceEngine(config, cloneCwd, 'test task', {
@ -118,20 +114,21 @@ describe('PieceEngine: worktree reportDir resolution', () => {
// When: run the piece
await engine.run();
// Then: runReportPhase was called with context containing projectCwd-based reportDir
// Then: runReportPhase was called with context containing cloneCwd-based reportDir
const reportPhaseMock = vi.mocked(runReportPhase);
expect(reportPhaseMock).toHaveBeenCalled();
const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string };
// reportDir should be resolved from projectCwd, not cloneCwd
const expectedPath = join(projectCwd, '.takt/reports/test-report-dir');
const unexpectedPath = join(cloneCwd, '.takt/reports/test-report-dir');
// reportDir should be resolved from cloneCwd (cwd), not projectCwd
// This prevents agents from discovering the main repository path via instruction
const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir');
const unexpectedPath = join(projectCwd, '.takt/reports/test-report-dir');
expect(phaseCtx.reportDir).toBe(expectedPath);
expect(phaseCtx.reportDir).not.toBe(unexpectedPath);
});
it('should pass projectCwd-based reportDir to buildInstruction (used by {report_dir} placeholder)', async () => {
it('should pass cloneCwd-based reportDir to buildInstruction (used by {report_dir} placeholder)', async () => {
// Given: worktree environment with a movement that uses {report_dir} in template
const config: PieceConfig = {
name: 'worktree-test',
@ -163,15 +160,16 @@ describe('PieceEngine: worktree reportDir resolution', () => {
// When: run the piece
await engine.run();
// Then: the instruction should contain projectCwd-based reportDir
// Then: the instruction should contain cloneCwd-based reportDir
// This prevents agents from discovering the main repository path
const runAgentMock = vi.mocked(runAgent);
expect(runAgentMock).toHaveBeenCalled();
const instruction = runAgentMock.mock.calls[0][1] as string;
const expectedPath = join(projectCwd, '.takt/reports/test-report-dir');
const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir');
expect(instruction).toContain(expectedPath);
// In worktree mode, cloneCwd path should NOT appear
expect(instruction).not.toContain(join(cloneCwd, '.takt/reports/test-report-dir'));
// In worktree mode, projectCwd path should NOT appear in instruction
expect(instruction).not.toContain(join(projectCwd, '.takt/reports/test-report-dir'));
});
it('should use same path in non-worktree mode (cwd === projectCwd)', async () => {

View File

@ -68,7 +68,7 @@ export class MovementExecutor {
projectCwd: this.deps.getProjectCwd(),
userInputs: state.userInputs,
previousOutput: getPreviousOutput(state),
reportDir: join(this.deps.getProjectCwd(), this.deps.getReportDir()),
reportDir: join(this.deps.getCwd(), this.deps.getReportDir()),
language: this.deps.getLanguage(),
interactive: this.deps.getInteractive(),
pieceMovements: pieceMovements,
@ -146,7 +146,7 @@ export class MovementExecutor {
/** Collect movement:report events for each report file that exists */
emitMovementReports(step: PieceMovement): void {
if (!step.report) return;
const baseDir = join(this.deps.getProjectCwd(), this.deps.getReportDir());
const baseDir = join(this.deps.getCwd(), this.deps.getReportDir());
if (typeof step.report === 'string') {
this.checkReportFile(step, baseDir, step.report);

View File

@ -96,7 +96,7 @@ export class OptionsBuilder {
): PhaseRunnerContext {
return {
cwd: this.getCwd(),
reportDir: join(this.getProjectCwd(), this.getReportDir()),
reportDir: join(this.getCwd(), this.getReportDir()),
language: this.getLanguage(),
interactive: this.engineOptions.interactive,
lastResponse,

View File

@ -7,7 +7,7 @@
*/
import { EventEmitter } from 'node:events';
import { mkdirSync, existsSync, symlinkSync } from 'node:fs';
import { mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import type {
PieceConfig,
@ -145,24 +145,12 @@ export class PieceEngine extends EventEmitter {
});
}
/** Ensure report directory exists (always in project root, not clone) */
/** Ensure report directory exists (in cwd, which is clone dir in worktree mode) */
private ensureReportDirExists(): void {
const reportDirPath = join(this.projectCwd, this.reportDir);
const reportDirPath = join(this.cwd, this.reportDir);
if (!existsSync(reportDirPath)) {
mkdirSync(reportDirPath, { recursive: true });
}
// Worktree mode: create symlink so agents can access reports via relative path
if (this.cwd !== this.projectCwd) {
const cwdReportsDir = join(this.cwd, '.takt', 'reports');
if (!existsSync(cwdReportsDir)) {
mkdirSync(join(this.cwd, '.takt'), { recursive: true });
symlinkSync(
join(this.projectCwd, '.takt', 'reports'),
cwdReportsDir,
);
}
}
}
/** Validate piece configuration at construction time */

View File

@ -7,10 +7,22 @@ import { execFileSync } from 'node:child_process';
/**
* Stage all changes and create a commit.
* Returns the short commit hash if changes were committed, undefined if no changes.
*
* Note: .takt/reports/ is force-added because .takt/ is gitignored.
* When using worktree mode, reports are generated inside the clone's .takt/reports/
* and must be included in the commit.
*/
export function stageAndCommit(cwd: string, message: string): string | undefined {
execFileSync('git', ['add', '-A'], { cwd, stdio: 'pipe' });
// Force-add .takt/reports/ even though .takt/ is gitignored.
// This ensures worktree-generated reports are included in the commit.
try {
execFileSync('git', ['add', '-f', '.takt/reports/'], { cwd, stdio: 'pipe' });
} catch {
// Ignore errors if .takt/reports/ doesn't exist
}
const statusOutput = execFileSync('git', ['status', '--porcelain'], {
cwd,
stdio: 'pipe',