triflux 3.2.0-dev.8 → 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.
Files changed (52) hide show
  1. package/bin/triflux.mjs +1296 -1055
  2. package/hooks/hooks.json +17 -0
  3. package/hooks/keyword-rules.json +20 -4
  4. package/hooks/pipeline-stop.mjs +54 -0
  5. package/hub/bridge.mjs +517 -318
  6. package/hub/hitl.mjs +45 -31
  7. package/hub/pipe.mjs +457 -0
  8. package/hub/pipeline/index.mjs +121 -0
  9. package/hub/pipeline/state.mjs +164 -0
  10. package/hub/pipeline/transitions.mjs +114 -0
  11. package/hub/router.mjs +422 -161
  12. package/hub/schema.sql +14 -0
  13. package/hub/server.mjs +499 -424
  14. package/hub/store.mjs +388 -314
  15. package/hub/team/cli-team-common.mjs +348 -0
  16. package/hub/team/cli-team-control.mjs +393 -0
  17. package/hub/team/cli-team-start.mjs +516 -0
  18. package/hub/team/cli-team-status.mjs +269 -0
  19. package/hub/team/cli.mjs +75 -1475
  20. package/hub/team/dashboard.mjs +1 -9
  21. package/hub/team/native.mjs +190 -130
  22. package/hub/team/nativeProxy.mjs +165 -78
  23. package/hub/team/orchestrator.mjs +15 -20
  24. package/hub/team/pane.mjs +137 -103
  25. package/hub/team/psmux.mjs +506 -0
  26. package/hub/team/session.mjs +393 -330
  27. package/hub/team/shared.mjs +13 -0
  28. package/hub/team/staleState.mjs +299 -0
  29. package/hub/tools.mjs +105 -31
  30. package/hub/workers/claude-worker.mjs +446 -0
  31. package/hub/workers/codex-mcp.mjs +414 -0
  32. package/hub/workers/factory.mjs +18 -0
  33. package/hub/workers/gemini-worker.mjs +349 -0
  34. package/hub/workers/interface.mjs +41 -0
  35. package/hud/hud-qos-status.mjs +1790 -1788
  36. package/package.json +4 -1
  37. package/scripts/__tests__/keyword-detector.test.mjs +8 -8
  38. package/scripts/keyword-detector.mjs +15 -0
  39. package/scripts/lib/keyword-rules.mjs +4 -1
  40. package/scripts/preflight-cache.mjs +72 -0
  41. package/scripts/psmux-steering-prototype.sh +368 -0
  42. package/scripts/setup.mjs +136 -71
  43. package/scripts/tfx-route-worker.mjs +161 -0
  44. package/scripts/tfx-route.sh +485 -91
  45. package/skills/tfx-auto/SKILL.md +90 -564
  46. package/skills/tfx-auto-codex/SKILL.md +1 -3
  47. package/skills/tfx-codex/SKILL.md +1 -4
  48. package/skills/tfx-doctor/SKILL.md +1 -0
  49. package/skills/tfx-gemini/SKILL.md +1 -4
  50. package/skills/tfx-multi/SKILL.md +378 -0
  51. package/skills/tfx-setup/SKILL.md +1 -4
  52. package/skills/tfx-team/SKILL.md +0 -304
package/hub/hitl.mjs CHANGED
@@ -1,12 +1,30 @@
1
1
  // hub/hitl.mjs — Human-in-the-Loop 매니저
2
2
  // 사용자 입력 요청/응답, 타임아웃 자동 처리
3
3
 
4
- /**
5
- * HITL 매니저 생성
6
- * @param {object} store — createStore() 반환 객체
7
- */
8
- export function createHitlManager(store) {
9
- return {
4
+ /**
5
+ * HITL 매니저 생성
6
+ * @param {object} store — createStore() 반환 객체
7
+ * @param {object} router — createRouter() 반환 객체
8
+ */
9
+ export function createHitlManager(store, router = null) {
10
+ function forwardHumanResponse({ requesterAgent, requestId, action, content, submittedBy, correlationId, traceId, priority }) {
11
+ if (!router?.handlePublish) {
12
+ throw new Error('router.handlePublish is required for HITL forwarding');
13
+ }
14
+ return router.handlePublish({
15
+ from: 'hub:hitl',
16
+ to: requesterAgent,
17
+ topic: 'human.response',
18
+ priority,
19
+ ttl_ms: 300000,
20
+ payload: { request_id: requestId, action, content, submitted_by: submittedBy },
21
+ correlation_id: correlationId,
22
+ trace_id: traceId,
23
+ message_type: 'human_response',
24
+ });
25
+ }
26
+
27
+ return {
10
28
  /**
11
29
  * 사용자에게 입력 요청 생성
12
30
  * 터미널에 알림 출력 후 pending 상태로 저장
@@ -61,21 +79,19 @@ export function createHitlManager(store) {
61
79
 
62
80
  // 요청자에게 응답 메시지 전달
63
81
  let forwardedMessageId = null;
64
- if (action === 'accept' || action === 'decline') {
65
- const msg = store.enqueueMessage({
66
- type: 'human_response',
67
- from: 'hub:hitl',
68
- to: hr.requester_agent,
69
- topic: 'human.response',
70
- priority: 7, // urgent — 사용자 블로킹 해소
71
- ttl_ms: 300000,
72
- payload: { request_id, action, content, submitted_by },
73
- correlation_id: hr.correlation_id,
74
- trace_id: hr.trace_id,
75
- });
76
- store.deliverToAgent(msg.id, hr.requester_agent);
77
- forwardedMessageId = msg.id;
78
- }
82
+ if (action === 'accept' || action === 'decline') {
83
+ const published = forwardHumanResponse({
84
+ requesterAgent: hr.requester_agent,
85
+ requestId: request_id,
86
+ action,
87
+ content,
88
+ submittedBy: submitted_by,
89
+ correlationId: hr.correlation_id,
90
+ traceId: hr.trace_id,
91
+ priority: 7,
92
+ });
93
+ forwardedMessageId = published.data?.message_id || null;
94
+ }
79
95
 
80
96
  return {
81
97
  ok: true,
@@ -98,18 +114,16 @@ export function createHitlManager(store) {
98
114
  for (const hr of expired) {
99
115
  store.updateHumanRequest(hr.request_id, 'timed_out', null);
100
116
  if (hr.default_action === 'timeout_continue') {
101
- const msg = store.enqueueMessage({
102
- type: 'human_response',
103
- from: 'hub:hitl',
104
- to: hr.requester_agent,
105
- topic: 'human.response',
117
+ forwardHumanResponse({
118
+ requesterAgent: hr.requester_agent,
119
+ requestId: hr.request_id,
120
+ action: 'timeout_continue',
121
+ content: null,
122
+ submittedBy: 'system',
123
+ correlationId: hr.correlation_id,
124
+ traceId: hr.trace_id,
106
125
  priority: 5,
107
- ttl_ms: 300000,
108
- payload: { request_id: hr.request_id, action: 'timeout_continue', content: null },
109
- correlation_id: hr.correlation_id,
110
- trace_id: hr.trace_id,
111
126
  });
112
- store.deliverToAgent(msg.id, hr.requester_agent);
113
127
  }
114
128
  }
115
129
  return expired.length;
package/hub/pipe.mjs ADDED
@@ -0,0 +1,457 @@
1
+ // hub/pipe.mjs — Named Pipe/Unix socket 제어 채널
2
+ // NDJSON 프로토콜로 에이전트 실시간 제어/이벤트 푸시를 처리한다.
3
+
4
+ import net from 'node:net';
5
+ import { existsSync, unlinkSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+
9
+ const DEFAULT_HEARTBEAT_TTL_MS = 60000;
10
+
11
+ /** 플랫폼별 pipe 경로 계산 */
12
+ export function getPipePath(sessionId = process.pid) {
13
+ if (process.platform === 'win32') {
14
+ return `\\\\.\\pipe\\triflux-${sessionId}`;
15
+ }
16
+ return join('/tmp', `triflux-${sessionId}.sock`);
17
+ }
18
+
19
+ function safeJsonParse(line) {
20
+ try {
21
+ return JSON.parse(line);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function normalizeTopics(topics) {
28
+ if (!Array.isArray(topics)) return [];
29
+ return topics
30
+ .map((topic) => String(topic || '').trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ /**
35
+ * Named Pipe 서버 생성
36
+ * @param {object} opts
37
+ * @param {object} opts.router
38
+ * @param {object} [opts.store]
39
+ * @param {string|number} [opts.sessionId]
40
+ * @param {number} [opts.heartbeatTtlMs]
41
+ */
42
+ export function createPipeServer({
43
+ router,
44
+ store = null,
45
+ sessionId = process.pid,
46
+ heartbeatTtlMs = DEFAULT_HEARTBEAT_TTL_MS,
47
+ } = {}) {
48
+ if (!router) {
49
+ throw new Error('router is required');
50
+ }
51
+
52
+ const pipePath = getPipePath(sessionId);
53
+ const clients = new Map();
54
+ let server = null;
55
+ let heartbeatTimer = null;
56
+
57
+ function sendFrame(client, frame) {
58
+ if (!client || client.closed || !client.socket.writable) return false;
59
+ try {
60
+ client.socket.write(`${JSON.stringify(frame)}\n`);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function sendResponse(client, requestId, result) {
68
+ return sendFrame(client, { type: 'response', request_id: requestId, ...result });
69
+ }
70
+
71
+ function closeClient(client) {
72
+ if (!client || client.closed) return;
73
+ client.closed = true;
74
+ clients.delete(client.id);
75
+ try { client.socket.destroy(); } catch {}
76
+ }
77
+
78
+ function touchClient(client) {
79
+ client.lastHeartbeatMs = Date.now();
80
+ }
81
+
82
+ function resolveAgentId(client, payload) {
83
+ const agentId = payload?.agent_id || client?.agentId;
84
+ if (!agentId) {
85
+ throw new Error('agent_id required');
86
+ }
87
+ return agentId;
88
+ }
89
+
90
+ function pushEvent(agentId, message) {
91
+ let delivered = false;
92
+ for (const client of clients.values()) {
93
+ if (client.agentId !== agentId) continue;
94
+ if (sendFrame(client, {
95
+ type: 'event',
96
+ event: 'message',
97
+ payload: { agent_id: agentId, message },
98
+ })) {
99
+ delivered = true;
100
+ }
101
+ }
102
+ return delivered;
103
+ }
104
+
105
+ function pushPendingMessages(agentId) {
106
+ if (!agentId) return 0;
107
+ const pending = router.getPendingMessages(agentId, { max_messages: 100 });
108
+ let pushed = 0;
109
+ for (const message of pending) {
110
+ if (router.markMessagePushed(agentId, message.id)) {
111
+ pushed += pushEvent(agentId, message) ? 1 : 0;
112
+ } else if (pushEvent(agentId, message)) {
113
+ pushed += 1;
114
+ }
115
+ }
116
+ return pushed;
117
+ }
118
+
119
+ async function processCommand(client, action, payload = {}) {
120
+ switch (action) {
121
+ case 'register': {
122
+ const result = router.registerAgent(payload);
123
+ if (client) {
124
+ client.agentId = payload.agent_id;
125
+ client.subscriptions = new Set(router.getSubscribedTopics(client.agentId));
126
+ touchClient(client);
127
+ pushPendingMessages(client.agentId);
128
+ }
129
+ return { ok: true, data: { ...result, pipe_path: pipePath } };
130
+ }
131
+
132
+ case 'subscribe': {
133
+ const agentId = resolveAgentId(client, payload);
134
+ const topics = normalizeTopics(payload.topics);
135
+ const result = router.subscribeAgent(agentId, topics, {
136
+ replace: Boolean(payload.replace),
137
+ });
138
+ if (client) {
139
+ client.agentId = agentId;
140
+ client.subscriptions = new Set(result.topics);
141
+ touchClient(client);
142
+ }
143
+ const replayed = pushPendingMessages(agentId);
144
+ return {
145
+ ok: true,
146
+ data: { ...result, replayed_messages: replayed },
147
+ };
148
+ }
149
+
150
+ case 'ack': {
151
+ const agentId = resolveAgentId(client, payload);
152
+ const acked = router.ackMessages(payload.message_ids || payload.ack_ids || [], agentId);
153
+ if (client) touchClient(client);
154
+ return { ok: true, data: { agent_id: agentId, acked_count: acked } };
155
+ }
156
+
157
+ case 'heartbeat': {
158
+ const agentId = resolveAgentId(client, payload);
159
+ const result = router.refreshAgentLease(agentId, payload.heartbeat_ttl_ms || heartbeatTtlMs);
160
+ if (client) touchClient(client);
161
+ return { ok: true, data: result };
162
+ }
163
+
164
+ case 'publish': {
165
+ const result = router.handlePublish(payload);
166
+ if (client) touchClient(client);
167
+ return result;
168
+ }
169
+
170
+ case 'handoff': {
171
+ const result = router.handleHandoff(payload);
172
+ if (client) touchClient(client);
173
+ return result;
174
+ }
175
+
176
+ case 'result': {
177
+ const result = router.handlePublish({
178
+ from: payload.agent_id,
179
+ to: `topic:${payload.topic || 'task.result'}`,
180
+ topic: payload.topic || 'task.result',
181
+ payload: payload.payload || {},
182
+ priority: 5,
183
+ ttl_ms: 3600000,
184
+ trace_id: payload.trace_id,
185
+ correlation_id: payload.correlation_id,
186
+ });
187
+ if (client) touchClient(client);
188
+ return result;
189
+ }
190
+
191
+ case 'control': {
192
+ const result = router.handlePublish({
193
+ from: payload.from_agent || 'lead',
194
+ to: payload.to_agent,
195
+ topic: 'lead.control',
196
+ payload: {
197
+ command: payload.command,
198
+ reason: payload.reason || '',
199
+ ...(payload.payload || {}),
200
+ issued_at: Date.now(),
201
+ },
202
+ priority: 8,
203
+ ttl_ms: Math.max(1000, Math.min(Number(payload.ttl_ms) || 3600000, 86400000)),
204
+ trace_id: payload.trace_id,
205
+ correlation_id: payload.correlation_id,
206
+ });
207
+ if (client) touchClient(client);
208
+ return result;
209
+ }
210
+
211
+ case 'deregister': {
212
+ const agentId = resolveAgentId(client, payload);
213
+ router.updateAgentStatus(agentId, 'offline');
214
+ if (client) touchClient(client);
215
+ return {
216
+ ok: true,
217
+ data: { agent_id: agentId, status: 'offline' },
218
+ };
219
+ }
220
+
221
+ default:
222
+ return {
223
+ ok: false,
224
+ error: { code: 'UNKNOWN_PIPE_COMMAND', message: `지원하지 않는 command: ${action}` },
225
+ };
226
+ }
227
+ }
228
+
229
+ function buildReplayMessages(agentId, payload = {}) {
230
+ const maxMessages = Math.max(1, Math.min(Number(payload.max_messages) || 20, 100));
231
+ const pending = router.getPendingMessages(agentId, {
232
+ max_messages: maxMessages,
233
+ include_topics: payload.topics,
234
+ });
235
+ if (!store?.getAuditMessagesForAgent) {
236
+ return pending.slice(0, maxMessages);
237
+ }
238
+
239
+ const audit = store.getAuditMessagesForAgent(agentId, {
240
+ max_messages: maxMessages,
241
+ include_topics: payload.topics,
242
+ });
243
+ const byId = new Map();
244
+ for (const message of [...pending, ...audit]) {
245
+ if (!message?.id || byId.has(message.id)) continue;
246
+ byId.set(message.id, message);
247
+ }
248
+ return Array.from(byId.values())
249
+ .sort((left, right) => right.created_at_ms - left.created_at_ms)
250
+ .slice(0, maxMessages);
251
+ }
252
+
253
+ async function processQuery(client, action, payload = {}) {
254
+ switch (action) {
255
+ case 'drain': {
256
+ const agentId = resolveAgentId(client, payload);
257
+ const messages = router.drainAgent(agentId, {
258
+ max_messages: payload.max_messages,
259
+ include_topics: payload.topics,
260
+ auto_ack: payload.auto_ack,
261
+ });
262
+ if (client) touchClient(client);
263
+ return {
264
+ ok: true,
265
+ data: { messages, count: messages.length, server_time_ms: Date.now() },
266
+ };
267
+ }
268
+
269
+ case 'context': {
270
+ const agentId = resolveAgentId(client, payload);
271
+ const messages = buildReplayMessages(agentId, payload);
272
+ if (client) touchClient(client);
273
+ return {
274
+ ok: true,
275
+ data: { messages, count: messages.length, server_time_ms: Date.now() },
276
+ };
277
+ }
278
+
279
+ case 'status': {
280
+ const scope = payload.scope || 'hub';
281
+ if (client) touchClient(client);
282
+ return router.getStatus(scope, payload);
283
+ }
284
+
285
+ default:
286
+ return {
287
+ ok: false,
288
+ error: { code: 'UNKNOWN_PIPE_QUERY', message: `지원하지 않는 query: ${action}` },
289
+ };
290
+ }
291
+ }
292
+
293
+ function onMessage(agentId, message) {
294
+ if (!agentId || !message) return;
295
+ if (router.markMessagePushed(agentId, message.id)) {
296
+ pushEvent(agentId, message);
297
+ return;
298
+ }
299
+ pushEvent(agentId, message);
300
+ }
301
+
302
+ async function handleFrame(client, frame) {
303
+ if (!frame || typeof frame !== 'object') {
304
+ return sendResponse(client, null, {
305
+ ok: false,
306
+ error: { code: 'INVALID_FRAME', message: 'JSON object frame required' },
307
+ });
308
+ }
309
+
310
+ if (!frame.type) {
311
+ return sendResponse(client, frame.request_id || null, {
312
+ ok: false,
313
+ error: { code: 'INVALID_FRAME', message: 'type required' },
314
+ });
315
+ }
316
+
317
+ touchClient(client);
318
+
319
+ try {
320
+ if (frame.type === 'command') {
321
+ const action = frame.payload?.action || frame.payload?.command;
322
+ const result = await processCommand(client, action, frame.payload || {});
323
+ return sendResponse(client, frame.payload?.request_id || frame.request_id || null, result);
324
+ }
325
+ if (frame.type === 'query') {
326
+ const action = frame.payload?.action || frame.payload?.query;
327
+ const result = await processQuery(client, action, frame.payload || {});
328
+ return sendResponse(client, frame.payload?.request_id || frame.request_id || null, result);
329
+ }
330
+ return sendResponse(client, frame.request_id || null, {
331
+ ok: false,
332
+ error: { code: 'INVALID_FRAME_TYPE', message: `지원하지 않는 type: ${frame.type}` },
333
+ });
334
+ } catch (error) {
335
+ return sendResponse(client, frame.request_id || null, {
336
+ ok: false,
337
+ error: { code: 'PIPE_REQUEST_FAILED', message: error.message },
338
+ });
339
+ }
340
+ }
341
+
342
+ function attachSocket(socket) {
343
+ const client = {
344
+ id: randomUUID(),
345
+ socket,
346
+ buffer: '',
347
+ agentId: null,
348
+ subscriptions: new Set(),
349
+ lastHeartbeatMs: Date.now(),
350
+ closed: false,
351
+ };
352
+ clients.set(client.id, client);
353
+
354
+ socket.setEncoding('utf8');
355
+ socket.on('data', async (chunk) => {
356
+ client.buffer += chunk;
357
+ let newlineIndex = client.buffer.indexOf('\n');
358
+ while (newlineIndex >= 0) {
359
+ const line = client.buffer.slice(0, newlineIndex).trim();
360
+ client.buffer = client.buffer.slice(newlineIndex + 1);
361
+ if (line) {
362
+ const frame = safeJsonParse(line);
363
+ await handleFrame(client, frame);
364
+ }
365
+ newlineIndex = client.buffer.indexOf('\n');
366
+ }
367
+ });
368
+
369
+ socket.on('close', () => closeClient(client));
370
+ socket.on('error', () => closeClient(client));
371
+ }
372
+
373
+ function startHeartbeatMonitor() {
374
+ heartbeatTimer = setInterval(() => {
375
+ const now = Date.now();
376
+ for (const client of clients.values()) {
377
+ if (now - client.lastHeartbeatMs <= heartbeatTtlMs) continue;
378
+ sendFrame(client, {
379
+ type: 'event',
380
+ event: 'disconnect',
381
+ payload: { reason: 'heartbeat_timeout' },
382
+ });
383
+ closeClient(client);
384
+ }
385
+ }, Math.max(1000, Math.floor(heartbeatTtlMs / 2)));
386
+ heartbeatTimer.unref();
387
+ }
388
+
389
+ return {
390
+ path: pipePath,
391
+
392
+ async start() {
393
+ if (server) return { path: pipePath };
394
+
395
+ if (process.platform !== 'win32' && existsSync(pipePath)) {
396
+ try { unlinkSync(pipePath); } catch {}
397
+ }
398
+
399
+ server = net.createServer(attachSocket);
400
+ router.deliveryEmitter.on('message', onMessage);
401
+
402
+ await new Promise((resolve, reject) => {
403
+ server.once('error', reject);
404
+ server.listen(pipePath, () => {
405
+ server.off('error', reject);
406
+ resolve();
407
+ });
408
+ });
409
+
410
+ startHeartbeatMonitor();
411
+ return { path: pipePath };
412
+ },
413
+
414
+ async stop() {
415
+ if (heartbeatTimer) {
416
+ clearInterval(heartbeatTimer);
417
+ heartbeatTimer = null;
418
+ }
419
+
420
+ router.deliveryEmitter.off('message', onMessage);
421
+
422
+ for (const client of clients.values()) {
423
+ closeClient(client);
424
+ }
425
+
426
+ if (server) {
427
+ const current = server;
428
+ server = null;
429
+ await new Promise((resolve) => current.close(resolve));
430
+ }
431
+
432
+ if (process.platform !== 'win32' && existsSync(pipePath)) {
433
+ try { unlinkSync(pipePath); } catch {}
434
+ }
435
+ },
436
+
437
+ getStatus() {
438
+ return {
439
+ path: pipePath,
440
+ protocol: 'ndjson',
441
+ clients: clients.size,
442
+ pending_messages: Array.from(clients.values()).reduce((sum, client) => {
443
+ if (!client.agentId) return sum;
444
+ return sum + router.getPendingMessages(client.agentId, { max_messages: 1000 }).length;
445
+ }, 0),
446
+ };
447
+ },
448
+
449
+ async executeCommand(action, payload) {
450
+ return await processCommand(null, action, payload);
451
+ },
452
+
453
+ async executeQuery(action, payload) {
454
+ return await processQuery(null, action, payload);
455
+ },
456
+ };
457
+ }
@@ -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';