diff --git a/CHANGELOG.md b/CHANGELOG.md index cf342cd..97eda44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,39 +9,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - Hybrid Codex ピース: 全主要ピース(default, minimal, expert, expert-cqrs, passthrough, review-fix-minimal, coding)の Codex バリアントを追加 - - coder エージェントを Codex プロバイダーで実行し、レビュアーは Claude を使うハイブリッド構成 + - coder エージェントを Codex プロバイダーで実行するハイブリッド構成 - en/ja 両対応 -- `passthrough` ピース: タスクをそのまま coder に渡す最小構成ピース(レビューなし) -- `coding` ピースを plan ベースに刷新: architect-planner による設計→実装→並列レビュー→修正の構成に変更 +- `passthrough` ピース: タスクをそのまま coder に渡す最小構成ピース - `takt export-cc` コマンド: ビルトインピース・エージェントを Claude Code Skill としてデプロイ - `takt list` に delete アクション追加、non-interactive モード分離 - AI 相談アクション: `takt add` / インタラクティブモードで GitHub Issue 作成・タスクファイル保存が可能に - サイクル検出: ai_review ↔ ai_fix 間の無限ループを検出する `CycleDetector` を追加 (#102) - 修正不要時の裁定ステップ(`ai_no_fix`)を default ピースに追加 -- `architect-planner` エージェント: アーキテクチャ設計と実装計画を統合する新エージェント -- ピースカテゴリに Hybrid Codex サブカテゴリを追加(en/ja) - CI: skipped な TAKT Action ランを週次で自動削除するワークフローを追加 +- ピースカテゴリに Hybrid Codex サブカテゴリを追加(en/ja) ### Changed - カテゴリ設定を簡素化: `default-categories.yaml` を `piece-categories.yaml` に統合し、ユーザーディレクトリへの自動コピー方式に変更 -- ピース選択UIのサブカテゴリナビゲーションを改善(再帰的な階層表示が正しく動作するように) +- ピース選択UIのサブカテゴリナビゲーションを修正(再帰的な階層表示が正しく動作するように) - Claude Code Skill を Agent Team ベースに刷新 -- エージェントプロンプトにボーイスカウトルールと後方互換コード検出ルールを追加 - `console.log` を `info()` に統一(list コマンド) ### Fixed - Hybrid Codex ピースの description に含まれるコロンが YAML パースエラーを起こす問題を修正 - サブカテゴリ選択時に `selectPieceFromCategoryTree` に不正な引数が渡される問題を修正 -- GitHub Actions でテストが git user 未設定により失敗する問題を修正(listTasks, listNonInteractive) ### Internal - `list` コマンドのリファクタリング: `listNonInteractive.ts`, `taskDeleteActions.ts` を分離 - `cycle-detector.ts` を追加、`PieceEngine` にサイクル検出を統合 - ピースカテゴリローダーのリファクタリング(`pieceCategories.ts`, `pieceSelection/index.ts`) -- 不要なワークツリーセッションテストを削除、ワークツリー削除テストを簡素化 - テスト追加: cycle-detector, engine-loop-monitors, piece-selection, listNonInteractive, taskDeleteActions, createIssue, saveTaskFile ## [0.6.0] - 2026-02-05 diff --git a/CLAUDE.md b/CLAUDE.md index 397b72b..6830b2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/__tests__/autoCommit.test.ts b/src/__tests__/autoCommit.test.ts index 4fab0fb..40a5300 100644 --- a/src/__tests__/autoCommit.test.ts +++ b/src/__tests__/autoCommit.test.ts @@ -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'); + }); }); diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index c189a58..ceadfa2 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -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 () => { diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index 83db492..648aba4 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -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); diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 829923a..9cf1442 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -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, diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index bfea300..dbc202c 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -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 */ diff --git a/src/infra/task/git.ts b/src/infra/task/git.ts index add1156..778fa9f 100644 --- a/src/infra/task/git.ts +++ b/src/infra/task/git.ts @@ -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',