triflux 3.2.0-dev.11 → 3.2.0-dev.13

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
@@ -12,6 +12,16 @@ import { parseArgs as nodeParseArgs } from 'node:util';
12
12
  import { randomUUID } from 'node:crypto';
13
13
 
14
14
  const HUB_PID_FILE = join(homedir(), '.claude', 'cache', 'tfx-hub', 'hub.pid');
15
+ const HUB_TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
16
+
17
+ // Hub 인증 토큰 읽기 (파일 없으면 null → 하위 호환)
18
+ function readHubToken() {
19
+ try {
20
+ return readFileSync(HUB_TOKEN_FILE, 'utf8').trim();
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
15
25
 
16
26
  export function getHubUrl() {
17
27
  if (process.env.TFX_HUB_URL) return process.env.TFX_HUB_URL.replace(/\/mcp$/, '');
@@ -46,9 +56,15 @@ export async function post(path, body, timeoutMs = 5000) {
46
56
  const timer = setTimeout(() => controller.abort(), timeoutMs);
47
57
 
48
58
  try {
59
+ const headers = { 'Content-Type': 'application/json' };
60
+ const token = readHubToken();
61
+ if (token) {
62
+ headers['Authorization'] = `Bearer ${token}`;
63
+ }
64
+
49
65
  const res = await fetch(`${getHubUrl()}${path}`, {
50
66
  method: 'POST',
51
- headers: { 'Content-Type': 'application/json' },
67
+ headers,
52
68
  body: JSON.stringify(body),
53
69
  signal: controller.signal,
54
70
  });
@@ -304,27 +320,41 @@ async function cmdDeregister(args) {
304
320
  }
305
321
 
306
322
  async function cmdTeamInfo(args) {
307
- const result = await post('/bridge/team/info', {
323
+ const body = {
308
324
  team_name: args.team,
309
325
  include_members: true,
310
326
  include_paths: true,
311
- });
312
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
327
+ };
328
+ const result = await post('/bridge/team/info', body);
329
+ if (result) {
330
+ console.log(JSON.stringify(result));
331
+ return;
332
+ }
333
+ // Hub 미실행 fallback — nativeProxy 직접 호출
334
+ const { teamInfo } = await import('./team/nativeProxy.mjs');
335
+ console.log(JSON.stringify(await teamInfo(body)));
313
336
  }
314
337
 
315
338
  async function cmdTeamTaskList(args) {
316
- const result = await post('/bridge/team/task-list', {
339
+ const body = {
317
340
  team_name: args.team,
318
341
  owner: args.owner,
319
342
  statuses: args.statuses ? args.statuses.split(',').map((status) => status.trim()).filter(Boolean) : [],
320
343
  include_internal: !!args['include-internal'],
321
344
  limit: parseInt(args.limit || '200', 10),
322
- });
323
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
345
+ };
346
+ const result = await post('/bridge/team/task-list', body);
347
+ if (result) {
348
+ console.log(JSON.stringify(result));
349
+ return;
350
+ }
351
+ // Hub 미실행 fallback — nativeProxy 직접 호출
352
+ const { teamTaskList } = await import('./team/nativeProxy.mjs');
353
+ console.log(JSON.stringify(await teamTaskList(body)));
324
354
  }
325
355
 
326
356
  async function cmdTeamTaskUpdate(args) {
327
- const result = await post('/bridge/team/task-update', {
357
+ const body = {
328
358
  team_name: args.team,
329
359
  task_id: args['task-id'],
330
360
  claim: !!args.claim,
@@ -338,20 +368,95 @@ async function cmdTeamTaskUpdate(args) {
338
368
  metadata_patch: args['metadata-patch'] ? parseJsonSafe(args['metadata-patch'], null) : undefined,
339
369
  if_match_mtime_ms: args['if-match-mtime-ms'] != null ? Number(args['if-match-mtime-ms']) : undefined,
340
370
  actor: args.actor,
341
- });
342
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
371
+ };
372
+ const result = await post('/bridge/team/task-update', body);
373
+ if (result) {
374
+ console.log(JSON.stringify(result));
375
+ return;
376
+ }
377
+ // Hub 미실행 fallback — nativeProxy 직접 호출
378
+ const { teamTaskUpdate } = await import('./team/nativeProxy.mjs');
379
+ console.log(JSON.stringify(await teamTaskUpdate(body)));
343
380
  }
344
381
 
345
382
  async function cmdTeamSendMessage(args) {
346
- const result = await post('/bridge/team/send-message', {
383
+ const body = {
347
384
  team_name: args.team,
348
385
  from: args.from,
349
386
  to: args.to || 'team-lead',
350
387
  text: args.text,
351
388
  summary: args.summary,
352
389
  color: args.color || 'blue',
390
+ };
391
+ const result = await post('/bridge/team/send-message', body);
392
+ if (result) {
393
+ console.log(JSON.stringify(result));
394
+ return;
395
+ }
396
+ // Hub 미실행 fallback — nativeProxy 직접 호출
397
+ const { teamSendMessage } = await import('./team/nativeProxy.mjs');
398
+ console.log(JSON.stringify(await teamSendMessage(body)));
399
+ }
400
+
401
+ function getHubDbPath() {
402
+ return join(homedir(), '.claude', 'cache', 'tfx-hub', 'state.db');
403
+ }
404
+
405
+ async function cmdPipelineState(args) {
406
+ // HTTP 우선
407
+ const result = await post('/bridge/pipeline/state', { team_name: args.team });
408
+ if (result) {
409
+ console.log(JSON.stringify(result));
410
+ return;
411
+ }
412
+ // Hub 미실행 fallback — 직접 SQLite 접근
413
+ try {
414
+ const { default: Database } = await import('better-sqlite3');
415
+ const { ensurePipelineTable, readPipelineState } = await import('./pipeline/state.mjs');
416
+ const dbPath = getHubDbPath();
417
+ if (!existsSync(dbPath)) {
418
+ console.log(JSON.stringify({ ok: false, error: 'hub_db_not_found' }));
419
+ return;
420
+ }
421
+ const db = new Database(dbPath, { readonly: true });
422
+ ensurePipelineTable(db);
423
+ const state = readPipelineState(db, args.team);
424
+ db.close();
425
+ console.log(JSON.stringify(state
426
+ ? { ok: true, data: state }
427
+ : { ok: false, error: 'pipeline_not_found' }));
428
+ } catch (e) {
429
+ console.log(JSON.stringify({ ok: false, error: e.message }));
430
+ }
431
+ }
432
+
433
+ async function cmdPipelineAdvance(args) {
434
+ // HTTP 우선
435
+ const result = await post('/bridge/pipeline/advance', {
436
+ team_name: args.team,
437
+ phase: args.status, // --status를 phase로 재활용
353
438
  });
354
- console.log(JSON.stringify(result || { ok: false, reason: 'hub_unavailable' }));
439
+ if (result) {
440
+ console.log(JSON.stringify(result));
441
+ return;
442
+ }
443
+ // Hub 미실행 fallback — 직접 SQLite 접근
444
+ try {
445
+ const { default: Database } = await import('better-sqlite3');
446
+ const { createPipeline } = await import('./pipeline/index.mjs');
447
+ const dbPath = getHubDbPath();
448
+ if (!existsSync(dbPath)) {
449
+ console.log(JSON.stringify({ ok: false, error: 'hub_db_not_found' }));
450
+ return;
451
+ }
452
+ const db = new Database(dbPath);
453
+ const pipeline = createPipeline(db, args.team);
454
+ const advanceResult = pipeline.advance(args.status);
455
+ db.close();
456
+ console.log(JSON.stringify(advanceResult));
457
+ } catch (e) {
458
+ console.log(JSON.stringify({ ok: false, error: e.message }));
459
+ }
355
460
  }
356
461
 
357
462
  async function cmdPing() {
@@ -397,9 +502,11 @@ export async function main(argv = process.argv.slice(2)) {
397
502
  case 'team-task-list': await cmdTeamTaskList(args); break;
398
503
  case 'team-task-update': await cmdTeamTaskUpdate(args); break;
399
504
  case 'team-send-message': await cmdTeamSendMessage(args); break;
505
+ case 'pipeline-state': await cmdPipelineState(args); break;
506
+ case 'pipeline-advance': await cmdPipelineAdvance(args); break;
400
507
  case 'ping': await cmdPing(args); break;
401
508
  default:
402
- console.error('사용법: bridge.mjs <register|result|context|deregister|team-info|team-task-list|team-task-update|team-send-message|ping> [--옵션]');
509
+ 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
510
  process.exit(1);
404
511
  }
405
512
  }
@@ -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
+ );