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 CHANGED
@@ -24,6 +24,18 @@
24
24
  }
25
25
  ]
26
26
  }
27
+ ],
28
+ "Stop": [
29
+ {
30
+ "matcher": "*",
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pipeline-stop.mjs\"",
35
+ "timeout": 5
36
+ }
37
+ ]
38
+ }
27
39
  ]
28
40
  }
29
41
  }
@@ -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 result = await post('/bridge/team/info', {
307
+ const body = {
308
308
  team_name: args.team,
309
309
  include_members: true,
310
310
  include_paths: true,
311
- });
312
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
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 result = await post('/bridge/team/task-list', {
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
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
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 result = await post('/bridge/team/task-update', {
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
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
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 result = await post('/bridge/team/send-message', {
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
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
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') {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.2.0-dev.11",
3
+ "version": "3.2.0-dev.12",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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="claude-native"; CLI_CMD=""; CLI_ARGS=""
230
- CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
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 v2.2슬림 래퍼 + 네비게이션 복원 기반 멀티-CLI 팀 오케스트레이터
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
- > v2.2 현재 슬림 래퍼 Agent로 Shift+Down 네비게이션 복원, Opus 토큰 77% 절감.
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-tmux로 분기.
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-tmux: 레거시 tmux 모드
258
- --tmux 플래그 시 기존 v1 방식으로 실행: Bash("node {PKG_ROOT}/bin/triflux.mjs multi --no-attach --agents {agents} \\\"{task}\\\"")
259
- 이후 사용자에게 tmux 세션 연결 안내.
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-tmux로 전환) |
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 모드용) |