resolved ai_review ↔ ai_fix ループの健全性チェックと修正不要時の裁定ステップを追加 #102
This commit is contained in:
parent
68b45abbf6
commit
3e54c80ba2
190
e2e/specs/eject.e2e.ts
Normal file
190
e2e/specs/eject.e2e.ts
Normal 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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -26,6 +26,30 @@ max_iterations: 30
|
|||||||
|
|
||||||
initial_movement: plan
|
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:
|
movements:
|
||||||
- name: plan
|
- name: plan
|
||||||
edit: false
|
edit: false
|
||||||
|
|||||||
@ -17,6 +17,30 @@ max_iterations: 30
|
|||||||
|
|
||||||
initial_movement: plan
|
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:
|
movements:
|
||||||
- name: plan
|
- name: plan
|
||||||
edit: false
|
edit: false
|
||||||
|
|||||||
218
src/__tests__/cycle-detector.test.ts
Normal file
218
src/__tests__/cycle-detector.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
315
src/__tests__/engine-loop-monitors.test.ts
Normal file
315
src/__tests__/engine-loop-monitors.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -75,10 +75,11 @@ program
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command('eject')
|
.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')
|
.argument('[name]', 'Specific builtin to eject')
|
||||||
.action(async (name?: string) => {
|
.option('--global', 'Eject to ~/.takt/ instead of project .takt/')
|
||||||
await ejectBuiltin(name);
|
.action(async (name: string | undefined, opts: { global?: boolean }) => {
|
||||||
|
await ejectBuiltin(name, { global: opts.global, projectDir: resolvedCwd });
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
@ -11,6 +11,9 @@ export type {
|
|||||||
PieceRule,
|
PieceRule,
|
||||||
PieceMovement,
|
PieceMovement,
|
||||||
LoopDetectionConfig,
|
LoopDetectionConfig,
|
||||||
|
LoopMonitorConfig,
|
||||||
|
LoopMonitorJudge,
|
||||||
|
LoopMonitorRule,
|
||||||
PieceConfig,
|
PieceConfig,
|
||||||
PieceState,
|
PieceState,
|
||||||
CustomAgentConfig,
|
CustomAgentConfig,
|
||||||
|
|||||||
@ -91,6 +91,36 @@ export interface LoopDetectionConfig {
|
|||||||
action?: 'abort' | 'warn' | 'ignore';
|
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 */
|
/** Piece configuration */
|
||||||
export interface PieceConfig {
|
export interface PieceConfig {
|
||||||
name: string;
|
name: string;
|
||||||
@ -100,6 +130,8 @@ export interface PieceConfig {
|
|||||||
maxIterations: number;
|
maxIterations: number;
|
||||||
/** Loop detection settings */
|
/** Loop detection settings */
|
||||||
loopDetection?: LoopDetectionConfig;
|
loopDetection?: LoopDetectionConfig;
|
||||||
|
/** Loop monitors for detecting cyclic patterns between movements */
|
||||||
|
loopMonitors?: LoopMonitorConfig[];
|
||||||
/**
|
/**
|
||||||
* Agent to use for answering AskUserQuestion prompts automatically.
|
* Agent to use for answering AskUserQuestion prompts automatically.
|
||||||
* When specified, questions from Claude Code are routed to this agent
|
* When specified, questions from Claude Code are routed to this agent
|
||||||
|
|||||||
@ -158,6 +158,34 @@ export const PieceMovementRawSchema = z.object({
|
|||||||
parallel: z.array(ParallelSubMovementRawSchema).optional(),
|
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 */
|
/** Piece configuration schema - raw YAML format */
|
||||||
export const PieceConfigRawSchema = z.object({
|
export const PieceConfigRawSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@ -165,6 +193,7 @@ export const PieceConfigRawSchema = z.object({
|
|||||||
movements: z.array(PieceMovementRawSchema).min(1),
|
movements: z.array(PieceMovementRawSchema).min(1),
|
||||||
initial_movement: z.string().optional(),
|
initial_movement: z.string().optional(),
|
||||||
max_iterations: z.number().int().positive().optional().default(10),
|
max_iterations: z.number().int().positive().optional().default(10),
|
||||||
|
loop_monitors: z.array(LoopMonitorSchema).optional(),
|
||||||
answer_agent: z.string().optional(),
|
answer_agent: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,9 @@ export type {
|
|||||||
ReportObjectConfig,
|
ReportObjectConfig,
|
||||||
PieceMovement,
|
PieceMovement,
|
||||||
LoopDetectionConfig,
|
LoopDetectionConfig,
|
||||||
|
LoopMonitorConfig,
|
||||||
|
LoopMonitorJudge,
|
||||||
|
LoopMonitorRule,
|
||||||
PieceConfig,
|
PieceConfig,
|
||||||
PieceState,
|
PieceState,
|
||||||
} from './piece-types.js';
|
} from './piece-types.js';
|
||||||
|
|||||||
@ -14,11 +14,13 @@ import type {
|
|||||||
PieceState,
|
PieceState,
|
||||||
PieceMovement,
|
PieceMovement,
|
||||||
AgentResponse,
|
AgentResponse,
|
||||||
|
LoopMonitorConfig,
|
||||||
} from '../../models/types.js';
|
} from '../../models/types.js';
|
||||||
import { COMPLETE_MOVEMENT, ABORT_MOVEMENT, ERROR_MESSAGES } from '../constants.js';
|
import { COMPLETE_MOVEMENT, ABORT_MOVEMENT, ERROR_MESSAGES } from '../constants.js';
|
||||||
import type { PieceEngineOptions } from '../types.js';
|
import type { PieceEngineOptions } from '../types.js';
|
||||||
import { determineNextMovementByRules } from './transitions.js';
|
import { determineNextMovementByRules } from './transitions.js';
|
||||||
import { LoopDetector } from './loop-detector.js';
|
import { LoopDetector } from './loop-detector.js';
|
||||||
|
import { CycleDetector } from './cycle-detector.js';
|
||||||
import { handleBlocked } from './blocked-handler.js';
|
import { handleBlocked } from './blocked-handler.js';
|
||||||
import {
|
import {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
@ -51,6 +53,7 @@ export class PieceEngine extends EventEmitter {
|
|||||||
private task: string;
|
private task: string;
|
||||||
private options: PieceEngineOptions;
|
private options: PieceEngineOptions;
|
||||||
private loopDetector: LoopDetector;
|
private loopDetector: LoopDetector;
|
||||||
|
private cycleDetector: CycleDetector;
|
||||||
private reportDir: string;
|
private reportDir: string;
|
||||||
private abortRequested = false;
|
private abortRequested = false;
|
||||||
|
|
||||||
@ -72,6 +75,7 @@ export class PieceEngine extends EventEmitter {
|
|||||||
this.task = task;
|
this.task = task;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.loopDetector = new LoopDetector(config.loopDetection);
|
this.loopDetector = new LoopDetector(config.loopDetection);
|
||||||
|
this.cycleDetector = new CycleDetector(config.loopMonitors ?? []);
|
||||||
this.reportDir = `.takt/reports/${generateReportDir(task)}`;
|
this.reportDir = `.takt/reports/${generateReportDir(task)}`;
|
||||||
this.ensureReportDirExists();
|
this.ensureReportDirExists();
|
||||||
this.validateConfig();
|
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 */
|
/** 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 */
|
/** Run the piece to completion */
|
||||||
async run(): Promise<PieceState> {
|
async run(): Promise<PieceState> {
|
||||||
while (this.state.status === 'running') {
|
while (this.state.status === 'running') {
|
||||||
@ -378,7 +516,7 @@ export class PieceEngine extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMovement = this.resolveNextMovement(movement, response);
|
let nextMovement = this.resolveNextMovement(movement, response);
|
||||||
log.debug('Movement transition', {
|
log.debug('Movement transition', {
|
||||||
from: movement.name,
|
from: movement.name,
|
||||||
status: response.status,
|
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) {
|
if (nextMovement === COMPLETE_MOVEMENT) {
|
||||||
this.state.status = 'completed';
|
this.state.status = 'completed';
|
||||||
this.emit('piece:complete', this.state);
|
this.emit('piece:complete', this.state);
|
||||||
|
|||||||
131
src/core/piece/engine/cycle-detector.ts
Normal file
131
src/core/piece/engine/cycle-detector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,3 +9,5 @@ export { MovementExecutor } from './MovementExecutor.js';
|
|||||||
export type { MovementExecutorDeps } from './MovementExecutor.js';
|
export type { MovementExecutorDeps } from './MovementExecutor.js';
|
||||||
export { ParallelRunner } from './ParallelRunner.js';
|
export { ParallelRunner } from './ParallelRunner.js';
|
||||||
export { OptionsBuilder } from './OptionsBuilder.js';
|
export { OptionsBuilder } from './OptionsBuilder.js';
|
||||||
|
export { CycleDetector } from './cycle-detector.js';
|
||||||
|
export type { CycleCheckResult } from './cycle-detector.js';
|
||||||
|
|||||||
@ -35,6 +35,9 @@ export { determineNextMovementByRules, extractBlockedPrompt } from './engine/tra
|
|||||||
// Loop detection (engine/)
|
// Loop detection (engine/)
|
||||||
export { LoopDetector } from './engine/loop-detector.js';
|
export { LoopDetector } from './engine/loop-detector.js';
|
||||||
|
|
||||||
|
// Cycle detection (engine/)
|
||||||
|
export { CycleDetector, type CycleCheckResult } from './engine/cycle-detector.js';
|
||||||
|
|
||||||
// State management (engine/)
|
// State management (engine/)
|
||||||
export {
|
export {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
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';
|
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||||
|
|
||||||
@ -119,6 +119,7 @@ export interface PieceEvents {
|
|||||||
'piece:abort': (state: PieceState, reason: string) => void;
|
'piece:abort': (state: PieceState, reason: string) => void;
|
||||||
'iteration:limit': (iteration: number, maxIterations: number) => void;
|
'iteration:limit': (iteration: number, maxIterations: number) => void;
|
||||||
'movement:loop_detected': (step: PieceMovement, consecutiveCount: number) => void;
|
'movement:loop_detected': (step: PieceMovement, consecutiveCount: number) => void;
|
||||||
|
'movement:cycle_detected': (monitor: LoopMonitorConfig, cycleCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User input request for blocked state */
|
/** User input request for blocked state */
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* /eject command implementation
|
* /eject command implementation
|
||||||
*
|
*
|
||||||
* Copies a builtin piece (and its agents) to ~/.takt/ for user customization.
|
* Copies a builtin piece (and its agents) for user customization.
|
||||||
* Once ejected, the user copy takes priority over the builtin version.
|
* 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';
|
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
@ -10,18 +13,25 @@ import { join, dirname } from 'node:path';
|
|||||||
import {
|
import {
|
||||||
getGlobalPiecesDir,
|
getGlobalPiecesDir,
|
||||||
getGlobalAgentsDir,
|
getGlobalAgentsDir,
|
||||||
|
getProjectPiecesDir,
|
||||||
|
getProjectAgentsDir,
|
||||||
getBuiltinPiecesDir,
|
getBuiltinPiecesDir,
|
||||||
getBuiltinAgentsDir,
|
getBuiltinAgentsDir,
|
||||||
getLanguage,
|
getLanguage,
|
||||||
} from '../../infra/config/index.js';
|
} from '../../infra/config/index.js';
|
||||||
import { header, success, info, warn, error, blankLine } from '../../shared/ui/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.
|
* Eject a builtin piece to project or global space for customization.
|
||||||
* Copies the piece YAML and related agent .md files to ~/.takt/.
|
* Copies the piece YAML and related agent .md files, preserving
|
||||||
* Agent paths in the ejected piece are rewritten from ../agents/ to ~/.takt/agents/.
|
* 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');
|
header('Eject Builtin');
|
||||||
|
|
||||||
const lang = getLanguage();
|
const lang = getLanguage();
|
||||||
@ -29,7 +39,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
|||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
// List available builtins
|
// List available builtins
|
||||||
listAvailableBuiltins(builtinPiecesDir);
|
listAvailableBuiltins(builtinPiecesDir, options.global);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,24 +50,24 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPiecesDir = getGlobalPiecesDir();
|
const projectDir = options.projectDir || process.cwd();
|
||||||
const userAgentsDir = getGlobalAgentsDir();
|
const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(projectDir);
|
||||||
|
const targetAgentsDir = options.global ? getGlobalAgentsDir() : getProjectAgentsDir(projectDir);
|
||||||
const builtinAgentsDir = getBuiltinAgentsDir(lang);
|
const builtinAgentsDir = getBuiltinAgentsDir(lang);
|
||||||
|
const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)';
|
||||||
|
|
||||||
// Copy piece YAML (rewrite agent paths)
|
info(`Ejecting to ${targetLabel}`);
|
||||||
const pieceDest = join(userPiecesDir, `${name}.yaml`);
|
blankLine();
|
||||||
|
|
||||||
|
// Copy piece YAML as-is (no path rewriting — directory structure mirrors builtin)
|
||||||
|
const pieceDest = join(targetPiecesDir, `${name}.yaml`);
|
||||||
if (existsSync(pieceDest)) {
|
if (existsSync(pieceDest)) {
|
||||||
warn(`User piece already exists: ${pieceDest}`);
|
warn(`User piece already exists: ${pieceDest}`);
|
||||||
warn('Skipping piece copy (user version takes priority).');
|
warn('Skipping piece copy (user version takes priority).');
|
||||||
} else {
|
} else {
|
||||||
mkdirSync(dirname(pieceDest), { recursive: true });
|
mkdirSync(dirname(pieceDest), { recursive: true });
|
||||||
const content = readFileSync(builtinPath, 'utf-8');
|
const content = readFileSync(builtinPath, 'utf-8');
|
||||||
// Rewrite relative agent paths to ~/.takt/agents/
|
writeFileSync(pieceDest, content, 'utf-8');
|
||||||
const rewritten = content.replace(
|
|
||||||
/agent:\s*\.\.\/agents\//g,
|
|
||||||
'agent: ~/.takt/agents/',
|
|
||||||
);
|
|
||||||
writeFileSync(pieceDest, rewritten, 'utf-8');
|
|
||||||
success(`Ejected piece: ${pieceDest}`);
|
success(`Ejected piece: ${pieceDest}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +77,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
|||||||
|
|
||||||
for (const relPath of agentPaths) {
|
for (const relPath of agentPaths) {
|
||||||
const srcPath = join(builtinAgentsDir, relPath);
|
const srcPath = join(builtinAgentsDir, relPath);
|
||||||
const destPath = join(userAgentsDir, relPath);
|
const destPath = join(targetAgentsDir, relPath);
|
||||||
|
|
||||||
if (!existsSync(srcPath)) continue;
|
if (!existsSync(srcPath)) continue;
|
||||||
|
|
||||||
@ -88,7 +98,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** List available builtin pieces for ejection */
|
/** List available builtin pieces for ejection */
|
||||||
function listAvailableBuiltins(builtinPiecesDir: string): void {
|
function listAvailableBuiltins(builtinPiecesDir: string, isGlobal?: boolean): void {
|
||||||
if (!existsSync(builtinPiecesDir)) {
|
if (!existsSync(builtinPiecesDir)) {
|
||||||
warn('No builtin pieces found.');
|
warn('No builtin pieces found.');
|
||||||
return;
|
return;
|
||||||
@ -106,7 +116,11 @@ function listAvailableBuiltins(builtinPiecesDir: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
blankLine();
|
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/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { join, dirname, basename } from 'node:path';
|
|||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
|
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`) */
|
/** Parsed movement type from Zod schema (replaces `any`) */
|
||||||
type RawStep = z.output<typeof PieceMovementRawSchema>;
|
type RawStep = z.output<typeof PieceMovementRawSchema>;
|
||||||
@ -210,6 +210,47 @@ function normalizeStepFromRaw(step: RawStep, pieceDir: string): PieceMovement {
|
|||||||
return result;
|
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.
|
* Convert raw YAML piece config to internal format.
|
||||||
* Agent paths are resolved relative to the piece directory.
|
* Agent paths are resolved relative to the piece directory.
|
||||||
@ -229,6 +270,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi
|
|||||||
movements,
|
movements,
|
||||||
initialMovement,
|
initialMovement,
|
||||||
maxIterations: parsed.max_iterations,
|
maxIterations: parsed.max_iterations,
|
||||||
|
loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir),
|
||||||
answerAgent: parsed.answer_agent,
|
answerAgent: parsed.answer_agent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,16 @@ export function getProjectConfigDir(projectDir: string): string {
|
|||||||
return join(resolve(projectDir), '.takt');
|
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 */
|
/** Get project config file path */
|
||||||
export function getProjectConfigPath(projectDir: string): string {
|
export function getProjectConfigPath(projectDir: string): string {
|
||||||
return join(getProjectConfigDir(projectDir), 'config.yaml');
|
return join(getProjectConfigDir(projectDir), 'config.yaml');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user