resolved ai_review ↔ ai_fix ループの健全性チェックと修正不要時の裁定ステップを追加 #102

This commit is contained in:
nrslib 2026-02-06 07:15:43 +09:00
parent 68b45abbf6
commit 3e54c80ba2
18 changed files with 1223 additions and 26 deletions

190
e2e/specs/eject.e2e.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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<Record<string, unknown>>()),
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<LoopMonitorConfig> = {},
): 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);
});
});
});

View File

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

View File

@ -11,6 +11,9 @@ export type {
PieceRule,
PieceMovement,
LoopDetectionConfig,
LoopMonitorConfig,
LoopMonitorJudge,
LoopMonitorRule,
PieceConfig,
PieceState,
CustomAgentConfig,

View File

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

View File

@ -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(),
});

View File

@ -30,6 +30,9 @@ export type {
ReportObjectConfig,
PieceMovement,
LoopDetectionConfig,
LoopMonitorConfig,
LoopMonitorJudge,
LoopMonitorRule,
PieceConfig,
PieceState,
} from './piece-types.js';

View File

@ -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<string> {
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<PieceState> {
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> {
export async function ejectBuiltin(name?: string, options: EjectOptions = {}): Promise<void> {
header('Eject Builtin');
const lang = getLanguage();
@ -29,7 +39,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
if (!name) {
// List available builtins
listAvailableBuiltins(builtinPiecesDir);
listAvailableBuiltins(builtinPiecesDir, options.global);
return;
}
@ -40,24 +50,24 @@ export async function ejectBuiltin(name?: string): Promise<void> {
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<void> {
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<void> {
}
/** 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/');
}
}
/**

View File

@ -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<typeof PieceMovementRawSchema>;
@ -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,
};
}

View File

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