Merge branch 'takt/#113/generate-slug-for-task' into develop
This commit is contained in:
commit
af6f59caa7
13
CHANGELOG.md
13
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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user