triflux 3.2.0-dev.9 → 3.3.0-dev.1
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/bin/triflux.mjs +1383 -1383
- package/hooks/hooks.json +17 -0
- package/hooks/keyword-rules.json +4 -4
- package/hooks/pipeline-stop.mjs +54 -0
- package/hub/bridge.mjs +120 -13
- 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 +78 -8
- package/hub/team/cli-team-control.mjs +381 -381
- package/hub/team/cli-team-start.mjs +474 -470
- package/hub/team/cli-team-status.mjs +238 -238
- package/hub/team/cli.mjs +86 -86
- package/hub/team/native.mjs +190 -62
- package/hub/team/nativeProxy.mjs +51 -38
- package/hub/team/orchestrator.mjs +15 -20
- package/hub/team/pane.mjs +101 -90
- package/hub/team/psmux.mjs +223 -14
- package/hub/team/session.mjs +450 -450
- package/hub/tools.mjs +126 -41
- package/hud/hud-qos-status.mjs +1790 -1790
- package/package.json +1 -1
- package/scripts/__tests__/keyword-detector.test.mjs +8 -8
- package/scripts/preflight-cache.mjs +72 -0
- package/scripts/setup.mjs +8 -1
- package/scripts/tfx-route.sh +74 -15
- package/skills/{tfx-team → tfx-multi}/SKILL.md +115 -32
|
@@ -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,
|
|
@@ -43,6 +52,14 @@ async function parseBody(req) {
|
|
|
43
52
|
|
|
44
53
|
const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
45
54
|
const PID_FILE = join(PID_DIR, 'hub.pid');
|
|
55
|
+
const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
|
|
56
|
+
|
|
57
|
+
// localhost 계열 Origin만 허용
|
|
58
|
+
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
59
|
+
|
|
60
|
+
function isAllowedOrigin(origin) {
|
|
61
|
+
return origin && ALLOWED_ORIGIN_RE.test(origin);
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
/**
|
|
48
65
|
* tfx-hub 시작
|
|
@@ -57,6 +74,11 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
57
74
|
dbPath = join(PID_DIR, 'state.db');
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
// 인증 토큰 생성 (환경변수 우선, 없으면 자동 생성)
|
|
78
|
+
const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
|
|
79
|
+
mkdirSync(join(homedir(), '.claude'), { recursive: true });
|
|
80
|
+
writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
|
|
81
|
+
|
|
60
82
|
const store = createStore(dbPath);
|
|
61
83
|
const router = createRouter(store);
|
|
62
84
|
const pipe = createPipeServer({ router, store, sessionId });
|
|
@@ -100,12 +122,16 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
const httpServer = createHttpServer(async (req, res) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
// CORS: localhost 계열 Origin만 허용
|
|
126
|
+
const origin = req.headers['origin'];
|
|
127
|
+
if (isAllowedOrigin(origin)) {
|
|
128
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
129
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
130
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
|
|
131
|
+
}
|
|
106
132
|
|
|
107
133
|
if (req.method === 'OPTIONS') {
|
|
108
|
-
res.writeHead(204);
|
|
134
|
+
res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
|
|
109
135
|
return res.end();
|
|
110
136
|
}
|
|
111
137
|
|
|
@@ -132,6 +158,14 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
132
158
|
if (req.url.startsWith('/bridge')) {
|
|
133
159
|
res.setHeader('Content-Type', 'application/json');
|
|
134
160
|
|
|
161
|
+
// Bearer 토큰 인증
|
|
162
|
+
const authHeader = req.headers['authorization'] || '';
|
|
163
|
+
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
164
|
+
if (bearerToken !== HUB_TOKEN) {
|
|
165
|
+
res.writeHead(401);
|
|
166
|
+
return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
136
170
|
res.writeHead(405);
|
|
137
171
|
return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
|
|
@@ -214,13 +248,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
214
248
|
if (req.method === 'POST') {
|
|
215
249
|
let teamResult = null;
|
|
216
250
|
if (path === '/bridge/team/info' || path === '/bridge/team-info') {
|
|
217
|
-
teamResult = teamInfo(body);
|
|
251
|
+
teamResult = await teamInfo(body);
|
|
218
252
|
} else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
|
|
219
|
-
teamResult = teamTaskList(body);
|
|
253
|
+
teamResult = await teamTaskList(body);
|
|
220
254
|
} else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
|
|
221
|
-
teamResult = teamTaskUpdate(body);
|
|
255
|
+
teamResult = await teamTaskUpdate(body);
|
|
222
256
|
} else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
|
|
223
|
-
teamResult = teamSendMessage(body);
|
|
257
|
+
teamResult = await teamSendMessage(body);
|
|
224
258
|
}
|
|
225
259
|
|
|
226
260
|
if (teamResult) {
|
|
@@ -240,6 +274,41 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
240
274
|
res.writeHead(404);
|
|
241
275
|
return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
|
|
242
276
|
}
|
|
277
|
+
|
|
278
|
+
// ── 파이프라인 엔드포인트 ──
|
|
279
|
+
if (path === '/bridge/pipeline/state' && req.method === 'POST') {
|
|
280
|
+
ensurePipelineTable(store.db);
|
|
281
|
+
const { team_name } = body;
|
|
282
|
+
const state = readPipelineState(store.db, team_name);
|
|
283
|
+
res.writeHead(state ? 200 : 404);
|
|
284
|
+
return res.end(JSON.stringify(state
|
|
285
|
+
? { ok: true, data: state }
|
|
286
|
+
: { ok: false, error: 'pipeline_not_found' }));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
|
|
290
|
+
ensurePipelineTable(store.db);
|
|
291
|
+
const { team_name, phase } = body;
|
|
292
|
+
const pipeline = createPipeline(store.db, team_name);
|
|
293
|
+
const result = pipeline.advance(phase);
|
|
294
|
+
res.writeHead(result.ok ? 200 : 400);
|
|
295
|
+
return res.end(JSON.stringify(result));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (path === '/bridge/pipeline/init' && req.method === 'POST') {
|
|
299
|
+
ensurePipelineTable(store.db);
|
|
300
|
+
const { team_name, fix_max, ralph_max } = body;
|
|
301
|
+
const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
|
|
302
|
+
res.writeHead(200);
|
|
303
|
+
return res.end(JSON.stringify({ ok: true, data: state }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (path === '/bridge/pipeline/list' && req.method === 'POST') {
|
|
307
|
+
ensurePipelineTable(store.db);
|
|
308
|
+
const states = listPipelineStates(store.db);
|
|
309
|
+
res.writeHead(200);
|
|
310
|
+
return res.end(JSON.stringify({ ok: true, data: states }));
|
|
311
|
+
}
|
|
243
312
|
}
|
|
244
313
|
|
|
245
314
|
if (path === '/bridge/context' && req.method === 'POST') {
|
|
@@ -408,6 +477,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
408
477
|
await pipe.stop();
|
|
409
478
|
store.close();
|
|
410
479
|
try { unlinkSync(PID_FILE); } catch {}
|
|
480
|
+
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
411
481
|
await new Promise((resolveClose) => httpServer.close(resolveClose));
|
|
412
482
|
};
|
|
413
483
|
|