From 3e54c80ba253ee7ea40931bb448c1fff55df150b Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:15:43 +0900 Subject: [PATCH] =?UTF-8?q?resolved=20ai=5Freview=20=E2=86=94=20ai=5Ffix?= =?UTF-8?q?=20=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E5=81=A5=E5=85=A8?= =?UTF-8?q?=E6=80=A7=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=81=A8=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E4=B8=8D=E8=A6=81=E6=99=82=E3=81=AE=E8=A3=81=E5=AE=9A?= =?UTF-8?q?=E3=82=B9=E3=83=86=E3=83=83=E3=83=97=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20#102?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/specs/eject.e2e.ts | 190 +++++++++++++ resources/global/en/pieces/default.yaml | 24 ++ resources/global/ja/pieces/default.yaml | 24 ++ src/__tests__/cycle-detector.test.ts | 218 ++++++++++++++ src/__tests__/engine-loop-monitors.test.ts | 315 +++++++++++++++++++++ src/app/cli/commands.ts | 7 +- src/core/models/index.ts | 3 + src/core/models/piece-types.ts | 32 +++ src/core/models/schemas.ts | 29 ++ src/core/models/types.ts | 3 + src/core/piece/engine/PieceEngine.ts | 157 +++++++++- src/core/piece/engine/cycle-detector.ts | 131 +++++++++ src/core/piece/engine/index.ts | 2 + src/core/piece/index.ts | 3 + src/core/piece/types.ts | 3 +- src/features/config/ejectBuiltin.ts | 54 ++-- src/infra/config/loaders/pieceParser.ts | 44 ++- src/infra/config/paths.ts | 10 + 18 files changed, 1223 insertions(+), 26 deletions(-) create mode 100644 e2e/specs/eject.e2e.ts create mode 100644 src/__tests__/cycle-detector.test.ts create mode 100644 src/__tests__/engine-loop-monitors.test.ts create mode 100644 src/core/piece/engine/cycle-detector.ts diff --git a/e2e/specs/eject.e2e.ts b/e2e/specs/eject.e2e.ts new file mode 100644 index 0000000..fbf5c85 --- /dev/null +++ b/e2e/specs/eject.e2e.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +/** + * Create a minimal local git repository for eject tests. + * No GitHub access needed — just a local git init. + */ +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-eject-e2e-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + // Create initial commit so branch exists + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { + rmSync(repoPath, { recursive: true, force: true }); + } catch { + // best-effort + } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Eject builtin pieces (takt eject)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { + repo.cleanup(); + } catch { + // best-effort + } + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); + + it('should list available builtin pieces when no name given', () => { + const result = runTakt({ + args: ['eject'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('default'); + expect(result.stdout).toContain('Available builtin pieces'); + }); + + it('should eject piece to project .takt/ by default', () => { + const result = runTakt({ + args: ['eject', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + + // Piece YAML should be in project .takt/pieces/ + const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); + expect(existsSync(piecePath)).toBe(true); + + // Agents should be in project .takt/agents/ + const agentsDir = join(repo.path, '.takt', 'agents', 'default'); + expect(existsSync(agentsDir)).toBe(true); + expect(existsSync(join(agentsDir, 'coder.md'))).toBe(true); + expect(existsSync(join(agentsDir, 'planner.md'))).toBe(true); + }); + + it('should preserve relative agent paths in ejected piece (no rewriting)', () => { + runTakt({ + args: ['eject', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); + const content = readFileSync(piecePath, 'utf-8'); + + // Relative paths should be preserved as ../agents/ + expect(content).toContain('agent: ../agents/default/'); + // Should NOT contain rewritten absolute paths + expect(content).not.toContain('agent: ~/.takt/agents/'); + }); + + it('should eject piece to global ~/.takt/ with --global flag', () => { + const result = runTakt({ + args: ['eject', 'default', '--global'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + + // Piece YAML should be in global dir (TAKT_CONFIG_DIR from isolated env) + const piecePath = join(isolatedEnv.taktDir, 'pieces', 'default.yaml'); + expect(existsSync(piecePath)).toBe(true); + + // Agents should be in global agents dir + const agentsDir = join(isolatedEnv.taktDir, 'agents', 'default'); + expect(existsSync(agentsDir)).toBe(true); + expect(existsSync(join(agentsDir, 'coder.md'))).toBe(true); + + // Should NOT be in project dir + const projectPiecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); + expect(existsSync(projectPiecePath)).toBe(false); + }); + + it('should warn and skip when piece already exists', () => { + // First eject + runTakt({ + args: ['eject', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Second eject — should skip + const result = runTakt({ + args: ['eject', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('already exists'); + }); + + it('should report error for non-existent builtin', () => { + const result = runTakt({ + args: ['eject', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('not found'); + }); + + it('should correctly eject agents for pieces with unique agents', () => { + const result = runTakt({ + args: ['eject', 'magi'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + + // MAGI piece should have its own agents + const magiDir = join(repo.path, '.takt', 'agents', 'magi'); + expect(existsSync(join(magiDir, 'melchior.md'))).toBe(true); + expect(existsSync(join(magiDir, 'balthasar.md'))).toBe(true); + expect(existsSync(join(magiDir, 'casper.md'))).toBe(true); + + // Should NOT have default agents mixed in + expect(existsSync(join(repo.path, '.takt', 'agents', 'default'))).toBe(false); + }); + + it('should preserve relative paths for global eject too', () => { + runTakt({ + args: ['eject', 'magi', '--global'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + const piecePath = join(isolatedEnv.taktDir, 'pieces', 'magi.yaml'); + const content = readFileSync(piecePath, 'utf-8'); + + expect(content).toContain('agent: ../agents/magi/'); + expect(content).not.toContain('agent: ~/.takt/agents/'); + }); +}); diff --git a/resources/global/en/pieces/default.yaml b/resources/global/en/pieces/default.yaml index 39d4a72..5774abc 100644 --- a/resources/global/en/pieces/default.yaml +++ b/resources/global/en/pieces/default.yaml @@ -26,6 +26,30 @@ max_iterations: 30 initial_movement: plan +loop_monitors: + - cycle: [ai_review, ai_fix] + threshold: 3 + judge: + agent: ../agents/default/supervisor.md + instruction_template: | + The ai_review ↔ ai_fix loop has repeated {cycle_count} times. + + Review the reports from each cycle and determine whether this loop + is healthy (making progress) or unproductive (repeating the same issues). + + **Reports to reference:** + - AI Review results: {report:04-ai-review.md} + + **Judgment criteria:** + - Are new issues being found/fixed in each cycle? + - Are the same findings being repeated? + - Are fixes actually being applied? + rules: + - condition: Healthy (making progress) + next: ai_review + - condition: Unproductive (no improvement) + next: reviewers + movements: - name: plan edit: false diff --git a/resources/global/ja/pieces/default.yaml b/resources/global/ja/pieces/default.yaml index 154cf48..b384a79 100644 --- a/resources/global/ja/pieces/default.yaml +++ b/resources/global/ja/pieces/default.yaml @@ -17,6 +17,30 @@ max_iterations: 30 initial_movement: plan +loop_monitors: + - cycle: [ai_review, ai_fix] + threshold: 3 + judge: + agent: ../agents/default/supervisor.md + instruction_template: | + ai_review と ai_fix のループが {cycle_count} 回繰り返されました。 + + 各サイクルのレポートを確認し、このループが健全(進捗がある)か、 + 非生産的(同じ問題を繰り返している)かを判断してください。 + + **参照するレポート:** + - AIレビュー結果: {report:04-ai-review.md} + + **判断基準:** + - 各サイクルで新しい問題が発見・修正されているか + - 同じ指摘が繰り返されていないか + - 修正が実際に反映されているか + rules: + - condition: 健全(進捗あり) + next: ai_review + - condition: 非生産的(改善なし) + next: reviewers + movements: - name: plan edit: false diff --git a/src/__tests__/cycle-detector.test.ts b/src/__tests__/cycle-detector.test.ts new file mode 100644 index 0000000..059ff94 --- /dev/null +++ b/src/__tests__/cycle-detector.test.ts @@ -0,0 +1,218 @@ +/** + * CycleDetector unit tests + * + * Tests cycle detection logic for loop_monitors. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { CycleDetector } from '../core/piece/engine/cycle-detector.js'; +import type { LoopMonitorConfig } from '../core/models/index.js'; + +function makeMonitor( + cycle: string[], + threshold: number, + rules = [ + { condition: 'healthy', next: 'ai_review' }, + { condition: 'unproductive', next: 'reviewers' }, + ], +): LoopMonitorConfig { + return { + cycle, + threshold, + judge: { rules }, + }; +} + +describe('CycleDetector', () => { + describe('2-step cycle detection', () => { + let detector: CycleDetector; + const monitor = makeMonitor(['ai_review', 'ai_fix'], 3); + + beforeEach(() => { + detector = new CycleDetector([monitor]); + }); + + it('should not trigger before threshold is reached', () => { + // 2 complete cycles (4 movements) + expect(detector.recordAndCheck('ai_review').triggered).toBe(false); + expect(detector.recordAndCheck('ai_fix').triggered).toBe(false); + expect(detector.recordAndCheck('ai_review').triggered).toBe(false); + expect(detector.recordAndCheck('ai_fix').triggered).toBe(false); + }); + + it('should trigger when threshold (3 cycles) is reached', () => { + // 3 complete cycles (6 movements) + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + detector.recordAndCheck('ai_review'); + const result = detector.recordAndCheck('ai_fix'); + + expect(result.triggered).toBe(true); + expect(result.cycleCount).toBe(3); + expect(result.monitor).toBe(monitor); + }); + + it('should not trigger when cycle is interrupted by another movement', () => { + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + // Interrupt the cycle with a different movement + detector.recordAndCheck('plan'); + detector.recordAndCheck('ai_review'); + const result = detector.recordAndCheck('ai_fix'); + + // Only 1 complete cycle since the interruption + expect(result.triggered).toBe(false); + }); + + it('should not trigger when only the cycle end matches', () => { + // History doesn't form a valid cycle pattern + detector.recordAndCheck('plan'); + detector.recordAndCheck('implement'); + detector.recordAndCheck('ai_fix'); + + expect(detector.recordAndCheck('ai_fix').triggered).toBe(false); + }); + + it('should reset correctly and not trigger after reset', () => { + // Build up 2 cycles + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + + // Reset + detector.reset(); + + // Now only 1 cycle after reset + detector.recordAndCheck('ai_review'); + const result = detector.recordAndCheck('ai_fix'); + expect(result.triggered).toBe(false); + expect(result.cycleCount).toBe(0); // Less than threshold + }); + + it('should trigger exactly at threshold, not before', () => { + // Threshold is 3 + // Cycle 1 + expect(detector.recordAndCheck('ai_review').triggered).toBe(false); + expect(detector.recordAndCheck('ai_fix').triggered).toBe(false); + + // Cycle 2 + expect(detector.recordAndCheck('ai_review').triggered).toBe(false); + expect(detector.recordAndCheck('ai_fix').triggered).toBe(false); + + // Cycle 3 (threshold reached) + expect(detector.recordAndCheck('ai_review').triggered).toBe(false); + expect(detector.recordAndCheck('ai_fix').triggered).toBe(true); + }); + }); + + describe('3-step cycle detection', () => { + it('should detect 3-step cycles', () => { + const monitor = makeMonitor(['A', 'B', 'C'], 2); + const detector = new CycleDetector([monitor]); + + // Cycle 1 + detector.recordAndCheck('A'); + detector.recordAndCheck('B'); + detector.recordAndCheck('C'); + + // Cycle 2 + detector.recordAndCheck('A'); + detector.recordAndCheck('B'); + const result = detector.recordAndCheck('C'); + + expect(result.triggered).toBe(true); + expect(result.cycleCount).toBe(2); + }); + }); + + describe('multiple monitors', () => { + it('should check all monitors and trigger the first matching one', () => { + const monitor1 = makeMonitor(['A', 'B'], 3); + const monitor2 = makeMonitor(['X', 'Y'], 2); + const detector = new CycleDetector([monitor1, monitor2]); + + // 2 cycles of X → Y (threshold for monitor2 is 2) + detector.recordAndCheck('X'); + detector.recordAndCheck('Y'); + detector.recordAndCheck('X'); + const result = detector.recordAndCheck('Y'); + + expect(result.triggered).toBe(true); + expect(result.cycleCount).toBe(2); + expect(result.monitor).toBe(monitor2); + }); + }); + + describe('no monitors', () => { + it('should never trigger with empty monitors', () => { + const detector = new CycleDetector([]); + detector.recordAndCheck('ai_review'); + detector.recordAndCheck('ai_fix'); + detector.recordAndCheck('ai_review'); + const result = detector.recordAndCheck('ai_fix'); + + expect(result.triggered).toBe(false); + }); + }); + + describe('getHistory', () => { + it('should return the full movement history', () => { + const detector = new CycleDetector([]); + detector.recordAndCheck('plan'); + detector.recordAndCheck('implement'); + detector.recordAndCheck('ai_review'); + + expect(detector.getHistory()).toEqual(['plan', 'implement', 'ai_review']); + }); + + it('should return empty after reset', () => { + const detector = new CycleDetector([]); + detector.recordAndCheck('plan'); + detector.reset(); + + expect(detector.getHistory()).toEqual([]); + }); + }); + + describe('threshold of 1', () => { + it('should trigger after first complete cycle', () => { + const monitor = makeMonitor(['A', 'B'], 1); + const detector = new CycleDetector([monitor]); + + detector.recordAndCheck('A'); + const result = detector.recordAndCheck('B'); + + expect(result.triggered).toBe(true); + expect(result.cycleCount).toBe(1); + }); + }); + + describe('beyond threshold', () => { + it('should also trigger at threshold + N (consecutive cycles)', () => { + const monitor = makeMonitor(['A', 'B'], 2); + const detector = new CycleDetector([monitor]); + + // 2 cycles → threshold met + detector.recordAndCheck('A'); + detector.recordAndCheck('B'); + detector.recordAndCheck('A'); + const result1 = detector.recordAndCheck('B'); + expect(result1.triggered).toBe(true); + expect(result1.cycleCount).toBe(2); + + // After reset + 3 more cycles → triggers at 2 again + detector.reset(); + detector.recordAndCheck('A'); + detector.recordAndCheck('B'); + detector.recordAndCheck('A'); + const result2 = detector.recordAndCheck('B'); + expect(result2.triggered).toBe(true); + expect(result2.cycleCount).toBe(2); + }); + }); +}); diff --git a/src/__tests__/engine-loop-monitors.test.ts b/src/__tests__/engine-loop-monitors.test.ts new file mode 100644 index 0000000..4a505b8 --- /dev/null +++ b/src/__tests__/engine-loop-monitors.test.ts @@ -0,0 +1,315 @@ +/** + * PieceEngine integration tests: loop_monitors (cycle detection + judge) + * + * Covers: + * - Loop monitor triggers judge when cycle threshold reached + * - Judge decision overrides normal next movement + * - Cycle detector resets after judge intervention + * - No trigger when threshold not reached + * - Validation of loop_monitors config + * - movement:cycle_detected event emission + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync } from 'node:fs'; +import type { PieceConfig, PieceMovement, LoopMonitorConfig } from '../core/models/index.js'; + +// --- Mock setup (must be before imports that use these modules) --- + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue(''), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +// --- Imports (after mocks) --- + +import { PieceEngine } from '../core/piece/index.js'; +import { runAgent } from '../agents/runner.js'; +import { + makeResponse, + makeMovement, + makeRule, + mockRunAgentSequence, + mockDetectMatchedRuleSequence, + createTestTmpDir, + applyDefaultMocks, + cleanupPieceEngine, +} from './engine-test-helpers.js'; + +/** + * Build a piece config with ai_review ↔ ai_fix loop and loop_monitors. + */ +function buildConfigWithLoopMonitor( + threshold = 3, + monitorOverrides: Partial = {}, +): PieceConfig { + return { + name: 'test-loop-monitor', + description: 'Test piece with loop monitors', + maxIterations: 30, + initialMovement: 'implement', + loopMonitors: [ + { + cycle: ['ai_review', 'ai_fix'], + threshold, + judge: { + rules: [ + { condition: 'Healthy', next: 'ai_review' }, + { condition: 'Unproductive', next: 'reviewers' }, + ], + }, + ...monitorOverrides, + }, + ], + movements: [ + makeMovement('implement', { + rules: [makeRule('done', 'ai_review')], + }), + makeMovement('ai_review', { + rules: [ + makeRule('No issues', 'reviewers'), + makeRule('Issues found', 'ai_fix'), + ], + }), + makeMovement('ai_fix', { + rules: [ + makeRule('Fixed', 'ai_review'), + makeRule('No fix needed', 'reviewers'), + ], + }), + makeMovement('reviewers', { + rules: [makeRule('All approved', 'COMPLETE')], + }), + ], + }; +} + +describe('PieceEngine Integration: Loop Monitors', () => { + let tmpDir: string; + let engine: PieceEngine | null = null; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = null; + } + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + // ===================================================== + // 1. Cycle triggers judge → unproductive → skip to reviewers + // ===================================================== + describe('Judge triggered on cycle threshold', () => { + it('should run judge and redirect to reviewers when cycle is unproductive', async () => { + const config = buildConfigWithLoopMonitor(2); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + // implement + makeResponse({ agent: 'implement', content: 'Implementation done' }), + // ai_review → issues found + makeResponse({ agent: 'ai_review', content: 'Issues found: X' }), + // ai_fix → fixed → ai_review + makeResponse({ agent: 'ai_fix', content: 'Fixed X' }), + // ai_review → issues found again + makeResponse({ agent: 'ai_review', content: 'Issues found: Y' }), + // ai_fix → fixed → cycle threshold reached (2 cycles complete) + makeResponse({ agent: 'ai_fix', content: 'Fixed Y' }), + // Judge runs (synthetic movement) + makeResponse({ agent: 'supervisor', content: 'Unproductive loop detected' }), + // reviewers (after judge redirects here) + makeResponse({ agent: 'reviewers', content: 'All approved' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // implement → ai_review + { index: 1, method: 'phase1_tag' }, // ai_review → ai_fix (issues found) + { index: 0, method: 'phase1_tag' }, // ai_fix → ai_review (fixed) + { index: 1, method: 'phase1_tag' }, // ai_review → ai_fix (issues found again) + { index: 0, method: 'phase1_tag' }, // ai_fix → ai_review (fixed) — but cycle detected! + // Judge rule match: Unproductive (index 1) → reviewers + { index: 1, method: 'ai_judge_fallback' }, + // reviewers → COMPLETE + { index: 0, method: 'phase1_tag' }, + ]); + + const cycleDetectedFn = vi.fn(); + engine.on('movement:cycle_detected', cycleDetectedFn); + + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(cycleDetectedFn).toHaveBeenCalledOnce(); + expect(cycleDetectedFn.mock.calls[0][1]).toBe(2); // cycleCount + // 7 iterations: implement + ai_review + ai_fix + ai_review + ai_fix + judge + reviewers + expect(state.iteration).toBe(7); + }); + + it('should run judge and continue loop when cycle is healthy', async () => { + const config = buildConfigWithLoopMonitor(2); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + // implement + makeResponse({ agent: 'implement', content: 'Implementation done' }), + // Cycle 1: ai_review → ai_fix + makeResponse({ agent: 'ai_review', content: 'Issues found: A' }), + makeResponse({ agent: 'ai_fix', content: 'Fixed A' }), + // Cycle 2: ai_review → ai_fix (threshold reached) + makeResponse({ agent: 'ai_review', content: 'Issues found: B' }), + makeResponse({ agent: 'ai_fix', content: 'Fixed B' }), + // Judge says healthy → continue to ai_review + makeResponse({ agent: 'supervisor', content: 'Loop is healthy, making progress' }), + // ai_review → no issues + makeResponse({ agent: 'ai_review', content: 'No issues remaining' }), + // reviewers → COMPLETE + makeResponse({ agent: 'reviewers', content: 'All approved' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // implement → ai_review + { index: 1, method: 'phase1_tag' }, // ai_review → ai_fix + { index: 0, method: 'phase1_tag' }, // ai_fix → ai_review + { index: 1, method: 'phase1_tag' }, // ai_review → ai_fix + { index: 0, method: 'phase1_tag' }, // ai_fix → ai_review — cycle detected! + // Judge: Healthy (index 0) → ai_review + { index: 0, method: 'ai_judge_fallback' }, + // ai_review → reviewers (no issues) + { index: 0, method: 'phase1_tag' }, + // reviewers → COMPLETE + { index: 0, method: 'phase1_tag' }, + ]); + + const state = await engine.run(); + + expect(state.status).toBe('completed'); + // 8 iterations: impl + ai_review*3 + ai_fix*2 + judge + reviewers + expect(state.iteration).toBe(8); + }); + }); + + // ===================================================== + // 2. No trigger when threshold not reached + // ===================================================== + describe('No trigger before threshold', () => { + it('should not trigger judge when fewer cycles than threshold', async () => { + const config = buildConfigWithLoopMonitor(3); // threshold = 3, only do 1 cycle + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ agent: 'implement', content: 'Implementation done' }), + makeResponse({ agent: 'ai_review', content: 'Issues found' }), + makeResponse({ agent: 'ai_fix', content: 'Fixed' }), + makeResponse({ agent: 'ai_review', content: 'No issues' }), + makeResponse({ agent: 'reviewers', content: 'All approved' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // implement → ai_review + { index: 1, method: 'phase1_tag' }, // ai_review → ai_fix + { index: 0, method: 'phase1_tag' }, // ai_fix → ai_review + { index: 0, method: 'phase1_tag' }, // ai_review → reviewers (no issues) + { index: 0, method: 'phase1_tag' }, // reviewers → COMPLETE + ]); + + const cycleDetectedFn = vi.fn(); + engine.on('movement:cycle_detected', cycleDetectedFn); + + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(cycleDetectedFn).not.toHaveBeenCalled(); + // No judge was called, so only 5 iterations + expect(state.iteration).toBe(5); + expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(5); + }); + }); + + // ===================================================== + // 3. Validation errors + // ===================================================== + describe('Config validation', () => { + it('should throw when loop_monitor cycle references nonexistent movement', () => { + const config = buildConfigWithLoopMonitor(3); + config.loopMonitors = [ + { + cycle: ['ai_review', 'nonexistent'], + threshold: 3, + judge: { + rules: [{ condition: 'test', next: 'ai_review' }], + }, + }, + ]; + + expect(() => { + new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + }).toThrow('nonexistent'); + }); + + it('should throw when loop_monitor judge rule references nonexistent movement', () => { + const config = buildConfigWithLoopMonitor(3); + config.loopMonitors = [ + { + cycle: ['ai_review', 'ai_fix'], + threshold: 3, + judge: { + rules: [{ condition: 'test', next: 'nonexistent_target' }], + }, + }, + ]; + + expect(() => { + new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + }).toThrow('nonexistent_target'); + }); + }); + + // ===================================================== + // 4. No loop monitors configured + // ===================================================== + describe('No loop monitors', () => { + it('should work normally without loop_monitors configured', async () => { + const config = buildConfigWithLoopMonitor(3); + config.loopMonitors = undefined; + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ agent: 'implement', content: 'Done' }), + makeResponse({ agent: 'ai_review', content: 'No issues' }), + makeResponse({ agent: 'reviewers', content: 'All approved' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + ]); + + const state = await engine.run(); + expect(state.status).toBe('completed'); + expect(state.iteration).toBe(3); + }); + }); +}); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 5ea8658..dabb185 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -75,10 +75,11 @@ program program .command('eject') - .description('Copy builtin piece/agents to ~/.takt/ for customization') + .description('Copy builtin piece/agents for customization (default: project .takt/)') .argument('[name]', 'Specific builtin to eject') - .action(async (name?: string) => { - await ejectBuiltin(name); + .option('--global', 'Eject to ~/.takt/ instead of project .takt/') + .action(async (name: string | undefined, opts: { global?: boolean }) => { + await ejectBuiltin(name, { global: opts.global, projectDir: resolvedCwd }); }); program diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 263d888..d821fec 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -11,6 +11,9 @@ export type { PieceRule, PieceMovement, LoopDetectionConfig, + LoopMonitorConfig, + LoopMonitorJudge, + LoopMonitorRule, PieceConfig, PieceState, CustomAgentConfig, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index c62b575..e9cb3e2 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -91,6 +91,36 @@ export interface LoopDetectionConfig { action?: 'abort' | 'warn' | 'ignore'; } +/** Rule for loop monitor judge decision */ +export interface LoopMonitorRule { + /** Human-readable condition text */ + condition: string; + /** Next movement name to transition to */ + next: string; +} + +/** Judge configuration for loop monitor */ +export interface LoopMonitorJudge { + /** Agent path, inline prompt, or undefined (uses default) */ + agent?: string; + /** Resolved absolute path to agent prompt file (set by loader) */ + agentPath?: string; + /** Custom instruction template for the judge (uses default if omitted) */ + instructionTemplate?: string; + /** Rules for the judge's decision */ + rules: LoopMonitorRule[]; +} + +/** Loop monitor configuration for detecting cyclic patterns between movements */ +export interface LoopMonitorConfig { + /** Ordered list of movement names forming the cycle to detect */ + cycle: string[]; + /** Number of complete cycles before triggering the judge (default: 3) */ + threshold: number; + /** Judge configuration for deciding what to do when threshold is reached */ + judge: LoopMonitorJudge; +} + /** Piece configuration */ export interface PieceConfig { name: string; @@ -100,6 +130,8 @@ export interface PieceConfig { maxIterations: number; /** Loop detection settings */ loopDetection?: LoopDetectionConfig; + /** Loop monitors for detecting cyclic patterns between movements */ + loopMonitors?: LoopMonitorConfig[]; /** * Agent to use for answering AskUserQuestion prompts automatically. * When specified, questions from Claude Code are routed to this agent diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index e8beaad..58db65b 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -158,6 +158,34 @@ export const PieceMovementRawSchema = z.object({ parallel: z.array(ParallelSubMovementRawSchema).optional(), }); +/** Loop monitor rule schema */ +export const LoopMonitorRuleSchema = z.object({ + /** Human-readable condition text */ + condition: z.string().min(1), + /** Next movement name to transition to */ + next: z.string().min(1), +}); + +/** Loop monitor judge schema */ +export const LoopMonitorJudgeSchema = z.object({ + /** Agent path, inline prompt, or omitted (uses default) */ + agent: z.string().optional(), + /** Custom instruction template for the judge */ + instruction_template: z.string().optional(), + /** Rules for the judge's decision */ + rules: z.array(LoopMonitorRuleSchema).min(1), +}); + +/** Loop monitor configuration schema */ +export const LoopMonitorSchema = z.object({ + /** Ordered list of movement names forming the cycle to detect */ + cycle: z.array(z.string().min(1)).min(2), + /** Number of complete cycles before triggering the judge (default: 3) */ + threshold: z.number().int().positive().optional().default(3), + /** Judge configuration */ + judge: LoopMonitorJudgeSchema, +}); + /** Piece configuration schema - raw YAML format */ export const PieceConfigRawSchema = z.object({ name: z.string().min(1), @@ -165,6 +193,7 @@ export const PieceConfigRawSchema = z.object({ movements: z.array(PieceMovementRawSchema).min(1), initial_movement: z.string().optional(), max_iterations: z.number().int().positive().optional().default(10), + loop_monitors: z.array(LoopMonitorSchema).optional(), answer_agent: z.string().optional(), }); diff --git a/src/core/models/types.ts b/src/core/models/types.ts index f0dca5f..efc4ef2 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -30,6 +30,9 @@ export type { ReportObjectConfig, PieceMovement, LoopDetectionConfig, + LoopMonitorConfig, + LoopMonitorJudge, + LoopMonitorRule, PieceConfig, PieceState, } from './piece-types.js'; diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index bfe4921..bfea300 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -14,11 +14,13 @@ import type { PieceState, PieceMovement, AgentResponse, + LoopMonitorConfig, } from '../../models/types.js'; import { COMPLETE_MOVEMENT, ABORT_MOVEMENT, ERROR_MESSAGES } from '../constants.js'; import type { PieceEngineOptions } from '../types.js'; import { determineNextMovementByRules } from './transitions.js'; import { LoopDetector } from './loop-detector.js'; +import { CycleDetector } from './cycle-detector.js'; import { handleBlocked } from './blocked-handler.js'; import { createInitialState, @@ -51,6 +53,7 @@ export class PieceEngine extends EventEmitter { private task: string; private options: PieceEngineOptions; private loopDetector: LoopDetector; + private cycleDetector: CycleDetector; private reportDir: string; private abortRequested = false; @@ -72,6 +75,7 @@ export class PieceEngine extends EventEmitter { this.task = task; this.options = options; this.loopDetector = new LoopDetector(config.loopDetection); + this.cycleDetector = new CycleDetector(config.loopMonitors ?? []); this.reportDir = `.takt/reports/${generateReportDir(task)}`; this.ensureReportDirExists(); this.validateConfig(); @@ -183,6 +187,26 @@ export class PieceEngine extends EventEmitter { } } } + + // Validate loop_monitors + if (this.config.loopMonitors) { + for (const monitor of this.config.loopMonitors) { + for (const cycleName of monitor.cycle) { + if (!movementNames.has(cycleName)) { + throw new Error( + `Invalid loop_monitor: cycle references unknown movement "${cycleName}"` + ); + } + } + for (const rule of monitor.judge.rules) { + if (!movementNames.has(rule.next)) { + throw new Error( + `Invalid loop_monitor judge rule: target movement "${rule.next}" does not exist` + ); + } + } + } + } } /** Get current piece state */ @@ -300,6 +324,120 @@ export class PieceEngine extends EventEmitter { ); } + /** + * Build the default instruction template for a loop monitor judge. + * Used when the monitor config does not specify a custom instruction_template. + */ + private buildDefaultJudgeInstructionTemplate( + monitor: LoopMonitorConfig, + cycleCount: number, + language: string, + ): string { + const cycleNames = monitor.cycle.join(' → '); + const rulesDesc = monitor.judge.rules.map((r) => `- ${r.condition} → ${r.next}`).join('\n'); + + if (language === 'ja') { + return [ + `ムーブメントのサイクル [${cycleNames}] が ${cycleCount} 回繰り返されました。`, + '', + 'このループが健全(進捗がある)か、非生産的(同じ問題を繰り返している)かを判断してください。', + '', + '**判断の選択肢:**', + rulesDesc, + '', + '**判断基準:**', + '- 各サイクルで新しい問題が発見・修正されているか', + '- 同じ指摘が繰り返されていないか', + '- 全体的な進捗があるか', + ].join('\n'); + } + + return [ + `The movement cycle [${cycleNames}] has repeated ${cycleCount} times.`, + '', + 'Determine whether this loop is healthy (making progress) or unproductive (repeating the same issues).', + '', + '**Decision options:**', + rulesDesc, + '', + '**Judgment criteria:**', + '- Are new issues being found/fixed in each cycle?', + '- Are the same findings being repeated?', + '- Is there overall progress?', + ].join('\n'); + } + + /** + * Execute a loop monitor judge as a synthetic movement. + * Returns the next movement name determined by the judge. + */ + private async runLoopMonitorJudge( + monitor: LoopMonitorConfig, + cycleCount: number, + ): Promise { + const language = this.options.language ?? 'en'; + const instructionTemplate = monitor.judge.instructionTemplate + ?? this.buildDefaultJudgeInstructionTemplate(monitor, cycleCount, language); + + // Replace {cycle_count} in custom templates + const processedTemplate = instructionTemplate.replace(/\{cycle_count\}/g, String(cycleCount)); + + // Build a synthetic PieceMovement for the judge + const judgeMovement: PieceMovement = { + name: `_loop_judge_${monitor.cycle.join('_')}`, + agent: monitor.judge.agent, + agentPath: monitor.judge.agentPath, + agentDisplayName: 'loop-judge', + edit: false, + instructionTemplate: processedTemplate, + rules: monitor.judge.rules.map((r) => ({ + condition: r.condition, + next: r.next, + })), + passPreviousResponse: true, + allowedTools: ['Read', 'Glob', 'Grep'], + }; + + log.info('Running loop monitor judge', { + cycle: monitor.cycle, + cycleCount, + threshold: monitor.threshold, + }); + + this.state.iteration++; + const movementIteration = incrementMovementIteration(this.state, judgeMovement.name); + const prebuiltInstruction = this.movementExecutor.buildInstruction( + judgeMovement, movementIteration, this.state, this.task, this.config.maxIterations, + ); + + this.emit('movement:start', judgeMovement, this.state.iteration, prebuiltInstruction); + + const { response, instruction } = await this.movementExecutor.runNormalMovement( + judgeMovement, + this.state, + this.task, + this.config.maxIterations, + this.updateAgentSession.bind(this), + prebuiltInstruction, + ); + this.emitCollectedReports(); + this.emit('movement:complete', judgeMovement, response, instruction); + + // Resolve next movement from the judge's rules + const nextMovement = this.resolveNextMovement(judgeMovement, response); + + log.info('Loop monitor judge decision', { + cycle: monitor.cycle, + nextMovement, + matchedRuleIndex: response.matchedRuleIndex, + }); + + // Reset cycle detector to prevent re-triggering immediately + this.cycleDetector.reset(); + + return nextMovement; + } + /** Run the piece to completion */ async run(): Promise { while (this.state.status === 'running') { @@ -378,7 +516,7 @@ export class PieceEngine extends EventEmitter { break; } - const nextMovement = this.resolveNextMovement(movement, response); + let nextMovement = this.resolveNextMovement(movement, response); log.debug('Movement transition', { from: movement.name, status: response.status, @@ -411,6 +549,23 @@ export class PieceEngine extends EventEmitter { } } + // Check loop monitors (cycle detection) after movement completion + const cycleCheck = this.cycleDetector.recordAndCheck(movement.name); + if (cycleCheck.triggered && cycleCheck.monitor) { + log.info('Loop monitor cycle threshold reached', { + cycle: cycleCheck.monitor.cycle, + cycleCount: cycleCheck.cycleCount, + threshold: cycleCheck.monitor.threshold, + }); + this.emit('movement:cycle_detected', cycleCheck.monitor, cycleCheck.cycleCount); + + // Run the judge to decide what to do + nextMovement = await this.runLoopMonitorJudge( + cycleCheck.monitor, + cycleCheck.cycleCount, + ); + } + if (nextMovement === COMPLETE_MOVEMENT) { this.state.status = 'completed'; this.emit('piece:complete', this.state); diff --git a/src/core/piece/engine/cycle-detector.ts b/src/core/piece/engine/cycle-detector.ts new file mode 100644 index 0000000..f60355d --- /dev/null +++ b/src/core/piece/engine/cycle-detector.ts @@ -0,0 +1,131 @@ +/** + * Cycle detection for loop monitors. + * + * Tracks movement execution history and detects when a specific cycle + * of movements has been repeated a configured number of times (threshold). + * + * Example: + * cycle: [ai_review, ai_fix], threshold: 3 + * History: ai_review → ai_fix → ai_review → ai_fix → ai_review → ai_fix + * ↑ + * 3 cycles → trigger + */ + +import type { LoopMonitorConfig } from '../../models/types.js'; + +/** Result of checking a single loop monitor */ +export interface CycleCheckResult { + /** Whether the threshold has been reached */ + triggered: boolean; + /** Current number of completed cycles */ + cycleCount: number; + /** The loop monitor config that was triggered (if triggered) */ + monitor?: LoopMonitorConfig; +} + +/** + * Tracks movement execution history and detects cyclic patterns + * as defined by loop_monitors configuration. + */ +export class CycleDetector { + /** Movement execution history (names in order) */ + private history: string[] = []; + private monitors: LoopMonitorConfig[]; + + constructor(monitors: LoopMonitorConfig[] = []) { + this.monitors = monitors; + } + + /** + * Record a movement completion and check if any cycle threshold is reached. + * + * The detection logic works as follows: + * 1. The movement name is appended to the history + * 2. For each monitor, we check if the cycle pattern has been completed + * by looking at the tail of the history + * 3. A cycle is "completed" when the last N entries in history match + * the cycle pattern repeated `threshold` times + * + * @param movementName The name of the movement that just completed + * @returns CycleCheckResult indicating if any monitor was triggered + */ + recordAndCheck(movementName: string): CycleCheckResult { + this.history.push(movementName); + + for (const monitor of this.monitors) { + const result = this.checkMonitor(monitor); + if (result.triggered) { + return result; + } + } + + return { triggered: false, cycleCount: 0 }; + } + + /** + * Check a single monitor against the current history. + * + * A cycle is detected when the last element of the history matches the + * last element of the cycle, and looking backwards we can find exactly + * `threshold` complete cycles. + */ + private checkMonitor(monitor: LoopMonitorConfig): CycleCheckResult { + const { cycle, threshold } = monitor; + const cycleLen = cycle.length; + + // The cycle's last step must match the most recent movement + const lastStep = cycle[cycleLen - 1]; + if (this.history[this.history.length - 1] !== lastStep) { + return { triggered: false, cycleCount: 0 }; + } + + // Need at least threshold * cycleLen entries to check + const requiredLen = threshold * cycleLen; + if (this.history.length < requiredLen) { + return { triggered: false, cycleCount: 0 }; + } + + // Count complete cycles from the end of history backwards + let cycleCount = 0; + let pos = this.history.length; + + while (pos >= cycleLen) { + // Check if the last cycleLen entries match the cycle pattern + let matches = true; + for (let i = 0; i < cycleLen; i++) { + if (this.history[pos - cycleLen + i] !== cycle[i]) { + matches = false; + break; + } + } + + if (matches) { + cycleCount++; + pos -= cycleLen; + } else { + break; + } + } + + if (cycleCount >= threshold) { + return { triggered: true, cycleCount, monitor }; + } + + return { triggered: false, cycleCount }; + } + + /** + * Reset the history after a judge intervention. + * This prevents the same cycle from immediately triggering again. + */ + reset(): void { + this.history = []; + } + + /** + * Get the current movement history (for debugging/testing). + */ + getHistory(): readonly string[] { + return this.history; + } +} diff --git a/src/core/piece/engine/index.ts b/src/core/piece/engine/index.ts index 3c5bf20..f94c0b4 100644 --- a/src/core/piece/engine/index.ts +++ b/src/core/piece/engine/index.ts @@ -9,3 +9,5 @@ export { MovementExecutor } from './MovementExecutor.js'; export type { MovementExecutorDeps } from './MovementExecutor.js'; export { ParallelRunner } from './ParallelRunner.js'; export { OptionsBuilder } from './OptionsBuilder.js'; +export { CycleDetector } from './cycle-detector.js'; +export type { CycleCheckResult } from './cycle-detector.js'; diff --git a/src/core/piece/index.ts b/src/core/piece/index.ts index 6a08774..bbb1a3b 100644 --- a/src/core/piece/index.ts +++ b/src/core/piece/index.ts @@ -35,6 +35,9 @@ export { determineNextMovementByRules, extractBlockedPrompt } from './engine/tra // Loop detection (engine/) export { LoopDetector } from './engine/loop-detector.js'; +// Cycle detection (engine/) +export { CycleDetector, type CycleCheckResult } from './engine/cycle-detector.js'; + // State management (engine/) export { createInitialState, diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index f5e179a..2527a50 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -6,7 +6,7 @@ */ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; -import type { PieceMovement, AgentResponse, PieceState, Language } from '../models/types.js'; +import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; export type ProviderType = 'claude' | 'codex' | 'mock'; @@ -119,6 +119,7 @@ export interface PieceEvents { 'piece:abort': (state: PieceState, reason: string) => void; 'iteration:limit': (iteration: number, maxIterations: number) => void; 'movement:loop_detected': (step: PieceMovement, consecutiveCount: number) => void; + 'movement:cycle_detected': (monitor: LoopMonitorConfig, cycleCount: number) => void; } /** User input request for blocked state */ diff --git a/src/features/config/ejectBuiltin.ts b/src/features/config/ejectBuiltin.ts index d2816a5..635072b 100644 --- a/src/features/config/ejectBuiltin.ts +++ b/src/features/config/ejectBuiltin.ts @@ -1,8 +1,11 @@ /** * /eject command implementation * - * Copies a builtin piece (and its agents) to ~/.takt/ for user customization. - * Once ejected, the user copy takes priority over the builtin version. + * Copies a builtin piece (and its agents) for user customization. + * Directory structure is mirrored so relative agent paths work as-is. + * + * Default target: project-local (.takt/) + * With --global: user global (~/.takt/) */ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; @@ -10,18 +13,25 @@ import { join, dirname } from 'node:path'; import { getGlobalPiecesDir, getGlobalAgentsDir, + getProjectPiecesDir, + getProjectAgentsDir, getBuiltinPiecesDir, getBuiltinAgentsDir, getLanguage, } from '../../infra/config/index.js'; import { header, success, info, warn, error, blankLine } from '../../shared/ui/index.js'; +export interface EjectOptions { + global?: boolean; + projectDir?: string; +} + /** - * Eject a builtin piece to user space for customization. - * Copies the piece YAML and related agent .md files to ~/.takt/. - * Agent paths in the ejected piece are rewritten from ../agents/ to ~/.takt/agents/. + * Eject a builtin piece to project or global space for customization. + * Copies the piece YAML and related agent .md files, preserving + * the directory structure so relative paths continue to work. */ -export async function ejectBuiltin(name?: string): Promise { +export async function ejectBuiltin(name?: string, options: EjectOptions = {}): Promise { header('Eject Builtin'); const lang = getLanguage(); @@ -29,7 +39,7 @@ export async function ejectBuiltin(name?: string): Promise { if (!name) { // List available builtins - listAvailableBuiltins(builtinPiecesDir); + listAvailableBuiltins(builtinPiecesDir, options.global); return; } @@ -40,24 +50,24 @@ export async function ejectBuiltin(name?: string): Promise { return; } - const userPiecesDir = getGlobalPiecesDir(); - const userAgentsDir = getGlobalAgentsDir(); + const projectDir = options.projectDir || process.cwd(); + const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(projectDir); + const targetAgentsDir = options.global ? getGlobalAgentsDir() : getProjectAgentsDir(projectDir); const builtinAgentsDir = getBuiltinAgentsDir(lang); + const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)'; - // Copy piece YAML (rewrite agent paths) - const pieceDest = join(userPiecesDir, `${name}.yaml`); + info(`Ejecting to ${targetLabel}`); + blankLine(); + + // Copy piece YAML as-is (no path rewriting — directory structure mirrors builtin) + const pieceDest = join(targetPiecesDir, `${name}.yaml`); if (existsSync(pieceDest)) { warn(`User piece already exists: ${pieceDest}`); warn('Skipping piece copy (user version takes priority).'); } else { mkdirSync(dirname(pieceDest), { recursive: true }); const content = readFileSync(builtinPath, 'utf-8'); - // Rewrite relative agent paths to ~/.takt/agents/ - const rewritten = content.replace( - /agent:\s*\.\.\/agents\//g, - 'agent: ~/.takt/agents/', - ); - writeFileSync(pieceDest, rewritten, 'utf-8'); + writeFileSync(pieceDest, content, 'utf-8'); success(`Ejected piece: ${pieceDest}`); } @@ -67,7 +77,7 @@ export async function ejectBuiltin(name?: string): Promise { for (const relPath of agentPaths) { const srcPath = join(builtinAgentsDir, relPath); - const destPath = join(userAgentsDir, relPath); + const destPath = join(targetAgentsDir, relPath); if (!existsSync(srcPath)) continue; @@ -88,7 +98,7 @@ export async function ejectBuiltin(name?: string): Promise { } /** List available builtin pieces for ejection */ -function listAvailableBuiltins(builtinPiecesDir: string): void { +function listAvailableBuiltins(builtinPiecesDir: string, isGlobal?: boolean): void { if (!existsSync(builtinPiecesDir)) { warn('No builtin pieces found.'); return; @@ -106,7 +116,11 @@ function listAvailableBuiltins(builtinPiecesDir: string): void { } blankLine(); - info('Usage: takt eject {name}'); + const globalFlag = isGlobal ? ' --global' : ''; + info(`Usage: takt eject {name}${globalFlag}`); + if (!isGlobal) { + info(' Add --global to eject to ~/.takt/ instead of .takt/'); + } } /** diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index f960769..1d3e114 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -10,7 +10,7 @@ import { join, dirname, basename } from 'node:path'; import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; -import type { PieceConfig, PieceMovement, PieceRule, ReportConfig, ReportObjectConfig } from '../../../core/models/index.js'; +import type { PieceConfig, PieceMovement, PieceRule, ReportConfig, ReportObjectConfig, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; /** Parsed movement type from Zod schema (replaces `any`) */ type RawStep = z.output; @@ -210,6 +210,47 @@ function normalizeStepFromRaw(step: RawStep, pieceDir: string): PieceMovement { return result; } +/** + * Normalize a raw loop monitor judge from YAML into internal format. + * Resolves agent paths and instruction_template content paths. + */ +function normalizeLoopMonitorJudge( + raw: { agent?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> }, + pieceDir: string, +): LoopMonitorJudge { + const agentSpec = raw.agent || undefined; + + let agentPath: string | undefined; + if (agentSpec) { + const resolved = resolveAgentPathForPiece(agentSpec, pieceDir); + if (existsSync(resolved)) { + agentPath = resolved; + } + } + + return { + agent: agentSpec, + agentPath, + instructionTemplate: resolveContentPath(raw.instruction_template, pieceDir), + rules: raw.rules.map((r) => ({ condition: r.condition, next: r.next })), + }; +} + +/** + * Normalize raw loop monitors from YAML into internal format. + */ +function normalizeLoopMonitors( + raw: Array<{ cycle: string[]; threshold: number; judge: { agent?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> } }> | undefined, + pieceDir: string, +): LoopMonitorConfig[] | undefined { + if (!raw || raw.length === 0) return undefined; + return raw.map((monitor) => ({ + cycle: monitor.cycle, + threshold: monitor.threshold, + judge: normalizeLoopMonitorJudge(monitor.judge, pieceDir), + })); +} + /** * Convert raw YAML piece config to internal format. * Agent paths are resolved relative to the piece directory. @@ -229,6 +270,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi movements, initialMovement, maxIterations: parsed.max_iterations, + loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir), answerAgent: parsed.answer_agent, }; } diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index 8b49b54..93847a5 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -51,6 +51,16 @@ export function getProjectConfigDir(projectDir: string): string { return join(resolve(projectDir), '.takt'); } +/** Get project pieces directory (.takt/pieces in project) */ +export function getProjectPiecesDir(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'pieces'); +} + +/** Get project agents directory (.takt/agents in project) */ +export function getProjectAgentsDir(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'agents'); +} + /** Get project config file path */ export function getProjectConfigPath(projectDir: string): string { return join(getProjectConfigDir(projectDir), 'config.yaml');