triflux 3.2.0-dev.11 → 3.2.0-dev.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/hooks.json +12 -0
- package/hooks/pipeline-stop.mjs +54 -0
- package/hub/bridge.mjs +103 -12
- package/hub/pipeline/index.mjs +121 -0
- package/hub/pipeline/state.mjs +164 -0
- package/hub/pipeline/transitions.mjs +114 -0
- package/hub/schema.sql +14 -0
- package/hub/server.mjs +44 -0
- package/hub/team/native.mjs +5 -1
- package/hub/tools.mjs +126 -41
- package/package.json +1 -1
- package/scripts/tfx-route.sh +3 -2
- package/skills/tfx-multi/SKILL.md +72 -9
package/hooks/hooks.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/pipeline-stop.mjs — 파이프라인 진행 중 세션 중단 시 지속 프롬프트 주입
|
|
3
|
+
//
|
|
4
|
+
// Claude Code의 Stop 이벤트에서 실행.
|
|
5
|
+
// 비터미널 단계의 파이프라인이 있으면 "작업 계속" 프롬프트를 반환한다.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
const HUB_DB_PATH = join(homedir(), '.claude', 'cache', 'tfx-hub', 'state.db');
|
|
12
|
+
const TERMINAL = new Set(['complete', 'failed']);
|
|
13
|
+
|
|
14
|
+
async function getPipelineStopPrompt() {
|
|
15
|
+
if (!existsSync(HUB_DB_PATH)) return null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { default: Database } = await import('better-sqlite3');
|
|
19
|
+
const { ensurePipelineTable, listPipelineStates } = await import(
|
|
20
|
+
join(process.env.CLAUDE_PLUGIN_ROOT || '.', 'hub', 'pipeline', 'state.mjs')
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const db = new Database(HUB_DB_PATH, { readonly: true });
|
|
24
|
+
ensurePipelineTable(db);
|
|
25
|
+
const states = listPipelineStates(db);
|
|
26
|
+
db.close();
|
|
27
|
+
|
|
28
|
+
// 비터미널 단계의 활성 파이프라인 찾기
|
|
29
|
+
const active = states.filter((s) => !TERMINAL.has(s.phase));
|
|
30
|
+
if (active.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
const lines = active.map((s) =>
|
|
33
|
+
`- 팀 ${s.team_name}: ${s.phase} 단계 (fix: ${s.fix_attempt}/${s.fix_max}, ralph: ${s.ralph_iteration}/${s.ralph_max})`
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return `[tfx-multi 파이프라인 진행 중]
|
|
37
|
+
활성 파이프라인이 있습니다:
|
|
38
|
+
${lines.join('\n')}
|
|
39
|
+
|
|
40
|
+
파이프라인을 이어서 진행하려면 /tfx-multi status 로 상태를 확인하세요.`;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const prompt = await getPipelineStopPrompt();
|
|
48
|
+
if (prompt) {
|
|
49
|
+
// hook 출력으로 지속 프롬프트 전달
|
|
50
|
+
console.log(prompt);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// stop 훅 실패는 무시
|
|
54
|
+
}
|
package/hub/bridge.mjs
CHANGED
|
@@ -304,27 +304,41 @@ async function cmdDeregister(args) {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
async function cmdTeamInfo(args) {
|
|
307
|
-
const
|
|
307
|
+
const body = {
|
|
308
308
|
team_name: args.team,
|
|
309
309
|
include_members: true,
|
|
310
310
|
include_paths: true,
|
|
311
|
-
}
|
|
312
|
-
|
|
311
|
+
};
|
|
312
|
+
const result = await post('/bridge/team/info', body);
|
|
313
|
+
if (result) {
|
|
314
|
+
console.log(JSON.stringify(result));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
318
|
+
const { teamInfo } = await import('./team/nativeProxy.mjs');
|
|
319
|
+
console.log(JSON.stringify(teamInfo(body)));
|
|
313
320
|
}
|
|
314
321
|
|
|
315
322
|
async function cmdTeamTaskList(args) {
|
|
316
|
-
const
|
|
323
|
+
const body = {
|
|
317
324
|
team_name: args.team,
|
|
318
325
|
owner: args.owner,
|
|
319
326
|
statuses: args.statuses ? args.statuses.split(',').map((status) => status.trim()).filter(Boolean) : [],
|
|
320
327
|
include_internal: !!args['include-internal'],
|
|
321
328
|
limit: parseInt(args.limit || '200', 10),
|
|
322
|
-
}
|
|
323
|
-
|
|
329
|
+
};
|
|
330
|
+
const result = await post('/bridge/team/task-list', body);
|
|
331
|
+
if (result) {
|
|
332
|
+
console.log(JSON.stringify(result));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
336
|
+
const { teamTaskList } = await import('./team/nativeProxy.mjs');
|
|
337
|
+
console.log(JSON.stringify(teamTaskList(body)));
|
|
324
338
|
}
|
|
325
339
|
|
|
326
340
|
async function cmdTeamTaskUpdate(args) {
|
|
327
|
-
const
|
|
341
|
+
const body = {
|
|
328
342
|
team_name: args.team,
|
|
329
343
|
task_id: args['task-id'],
|
|
330
344
|
claim: !!args.claim,
|
|
@@ -338,20 +352,95 @@ async function cmdTeamTaskUpdate(args) {
|
|
|
338
352
|
metadata_patch: args['metadata-patch'] ? parseJsonSafe(args['metadata-patch'], null) : undefined,
|
|
339
353
|
if_match_mtime_ms: args['if-match-mtime-ms'] != null ? Number(args['if-match-mtime-ms']) : undefined,
|
|
340
354
|
actor: args.actor,
|
|
341
|
-
}
|
|
342
|
-
|
|
355
|
+
};
|
|
356
|
+
const result = await post('/bridge/team/task-update', body);
|
|
357
|
+
if (result) {
|
|
358
|
+
console.log(JSON.stringify(result));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
362
|
+
const { teamTaskUpdate } = await import('./team/nativeProxy.mjs');
|
|
363
|
+
console.log(JSON.stringify(teamTaskUpdate(body)));
|
|
343
364
|
}
|
|
344
365
|
|
|
345
366
|
async function cmdTeamSendMessage(args) {
|
|
346
|
-
const
|
|
367
|
+
const body = {
|
|
347
368
|
team_name: args.team,
|
|
348
369
|
from: args.from,
|
|
349
370
|
to: args.to || 'team-lead',
|
|
350
371
|
text: args.text,
|
|
351
372
|
summary: args.summary,
|
|
352
373
|
color: args.color || 'blue',
|
|
374
|
+
};
|
|
375
|
+
const result = await post('/bridge/team/send-message', body);
|
|
376
|
+
if (result) {
|
|
377
|
+
console.log(JSON.stringify(result));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
381
|
+
const { teamSendMessage } = await import('./team/nativeProxy.mjs');
|
|
382
|
+
console.log(JSON.stringify(teamSendMessage(body)));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getHubDbPath() {
|
|
386
|
+
return join(homedir(), '.claude', 'cache', 'tfx-hub', 'state.db');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function cmdPipelineState(args) {
|
|
390
|
+
// HTTP 우선
|
|
391
|
+
const result = await post('/bridge/pipeline/state', { team_name: args.team });
|
|
392
|
+
if (result) {
|
|
393
|
+
console.log(JSON.stringify(result));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Hub 미실행 fallback — 직접 SQLite 접근
|
|
397
|
+
try {
|
|
398
|
+
const { default: Database } = await import('better-sqlite3');
|
|
399
|
+
const { ensurePipelineTable, readPipelineState } = await import('./pipeline/state.mjs');
|
|
400
|
+
const dbPath = getHubDbPath();
|
|
401
|
+
if (!existsSync(dbPath)) {
|
|
402
|
+
console.log(JSON.stringify({ ok: false, error: 'hub_db_not_found' }));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const db = new Database(dbPath, { readonly: true });
|
|
406
|
+
ensurePipelineTable(db);
|
|
407
|
+
const state = readPipelineState(db, args.team);
|
|
408
|
+
db.close();
|
|
409
|
+
console.log(JSON.stringify(state
|
|
410
|
+
? { ok: true, data: state }
|
|
411
|
+
: { ok: false, error: 'pipeline_not_found' }));
|
|
412
|
+
} catch (e) {
|
|
413
|
+
console.log(JSON.stringify({ ok: false, error: e.message }));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function cmdPipelineAdvance(args) {
|
|
418
|
+
// HTTP 우선
|
|
419
|
+
const result = await post('/bridge/pipeline/advance', {
|
|
420
|
+
team_name: args.team,
|
|
421
|
+
phase: args.status, // --status를 phase로 재활용
|
|
353
422
|
});
|
|
354
|
-
|
|
423
|
+
if (result) {
|
|
424
|
+
console.log(JSON.stringify(result));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Hub 미실행 fallback — 직접 SQLite 접근
|
|
428
|
+
try {
|
|
429
|
+
const { default: Database } = await import('better-sqlite3');
|
|
430
|
+
const { createPipeline } = await import('./pipeline/index.mjs');
|
|
431
|
+
const dbPath = getHubDbPath();
|
|
432
|
+
if (!existsSync(dbPath)) {
|
|
433
|
+
console.log(JSON.stringify({ ok: false, error: 'hub_db_not_found' }));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const db = new Database(dbPath);
|
|
437
|
+
const pipeline = createPipeline(db, args.team);
|
|
438
|
+
const advanceResult = pipeline.advance(args.status);
|
|
439
|
+
db.close();
|
|
440
|
+
console.log(JSON.stringify(advanceResult));
|
|
441
|
+
} catch (e) {
|
|
442
|
+
console.log(JSON.stringify({ ok: false, error: e.message }));
|
|
443
|
+
}
|
|
355
444
|
}
|
|
356
445
|
|
|
357
446
|
async function cmdPing() {
|
|
@@ -397,9 +486,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
397
486
|
case 'team-task-list': await cmdTeamTaskList(args); break;
|
|
398
487
|
case 'team-task-update': await cmdTeamTaskUpdate(args); break;
|
|
399
488
|
case 'team-send-message': await cmdTeamSendMessage(args); break;
|
|
489
|
+
case 'pipeline-state': await cmdPipelineState(args); break;
|
|
490
|
+
case 'pipeline-advance': await cmdPipelineAdvance(args); break;
|
|
400
491
|
case 'ping': await cmdPing(args); break;
|
|
401
492
|
default:
|
|
402
|
-
console.error('사용법: bridge.mjs <register|result|context|deregister|team-info|team-task-list|team-task-update|team-send-message|ping> [--옵션]');
|
|
493
|
+
console.error('사용법: bridge.mjs <register|result|context|deregister|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|ping> [--옵션]');
|
|
403
494
|
process.exit(1);
|
|
404
495
|
}
|
|
405
496
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// hub/pipeline/index.mjs — 파이프라인 매니저
|
|
2
|
+
//
|
|
3
|
+
// 상태(state.mjs) + 전이(transitions.mjs) 통합 인터페이스
|
|
4
|
+
|
|
5
|
+
import { canTransition, transitionPhase, ralphRestart, TERMINAL } from './transitions.mjs';
|
|
6
|
+
import {
|
|
7
|
+
ensurePipelineTable,
|
|
8
|
+
initPipelineState,
|
|
9
|
+
readPipelineState,
|
|
10
|
+
updatePipelineState,
|
|
11
|
+
removePipelineState,
|
|
12
|
+
} from './state.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 파이프라인 매니저 생성
|
|
16
|
+
* @param {object} db - better-sqlite3 인스턴스 (store.db)
|
|
17
|
+
* @param {string} teamName
|
|
18
|
+
* @param {object} opts - { fix_max?, ralph_max? }
|
|
19
|
+
* @returns {object} 파이프라인 API
|
|
20
|
+
*/
|
|
21
|
+
export function createPipeline(db, teamName, opts = {}) {
|
|
22
|
+
ensurePipelineTable(db);
|
|
23
|
+
|
|
24
|
+
// 기존 상태가 있으면 로드, 없으면 초기화
|
|
25
|
+
let state = readPipelineState(db, teamName);
|
|
26
|
+
if (!state) {
|
|
27
|
+
state = initPipelineState(db, teamName, opts);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* 현재 상태 조회
|
|
33
|
+
*/
|
|
34
|
+
getState() {
|
|
35
|
+
state = readPipelineState(db, teamName) || state;
|
|
36
|
+
return { ...state };
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 다음 단계로 전이 가능 여부
|
|
41
|
+
* @param {string} phase
|
|
42
|
+
*/
|
|
43
|
+
canAdvance(phase) {
|
|
44
|
+
const current = readPipelineState(db, teamName);
|
|
45
|
+
return current ? canTransition(current.phase, phase) : false;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 다음 단계로 전이
|
|
50
|
+
* @param {string} nextPhase
|
|
51
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
52
|
+
*/
|
|
53
|
+
advance(nextPhase) {
|
|
54
|
+
const current = readPipelineState(db, teamName);
|
|
55
|
+
if (!current) {
|
|
56
|
+
return { ok: false, error: `파이프라인 없음: ${teamName}` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = transitionPhase(current, nextPhase);
|
|
60
|
+
if (!result.ok) return result;
|
|
61
|
+
|
|
62
|
+
state = updatePipelineState(db, teamName, result.state);
|
|
63
|
+
return { ok: true, state: { ...state } };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ralph loop 재시작 (plan부터 다시)
|
|
68
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
69
|
+
*/
|
|
70
|
+
restart() {
|
|
71
|
+
const current = readPipelineState(db, teamName);
|
|
72
|
+
if (!current) {
|
|
73
|
+
return { ok: false, error: `파이프라인 없음: ${teamName}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = ralphRestart(current);
|
|
77
|
+
if (!result.ok) return result;
|
|
78
|
+
|
|
79
|
+
state = updatePipelineState(db, teamName, result.state);
|
|
80
|
+
return { ok: true, state: { ...state } };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* artifact 저장 (plan_path, prd_path, verify_report 등)
|
|
85
|
+
* @param {string} key
|
|
86
|
+
* @param {*} value
|
|
87
|
+
*/
|
|
88
|
+
setArtifact(key, value) {
|
|
89
|
+
const current = readPipelineState(db, teamName);
|
|
90
|
+
if (!current) return;
|
|
91
|
+
const artifacts = { ...(current.artifacts || {}), [key]: value };
|
|
92
|
+
state = updatePipelineState(db, teamName, { artifacts });
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 터미널 상태 여부
|
|
97
|
+
*/
|
|
98
|
+
isTerminal() {
|
|
99
|
+
const current = readPipelineState(db, teamName);
|
|
100
|
+
return current ? TERMINAL.has(current.phase) : true;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 파이프라인 초기화 (리셋)
|
|
105
|
+
*/
|
|
106
|
+
reset() {
|
|
107
|
+
state = initPipelineState(db, teamName, opts);
|
|
108
|
+
return { ...state };
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 파이프라인 삭제
|
|
113
|
+
*/
|
|
114
|
+
remove() {
|
|
115
|
+
return removePipelineState(db, teamName);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { ensurePipelineTable } from './state.mjs';
|
|
121
|
+
export { PHASES, TERMINAL, ALLOWED, canTransition } from './transitions.mjs';
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// hub/pipeline/state.mjs — Hub SQLite 파이프라인 상태 저장/로드
|
|
2
|
+
//
|
|
3
|
+
// store.mjs의 기존 SQLite 연결(db)을 활용한다.
|
|
4
|
+
// pipeline_state 테이블은 schema.sql에 정의.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* pipeline_state 테이블 초기화 (store.db에 없으면 생성)
|
|
8
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
9
|
+
*/
|
|
10
|
+
export function ensurePipelineTable(db) {
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS pipeline_state (
|
|
13
|
+
team_name TEXT PRIMARY KEY,
|
|
14
|
+
phase TEXT NOT NULL DEFAULT 'plan',
|
|
15
|
+
fix_attempt INTEGER DEFAULT 0,
|
|
16
|
+
fix_max INTEGER DEFAULT 3,
|
|
17
|
+
ralph_iteration INTEGER DEFAULT 0,
|
|
18
|
+
ralph_max INTEGER DEFAULT 10,
|
|
19
|
+
artifacts TEXT DEFAULT '{}',
|
|
20
|
+
phase_history TEXT DEFAULT '[]',
|
|
21
|
+
created_at INTEGER,
|
|
22
|
+
updated_at INTEGER
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STATEMENTS = new WeakMap();
|
|
28
|
+
|
|
29
|
+
function getStatements(db) {
|
|
30
|
+
let s = STATEMENTS.get(db);
|
|
31
|
+
if (s) return s;
|
|
32
|
+
|
|
33
|
+
s = {
|
|
34
|
+
get: db.prepare('SELECT * FROM pipeline_state WHERE team_name = ?'),
|
|
35
|
+
insert: db.prepare(`
|
|
36
|
+
INSERT INTO pipeline_state (team_name, phase, fix_attempt, fix_max, ralph_iteration, ralph_max, artifacts, phase_history, created_at, updated_at)
|
|
37
|
+
VALUES (@team_name, @phase, @fix_attempt, @fix_max, @ralph_iteration, @ralph_max, @artifacts, @phase_history, @created_at, @updated_at)
|
|
38
|
+
`),
|
|
39
|
+
update: db.prepare(`
|
|
40
|
+
UPDATE pipeline_state SET
|
|
41
|
+
phase = @phase,
|
|
42
|
+
fix_attempt = @fix_attempt,
|
|
43
|
+
fix_max = @fix_max,
|
|
44
|
+
ralph_iteration = @ralph_iteration,
|
|
45
|
+
ralph_max = @ralph_max,
|
|
46
|
+
artifacts = @artifacts,
|
|
47
|
+
phase_history = @phase_history,
|
|
48
|
+
updated_at = @updated_at
|
|
49
|
+
WHERE team_name = @team_name
|
|
50
|
+
`),
|
|
51
|
+
remove: db.prepare('DELETE FROM pipeline_state WHERE team_name = ?'),
|
|
52
|
+
list: db.prepare('SELECT * FROM pipeline_state ORDER BY updated_at DESC'),
|
|
53
|
+
};
|
|
54
|
+
STATEMENTS.set(db, s);
|
|
55
|
+
return s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseRow(row) {
|
|
59
|
+
if (!row) return null;
|
|
60
|
+
return {
|
|
61
|
+
...row,
|
|
62
|
+
artifacts: JSON.parse(row.artifacts || '{}'),
|
|
63
|
+
phase_history: JSON.parse(row.phase_history || '[]'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function serializeState(state) {
|
|
68
|
+
return {
|
|
69
|
+
team_name: state.team_name,
|
|
70
|
+
phase: state.phase || 'plan',
|
|
71
|
+
fix_attempt: state.fix_attempt ?? 0,
|
|
72
|
+
fix_max: state.fix_max ?? 3,
|
|
73
|
+
ralph_iteration: state.ralph_iteration ?? 0,
|
|
74
|
+
ralph_max: state.ralph_max ?? 10,
|
|
75
|
+
artifacts: JSON.stringify(state.artifacts || {}),
|
|
76
|
+
phase_history: JSON.stringify(state.phase_history || []),
|
|
77
|
+
created_at: state.created_at ?? Date.now(),
|
|
78
|
+
updated_at: state.updated_at ?? Date.now(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 파이프라인 상태 초기화 (새 파이프라인)
|
|
84
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
85
|
+
* @param {string} teamName
|
|
86
|
+
* @param {object} opts - { fix_max?, ralph_max? }
|
|
87
|
+
* @returns {object} 초기 상태
|
|
88
|
+
*/
|
|
89
|
+
export function initPipelineState(db, teamName, opts = {}) {
|
|
90
|
+
const S = getStatements(db);
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const state = {
|
|
93
|
+
team_name: teamName,
|
|
94
|
+
phase: 'plan',
|
|
95
|
+
fix_attempt: 0,
|
|
96
|
+
fix_max: opts.fix_max ?? 3,
|
|
97
|
+
ralph_iteration: 0,
|
|
98
|
+
ralph_max: opts.ralph_max ?? 10,
|
|
99
|
+
artifacts: {},
|
|
100
|
+
phase_history: [],
|
|
101
|
+
created_at: now,
|
|
102
|
+
updated_at: now,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// 기존 상태가 있으면 삭제 후 재생성
|
|
106
|
+
S.remove.run(teamName);
|
|
107
|
+
S.insert.run(serializeState(state));
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 파이프라인 상태 조회
|
|
113
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
114
|
+
* @param {string} teamName
|
|
115
|
+
* @returns {object|null}
|
|
116
|
+
*/
|
|
117
|
+
export function readPipelineState(db, teamName) {
|
|
118
|
+
const S = getStatements(db);
|
|
119
|
+
return parseRow(S.get.get(teamName));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 파이프라인 상태 업데이트 (부분 패치)
|
|
124
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
125
|
+
* @param {string} teamName
|
|
126
|
+
* @param {object} patch - 업데이트할 필드
|
|
127
|
+
* @returns {object|null} 업데이트된 상태
|
|
128
|
+
*/
|
|
129
|
+
export function updatePipelineState(db, teamName, patch) {
|
|
130
|
+
const S = getStatements(db);
|
|
131
|
+
const current = parseRow(S.get.get(teamName));
|
|
132
|
+
if (!current) return null;
|
|
133
|
+
|
|
134
|
+
const merged = {
|
|
135
|
+
...current,
|
|
136
|
+
...patch,
|
|
137
|
+
team_name: teamName, // team_name 변경 불가
|
|
138
|
+
updated_at: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
S.update.run(serializeState(merged));
|
|
142
|
+
return merged;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 파이프라인 상태 삭제
|
|
147
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
148
|
+
* @param {string} teamName
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
export function removePipelineState(db, teamName) {
|
|
152
|
+
const S = getStatements(db);
|
|
153
|
+
return S.remove.run(teamName).changes > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 활성 파이프라인 목록
|
|
158
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
159
|
+
* @returns {object[]}
|
|
160
|
+
*/
|
|
161
|
+
export function listPipelineStates(db) {
|
|
162
|
+
const S = getStatements(db);
|
|
163
|
+
return S.list.all().map(parseRow);
|
|
164
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// hub/pipeline/transitions.mjs — 파이프라인 단계 전이 규칙
|
|
2
|
+
//
|
|
3
|
+
// plan → prd → exec → verify → complete/fix
|
|
4
|
+
// fix → exec/verify/complete/failed
|
|
5
|
+
// complete, failed = 터미널 상태
|
|
6
|
+
|
|
7
|
+
export const PHASES = ['plan', 'prd', 'exec', 'verify', 'fix', 'complete', 'failed'];
|
|
8
|
+
|
|
9
|
+
export const TERMINAL = new Set(['complete', 'failed']);
|
|
10
|
+
|
|
11
|
+
export const ALLOWED = {
|
|
12
|
+
'plan': ['prd'],
|
|
13
|
+
'prd': ['exec'],
|
|
14
|
+
'exec': ['verify'],
|
|
15
|
+
'verify': ['fix', 'complete', 'failed'],
|
|
16
|
+
'fix': ['exec', 'verify', 'complete', 'failed'],
|
|
17
|
+
'complete': [],
|
|
18
|
+
'failed': [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 전이 가능 여부 확인
|
|
23
|
+
* @param {string} from - 현재 단계
|
|
24
|
+
* @param {string} to - 다음 단계
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function canTransition(from, to) {
|
|
28
|
+
const targets = ALLOWED[from];
|
|
29
|
+
if (!targets) return false;
|
|
30
|
+
return targets.includes(to);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 상태 전이 실행 — fix loop 바운딩 포함
|
|
35
|
+
* @param {object} state - 파이프라인 상태 객체
|
|
36
|
+
* @param {string} nextPhase - 다음 단계
|
|
37
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
38
|
+
*/
|
|
39
|
+
export function transitionPhase(state, nextPhase) {
|
|
40
|
+
const current = state.phase;
|
|
41
|
+
|
|
42
|
+
if (!canTransition(current, nextPhase)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `전이 불가: ${current} → ${nextPhase}. 허용: [${(ALLOWED[current] || []).join(', ')}]`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const next = { ...state, phase: nextPhase, updated_at: Date.now() };
|
|
50
|
+
|
|
51
|
+
// fix 단계 진입 시 attempt 증가 + 바운딩
|
|
52
|
+
if (nextPhase === 'fix') {
|
|
53
|
+
next.fix_attempt = (state.fix_attempt || 0) + 1;
|
|
54
|
+
if (next.fix_attempt > (state.fix_max || 3)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: `fix loop 초과: ${next.fix_attempt}/${state.fix_max || 3}회. ralph loop로 승격 필요.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// fix → exec 재진입 시 (fix 후 재실행)
|
|
63
|
+
if (current === 'fix' && nextPhase === 'exec') {
|
|
64
|
+
// fix_attempt 유지 (이미 fix 진입 시 증가됨)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// verify → fix → ... → verify 반복 후 fix_max 초과 시 ralph loop
|
|
68
|
+
if (nextPhase === 'failed' && current === 'fix') {
|
|
69
|
+
// ralph loop 반복 증가
|
|
70
|
+
next.ralph_iteration = (state.ralph_iteration || 0) + 1;
|
|
71
|
+
if (next.ralph_iteration > (state.ralph_max || 10)) {
|
|
72
|
+
// 최종 실패 — ralph loop도 초과
|
|
73
|
+
next.phase = 'failed';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// phase_history 기록
|
|
78
|
+
const history = Array.isArray(state.phase_history) ? [...state.phase_history] : [];
|
|
79
|
+
history.push({ from: current, to: nextPhase, at: Date.now() });
|
|
80
|
+
next.phase_history = history;
|
|
81
|
+
|
|
82
|
+
return { ok: true, state: next };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ralph loop 재시작 전이
|
|
87
|
+
* fix_max 초과 시 plan으로 돌아가며 ralph_iteration 증가
|
|
88
|
+
* @param {object} state - 현재 상태
|
|
89
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function ralphRestart(state) {
|
|
92
|
+
const iteration = (state.ralph_iteration || 0) + 1;
|
|
93
|
+
if (iteration > (state.ralph_max || 10)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `ralph loop 초과: ${iteration}/${state.ralph_max || 10}회. 최종 실패.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const history = Array.isArray(state.phase_history) ? [...state.phase_history] : [];
|
|
101
|
+
history.push({ from: state.phase, to: 'plan', at: Date.now(), ralph_restart: true });
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
state: {
|
|
106
|
+
...state,
|
|
107
|
+
phase: 'plan',
|
|
108
|
+
fix_attempt: 0,
|
|
109
|
+
ralph_iteration: iteration,
|
|
110
|
+
phase_history: history,
|
|
111
|
+
updated_at: Date.now(),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
package/hub/schema.sql
CHANGED
|
@@ -78,3 +78,17 @@ CREATE INDEX IF NOT EXISTS idx_inbox_message ON message_inbox(message_id);
|
|
|
78
78
|
CREATE INDEX IF NOT EXISTS idx_human_requests_state ON human_requests(state);
|
|
79
79
|
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
|
|
80
80
|
CREATE INDEX IF NOT EXISTS idx_agents_lease ON agents(lease_expires_ms);
|
|
81
|
+
|
|
82
|
+
-- 파이프라인 상태 테이블 (Phase 2)
|
|
83
|
+
CREATE TABLE IF NOT EXISTS pipeline_state (
|
|
84
|
+
team_name TEXT PRIMARY KEY,
|
|
85
|
+
phase TEXT NOT NULL DEFAULT 'plan',
|
|
86
|
+
fix_attempt INTEGER DEFAULT 0,
|
|
87
|
+
fix_max INTEGER DEFAULT 3,
|
|
88
|
+
ralph_iteration INTEGER DEFAULT 0,
|
|
89
|
+
ralph_max INTEGER DEFAULT 10,
|
|
90
|
+
artifacts TEXT DEFAULT '{}',
|
|
91
|
+
phase_history TEXT DEFAULT '[]',
|
|
92
|
+
created_at INTEGER,
|
|
93
|
+
updated_at INTEGER
|
|
94
|
+
);
|
package/hub/server.mjs
CHANGED
|
@@ -14,6 +14,15 @@ import { createRouter } from './router.mjs';
|
|
|
14
14
|
import { createHitlManager } from './hitl.mjs';
|
|
15
15
|
import { createPipeServer } from './pipe.mjs';
|
|
16
16
|
import { createTools } from './tools.mjs';
|
|
17
|
+
import {
|
|
18
|
+
ensurePipelineTable,
|
|
19
|
+
createPipeline,
|
|
20
|
+
} from './pipeline/index.mjs';
|
|
21
|
+
import {
|
|
22
|
+
readPipelineState,
|
|
23
|
+
initPipelineState,
|
|
24
|
+
listPipelineStates,
|
|
25
|
+
} from './pipeline/state.mjs';
|
|
17
26
|
import {
|
|
18
27
|
teamInfo,
|
|
19
28
|
teamTaskList,
|
|
@@ -240,6 +249,41 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
240
249
|
res.writeHead(404);
|
|
241
250
|
return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
|
|
242
251
|
}
|
|
252
|
+
|
|
253
|
+
// ── 파이프라인 엔드포인트 ──
|
|
254
|
+
if (path === '/bridge/pipeline/state' && req.method === 'POST') {
|
|
255
|
+
ensurePipelineTable(store.db);
|
|
256
|
+
const { team_name } = body;
|
|
257
|
+
const state = readPipelineState(store.db, team_name);
|
|
258
|
+
res.writeHead(state ? 200 : 404);
|
|
259
|
+
return res.end(JSON.stringify(state
|
|
260
|
+
? { ok: true, data: state }
|
|
261
|
+
: { ok: false, error: 'pipeline_not_found' }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
|
|
265
|
+
ensurePipelineTable(store.db);
|
|
266
|
+
const { team_name, phase } = body;
|
|
267
|
+
const pipeline = createPipeline(store.db, team_name);
|
|
268
|
+
const result = pipeline.advance(phase);
|
|
269
|
+
res.writeHead(result.ok ? 200 : 400);
|
|
270
|
+
return res.end(JSON.stringify(result));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (path === '/bridge/pipeline/init' && req.method === 'POST') {
|
|
274
|
+
ensurePipelineTable(store.db);
|
|
275
|
+
const { team_name, fix_max, ralph_max } = body;
|
|
276
|
+
const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
|
|
277
|
+
res.writeHead(200);
|
|
278
|
+
return res.end(JSON.stringify({ ok: true, data: state }));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (path === '/bridge/pipeline/list' && req.method === 'POST') {
|
|
282
|
+
ensurePipelineTable(store.db);
|
|
283
|
+
const states = listPipelineStates(store.db);
|
|
284
|
+
res.writeHead(200);
|
|
285
|
+
return res.end(JSON.stringify({ ok: true, data: states }));
|
|
286
|
+
}
|
|
243
287
|
}
|
|
244
288
|
|
|
245
289
|
if (path === '/bridge/context' && req.method === 'POST') {
|
package/hub/team/native.mjs
CHANGED
|
@@ -33,12 +33,16 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
|
33
33
|
agentName = "",
|
|
34
34
|
leadName = "team-lead",
|
|
35
35
|
mcp_profile = "auto",
|
|
36
|
+
pipelinePhase = "",
|
|
36
37
|
} = opts;
|
|
37
38
|
|
|
38
39
|
// 셸 이스케이프
|
|
39
40
|
const escaped = subtask.replace(/'/g, "'\\''");
|
|
41
|
+
const pipelineHint = pipelinePhase
|
|
42
|
+
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
43
|
+
: '';
|
|
40
44
|
|
|
41
|
-
return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지
|
|
45
|
+
return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지 마라.${pipelineHint}
|
|
42
46
|
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
43
47
|
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
44
48
|
|
package/hub/tools.mjs
CHANGED
|
@@ -8,16 +8,25 @@ import {
|
|
|
8
8
|
teamTaskUpdate,
|
|
9
9
|
teamSendMessage,
|
|
10
10
|
} from './team/nativeProxy.mjs';
|
|
11
|
+
import {
|
|
12
|
+
ensurePipelineTable,
|
|
13
|
+
createPipeline,
|
|
14
|
+
} from './pipeline/index.mjs';
|
|
15
|
+
import {
|
|
16
|
+
readPipelineState,
|
|
17
|
+
initPipelineState,
|
|
18
|
+
listPipelineStates,
|
|
19
|
+
} from './pipeline/state.mjs';
|
|
11
20
|
|
|
12
21
|
/**
|
|
13
22
|
* MCP 도구 목록 생성
|
|
14
23
|
* @param {object} store — createStore() 반환
|
|
15
24
|
* @param {object} router — createRouter() 반환
|
|
16
|
-
* @param {object} hitl — createHitlManager() 반환
|
|
17
|
-
* @param {object} pipe — createPipeServer() 반환
|
|
18
|
-
* @returns {Array<{name, description, inputSchema, handler}>}
|
|
19
|
-
*/
|
|
20
|
-
export function createTools(store, router, hitl, pipe = null) {
|
|
25
|
+
* @param {object} hitl — createHitlManager() 반환
|
|
26
|
+
* @param {object} pipe — createPipeServer() 반환
|
|
27
|
+
* @returns {Array<{name, description, inputSchema, handler}>}
|
|
28
|
+
*/
|
|
29
|
+
export function createTools(store, router, hitl, pipe = null) {
|
|
21
30
|
/** 도구 핸들러 래퍼 — 에러 처리 + MCP content 형식 변환 */
|
|
22
31
|
function wrap(code, fn) {
|
|
23
32
|
return async (args) => {
|
|
@@ -49,10 +58,10 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
49
58
|
heartbeat_ttl_ms: { type: 'integer', minimum: 10000, maximum: 7200000 },
|
|
50
59
|
},
|
|
51
60
|
},
|
|
52
|
-
handler: wrap('REGISTER_FAILED', (args) => {
|
|
53
|
-
const data = router.registerAgent(args);
|
|
54
|
-
return { ok: true, data };
|
|
55
|
-
}),
|
|
61
|
+
handler: wrap('REGISTER_FAILED', (args) => {
|
|
62
|
+
const data = router.registerAgent(args);
|
|
63
|
+
return { ok: true, data };
|
|
64
|
+
}),
|
|
56
65
|
},
|
|
57
66
|
|
|
58
67
|
// ── 2. status ──
|
|
@@ -122,10 +131,10 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
122
131
|
}),
|
|
123
132
|
},
|
|
124
133
|
|
|
125
|
-
// ── 5. poll_messages ──
|
|
126
|
-
{
|
|
127
|
-
name: 'poll_messages',
|
|
128
|
-
description: 'Deprecated. poll_messages 대신 Named Pipe subscribe/publish 채널을 사용합니다',
|
|
134
|
+
// ── 5. poll_messages ──
|
|
135
|
+
{
|
|
136
|
+
name: 'poll_messages',
|
|
137
|
+
description: 'Deprecated. poll_messages 대신 Named Pipe subscribe/publish 채널을 사용합니다',
|
|
129
138
|
inputSchema: {
|
|
130
139
|
type: 'object',
|
|
131
140
|
required: ['agent_id'],
|
|
@@ -137,34 +146,34 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
137
146
|
ack_ids: { type: 'array', items: { type: 'string' }, maxItems: 100 },
|
|
138
147
|
auto_ack: { type: 'boolean', default: false },
|
|
139
148
|
},
|
|
140
|
-
},
|
|
141
|
-
handler: wrap('POLL_DEPRECATED', async (args) => {
|
|
142
|
-
const replay = router.drainAgent(args.agent_id, {
|
|
143
|
-
max_messages: args.max_messages,
|
|
144
|
-
include_topics: args.include_topics,
|
|
145
|
-
auto_ack: args.auto_ack,
|
|
146
|
-
});
|
|
147
|
-
if (args.ack_ids?.length) {
|
|
148
|
-
router.ackMessages(args.ack_ids, args.agent_id);
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
ok: false,
|
|
152
|
-
error: {
|
|
153
|
-
code: 'POLL_DEPRECATED',
|
|
154
|
-
message: 'poll_messages는 deprecated 되었습니다. pipe subscribe/publish 채널을 사용하세요.',
|
|
155
|
-
},
|
|
156
|
-
data: {
|
|
157
|
-
pipe_path: pipe?.path || null,
|
|
158
|
-
delivery_mode: 'pipe_push',
|
|
159
|
-
protocol: 'ndjson',
|
|
160
|
-
replay: {
|
|
161
|
-
messages: replay,
|
|
162
|
-
count: replay.length,
|
|
163
|
-
},
|
|
164
|
-
server_time_ms: Date.now(),
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
}),
|
|
149
|
+
},
|
|
150
|
+
handler: wrap('POLL_DEPRECATED', async (args) => {
|
|
151
|
+
const replay = router.drainAgent(args.agent_id, {
|
|
152
|
+
max_messages: args.max_messages,
|
|
153
|
+
include_topics: args.include_topics,
|
|
154
|
+
auto_ack: args.auto_ack,
|
|
155
|
+
});
|
|
156
|
+
if (args.ack_ids?.length) {
|
|
157
|
+
router.ackMessages(args.ack_ids, args.agent_id);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
error: {
|
|
162
|
+
code: 'POLL_DEPRECATED',
|
|
163
|
+
message: 'poll_messages는 deprecated 되었습니다. pipe subscribe/publish 채널을 사용하세요.',
|
|
164
|
+
},
|
|
165
|
+
data: {
|
|
166
|
+
pipe_path: pipe?.path || null,
|
|
167
|
+
delivery_mode: 'pipe_push',
|
|
168
|
+
protocol: 'ndjson',
|
|
169
|
+
replay: {
|
|
170
|
+
messages: replay,
|
|
171
|
+
count: replay.length,
|
|
172
|
+
},
|
|
173
|
+
server_time_ms: Date.now(),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
168
177
|
},
|
|
169
178
|
|
|
170
179
|
// ── 6. handoff ──
|
|
@@ -325,5 +334,81 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
325
334
|
return teamSendMessage(args);
|
|
326
335
|
}),
|
|
327
336
|
},
|
|
337
|
+
|
|
338
|
+
// ── 13. pipeline_state ──
|
|
339
|
+
{
|
|
340
|
+
name: 'pipeline_state',
|
|
341
|
+
description: '파이프라인 상태를 조회합니다 (--thorough 모드)',
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
required: ['team_name'],
|
|
345
|
+
properties: {
|
|
346
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
handler: wrap('PIPELINE_STATE_FAILED', (args) => {
|
|
350
|
+
ensurePipelineTable(store.db);
|
|
351
|
+
const state = readPipelineState(store.db, args.team_name);
|
|
352
|
+
return state
|
|
353
|
+
? { ok: true, data: state }
|
|
354
|
+
: { ok: false, error: { code: 'PIPELINE_NOT_FOUND', message: `파이프라인 없음: ${args.team_name}` } };
|
|
355
|
+
}),
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// ── 14. pipeline_advance ──
|
|
359
|
+
{
|
|
360
|
+
name: 'pipeline_advance',
|
|
361
|
+
description: '파이프라인을 다음 단계로 전이합니다 (전이 규칙 + fix loop 바운딩 적용)',
|
|
362
|
+
inputSchema: {
|
|
363
|
+
type: 'object',
|
|
364
|
+
required: ['team_name', 'phase'],
|
|
365
|
+
properties: {
|
|
366
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
367
|
+
phase: { type: 'string', enum: ['plan', 'prd', 'exec', 'verify', 'fix', 'complete', 'failed'] },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
handler: wrap('PIPELINE_ADVANCE_FAILED', (args) => {
|
|
371
|
+
ensurePipelineTable(store.db);
|
|
372
|
+
const pipeline = createPipeline(store.db, args.team_name);
|
|
373
|
+
return pipeline.advance(args.phase);
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
// ── 15. pipeline_init ──
|
|
378
|
+
{
|
|
379
|
+
name: 'pipeline_init',
|
|
380
|
+
description: '새 파이프라인을 초기화합니다 (기존 상태 덮어쓰기)',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
required: ['team_name'],
|
|
384
|
+
properties: {
|
|
385
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
386
|
+
fix_max: { type: 'integer', minimum: 1, maximum: 20, default: 3 },
|
|
387
|
+
ralph_max: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
handler: wrap('PIPELINE_INIT_FAILED', (args) => {
|
|
391
|
+
ensurePipelineTable(store.db);
|
|
392
|
+
const state = initPipelineState(store.db, args.team_name, {
|
|
393
|
+
fix_max: args.fix_max,
|
|
394
|
+
ralph_max: args.ralph_max,
|
|
395
|
+
});
|
|
396
|
+
return { ok: true, data: state };
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
// ── 16. pipeline_list ──
|
|
401
|
+
{
|
|
402
|
+
name: 'pipeline_list',
|
|
403
|
+
description: '활성 파이프라인 목록을 조회합니다',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {},
|
|
407
|
+
},
|
|
408
|
+
handler: wrap('PIPELINE_LIST_FAILED', () => {
|
|
409
|
+
ensurePipelineTable(store.db);
|
|
410
|
+
return { ok: true, data: listPipelineStates(store.db) };
|
|
411
|
+
}),
|
|
412
|
+
},
|
|
328
413
|
];
|
|
329
414
|
}
|
package/package.json
CHANGED
package/scripts/tfx-route.sh
CHANGED
|
@@ -226,8 +226,9 @@ route_agent() {
|
|
|
226
226
|
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
227
227
|
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
228
228
|
verifier)
|
|
229
|
-
CLI_TYPE="
|
|
230
|
-
|
|
229
|
+
CLI_TYPE="codex"; CLI_CMD="codex"
|
|
230
|
+
CLI_ARGS="exec --profile thorough ${codex_base} review"
|
|
231
|
+
CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
231
232
|
test-engineer)
|
|
232
233
|
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
233
234
|
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
@@ -6,17 +6,18 @@ triggers:
|
|
|
6
6
|
argument-hint: '"작업 설명" | --agents codex,gemini "작업" | --tmux "작업" | status | stop'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# tfx-multi
|
|
9
|
+
# tfx-multi v3 — 파이프라인 기반 멀티-CLI 팀 오케스트레이터
|
|
10
10
|
|
|
11
11
|
> Claude Code Native Teams의 Shift+Down 네비게이션을 복원한다.
|
|
12
12
|
> Codex/Gemini 워커마다 최소 프롬프트(~100 토큰)의 슬림 Agent 래퍼를 spawn하여 네비게이션에 등록하고,
|
|
13
13
|
> 실제 작업은 `tfx-route.sh`가 수행한다. task 상태는 `team_task_list`를 truth source로 검증한다.
|
|
14
|
-
>
|
|
14
|
+
> v3 — `--quick`(기본, v2.2 호환) + `--thorough`(전체 파이프라인: plan→prd→exec→verify→fix loop).
|
|
15
15
|
|
|
16
16
|
## 사용법
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
/tfx-multi "인증 리팩터링 + UI 개선 + 보안 리뷰"
|
|
19
|
+
/tfx-multi "인증 리팩터링 + UI 개선 + 보안 리뷰" # --quick (기본)
|
|
20
|
+
/tfx-multi --thorough "인증 리팩터링 + UI 개선 + 보안 리뷰" # 전체 파이프라인
|
|
20
21
|
/tfx-multi --agents codex,gemini "프론트+백엔드"
|
|
21
22
|
/tfx-multi --tmux "작업" # 레거시 tmux 모드
|
|
22
23
|
/tfx-multi status
|
|
@@ -60,7 +61,7 @@ argument-hint: '"작업 설명" | --agents codex,gemini "작업" | --tmux "작
|
|
|
60
61
|
(`bin/triflux.mjs` 절대경로는 triflux 패키지 루트 기준)
|
|
61
62
|
- 그 외 → Phase 2 트리아지
|
|
62
63
|
|
|
63
|
-
**--tmux 감지:** 입력에 `--tmux`가 포함되면 Phase 3-
|
|
64
|
+
**--tmux/--psmux 감지:** 입력에 `--tmux` 또는 `--psmux`가 포함되면 Phase 3-mux로 분기. psmux가 primary (Windows).
|
|
64
65
|
|
|
65
66
|
### Phase 2: 트리아지 (tfx-auto와 동일)
|
|
66
67
|
|
|
@@ -96,6 +97,32 @@ Bash("codex exec --full-auto --skip-git-repo-check '다음 작업을 분석하
|
|
|
96
97
|
|
|
97
98
|
Codex 분류 건너뜀 → Opus가 직접 N개 서브태스크 분해.
|
|
98
99
|
|
|
100
|
+
### Phase 2.5–2.6: 파이프라인 Plan/PRD (`--thorough` 전용)
|
|
101
|
+
|
|
102
|
+
> `--quick`(기본) 모드에서는 이 단계를 건너뛰고 Phase 3으로 직행한다.
|
|
103
|
+
> `--thorough` 모드에서만 실행된다.
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
[--thorough 모드 감지]
|
|
107
|
+
|
|
108
|
+
Phase 2.5: Plan (Codex architect)
|
|
109
|
+
1. Hub pipeline 초기화:
|
|
110
|
+
Bash("node hub/bridge.mjs pipeline-advance --team ${teamName} --status plan")
|
|
111
|
+
— 또는 createPipeline(db, teamName) 직접 호출
|
|
112
|
+
2. Codex architect로 작업 분석 + 접근법 설계:
|
|
113
|
+
bash ~/.claude/scripts/tfx-route.sh architect "${task}" analyze
|
|
114
|
+
3. 결과를 파이프라인 artifact에 저장:
|
|
115
|
+
pipeline.setArtifact('plan_path', planOutputPath)
|
|
116
|
+
4. pipeline advance: plan → prd
|
|
117
|
+
|
|
118
|
+
Phase 2.6: PRD (Codex analyst)
|
|
119
|
+
1. Codex analyst로 수용 기준 확정:
|
|
120
|
+
bash ~/.claude/scripts/tfx-route.sh analyst "${task}" analyze
|
|
121
|
+
2. 결과를 파이프라인 artifact에 저장:
|
|
122
|
+
pipeline.setArtifact('prd_path', prdOutputPath)
|
|
123
|
+
3. pipeline advance: prd → exec
|
|
124
|
+
```
|
|
125
|
+
|
|
99
126
|
### Phase 3: Native Teams 실행 (v2.1 개편)
|
|
100
127
|
|
|
101
128
|
트리아지 결과를 Claude Code 네이티브 Agent Teams로 실행한다.
|
|
@@ -212,6 +239,37 @@ Shift+Down으로 다음 워커 (마지막→리드 wrap), Shift+Tab으로 이전
|
|
|
212
239
|
(Shift+Up은 Claude Code 미지원 — 대부분 터미널에서 scroll-up으로 먹힘)"
|
|
213
240
|
```
|
|
214
241
|
|
|
242
|
+
### Phase 3.5–3.7: Verify/Fix Loop (`--thorough` 전용)
|
|
243
|
+
|
|
244
|
+
> `--quick`(기본) 모드에서는 이 단계를 건너뛰고 Phase 4로 직행한다.
|
|
245
|
+
> `--thorough` 모드에서만 실행된다.
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
Phase 3.5: Verify (Codex review)
|
|
249
|
+
1. pipeline advance: exec → verify
|
|
250
|
+
2. Codex verifier로 결과 검증:
|
|
251
|
+
bash ~/.claude/scripts/tfx-route.sh verifier "결과 검증: ${task}" review
|
|
252
|
+
— verifier는 Codex --profile thorough review로 실행됨
|
|
253
|
+
3. 검증 결과를 파이프라인 artifact에 저장:
|
|
254
|
+
pipeline.setArtifact('verify_report', verifyOutputPath)
|
|
255
|
+
4. 통과 → pipeline advance: verify → complete → Phase 5 (cleanup)
|
|
256
|
+
5. 실패 → Phase 3.6
|
|
257
|
+
|
|
258
|
+
Phase 3.6: Fix (Codex executor, max 3회)
|
|
259
|
+
1. pipeline advance: verify → fix
|
|
260
|
+
— fix_attempt 자동 증가, fix_max(3) 초과 시 전이 거부
|
|
261
|
+
2. fix_attempt > fix_max → Phase 3.7 (ralph loop) 또는 failed 보고 → Phase 5
|
|
262
|
+
3. Codex executor로 실패 항목 수정:
|
|
263
|
+
bash ~/.claude/scripts/tfx-route.sh executor "실패 항목 수정: ${failedItems}" implement
|
|
264
|
+
4. pipeline advance: fix → exec (재실행)
|
|
265
|
+
5. → Phase 3 (exec) → Phase 3.5 (verify) 재실행
|
|
266
|
+
|
|
267
|
+
Phase 3.7: Ralph Loop (fix 3회 초과 시)
|
|
268
|
+
1. ralph_iteration 증가 (pipeline.restart())
|
|
269
|
+
2. ralph_iteration > ralph_max(10) → 최종 failed → Phase 5
|
|
270
|
+
3. fix_attempt 리셋, 전체 파이프라인 재시작 (Phase 2.5 plan부터)
|
|
271
|
+
```
|
|
272
|
+
|
|
215
273
|
### Phase 4: 결과 수집 (truth source = team_task_list)
|
|
216
274
|
|
|
217
275
|
1. 리드가 Step 3c에서 실행한 모든 백그라운드 Bash 프로세스 완료를 대기한다.
|
|
@@ -254,9 +312,12 @@ Bash("node hub/bridge.mjs team-task-list --team ${teamName}")
|
|
|
254
312
|
```
|
|
255
313
|
5. 종합 보고서를 출력한다.
|
|
256
314
|
|
|
257
|
-
### Phase 3-
|
|
258
|
-
|
|
259
|
-
|
|
315
|
+
### Phase 3-mux: 레거시 psmux/tmux 모드
|
|
316
|
+
`--tmux` 또는 `--psmux` 플래그 시 pane 기반 실행. `detectMultiplexer()`가 psmux → tmux → wsl-tmux → git-bash-tmux 순으로 자동 감지.
|
|
317
|
+
Windows에서는 **psmux가 1순위** (ADR-001).
|
|
318
|
+
|
|
319
|
+
Bash("node {PKG_ROOT}/bin/triflux.mjs multi --no-attach --agents {agents} \\\"{task}\\\"")
|
|
320
|
+
이후 사용자에게 세션 연결 안내.
|
|
260
321
|
|
|
261
322
|
## 에이전트 매핑
|
|
262
323
|
> 에이전트 매핑: codex/gemini → tfx-route.sh, claude → Agent(subagent_type) 직접 실행. 상세는 Phase 3c/3d 참조.
|
|
@@ -266,14 +327,15 @@ Bash("node hub/bridge.mjs team-task-list --team ${teamName}")
|
|
|
266
327
|
- **CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1** — settings.json env에 설정 (`tfx setup`이 자동 설정)
|
|
267
328
|
- **codex/gemini CLI** — 해당 에이전트 사용 시
|
|
268
329
|
- **tfx setup** — tfx-route.sh 동기화 + AGENT_TEAMS 자동 설정 (사전 실행 권장)
|
|
269
|
-
- **Hub 활성 상태** — Named Pipe(`\\.\pipe\triflux-{pid}`) 우선, HTTP `127.0.0.1:27888` fallback. `bridge.mjs`가 자동 선택.
|
|
330
|
+
- **Hub 활성 상태** — Named Pipe(`\\.\pipe\triflux-{pid}`) 우선, HTTP `127.0.0.1:27888` fallback. `bridge.mjs`가 자동 선택. Hub 미실행 시 nativeProxy fallback.
|
|
331
|
+
- **멀티플렉서** — psmux(Windows 1순위) / tmux / wsl-tmux / git-bash-tmux 자동 감지 (`--tmux`/`--psmux` 모드)
|
|
270
332
|
- **출력 정책** — preflight는 비동기/요약 출력이 기본이며, 실패 시에만 상세 출력
|
|
271
333
|
|
|
272
334
|
## 에러 처리
|
|
273
335
|
|
|
274
336
|
| 에러 | 처리 |
|
|
275
337
|
|------|------|
|
|
276
|
-
| TeamCreate 실패 / Agent Teams 비활성 | `--tmux` 폴백 (Phase 3-
|
|
338
|
+
| TeamCreate 실패 / Agent Teams 비활성 | `--psmux/--tmux` 폴백 (Phase 3-mux로 전환) |
|
|
277
339
|
| tfx-route.sh 없음 | `tfx setup` 실행 안내 |
|
|
278
340
|
| CLI 미설치 (codex/gemini) | 해당 서브태스크를 claude 워커로 대체 |
|
|
279
341
|
| Codex 분류 실패 | Opus 직접 분류+분해 |
|
|
@@ -292,5 +354,6 @@ Bash("node hub/bridge.mjs team-task-list --team ${teamName}")
|
|
|
292
354
|
| `scripts/tfx-route.sh` | 팀 통합 라우터 (`TFX_TEAM_*`, task claim/complete, send-message, Named Pipe/HTTP bridge) |
|
|
293
355
|
| `hub/team/native.mjs` | Native Teams 래퍼 (프롬프트 템플릿, 팀 설정 빌더) |
|
|
294
356
|
| `hub/team/cli.mjs` | tmux 팀 CLI (`--tmux` 레거시 모드) |
|
|
357
|
+
| `hub/pipeline/` | 파이프라인 상태 기계 (transitions, state, index) — `--thorough` 모드 |
|
|
295
358
|
| `tfx-auto` | one-shot 실행 오케스트레이터 (병행 유지) |
|
|
296
359
|
| `tfx-hub` | MCP 메시지 버스 관리 (tmux 모드용) |
|