@triflux/core 10.0.3 → 10.25.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 (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +47 -7
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +279 -33
  7. package/hooks/hook-registry.json +104 -7
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +308 -18
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +832 -291
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -3
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -17
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +171 -39
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +303 -107
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +116 -58
  75. package/hub/research.mjs +34 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +129 -63
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +76 -72
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +393 -249
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1282 -116
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +72 -12
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +36 -11
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
package/hub/state.mjs CHANGED
@@ -1,20 +1,34 @@
1
- import { execSync } from 'node:child_process';
2
- import { mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync, renameSync, existsSync, statSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
-
7
- const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
8
- const PID_FILE_NAME = 'hub.pid';
9
- const LEGACY_STATE_FILE_NAME = 'hub-state.json';
10
- const LOCK_FILE_NAME = 'hub-start.lock';
1
+ import { execSync } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
18
+ const PID_FILE_NAME = "hub.pid";
19
+ const LEGACY_STATE_FILE_NAME = "hub-state.json";
20
+ const LOCK_FILE_NAME = "hub-start.lock";
11
21
 
12
22
  let heldLockPath = null;
13
23
  let heldLockFd = null;
14
24
  let cachedVersionHash = null;
15
25
 
16
26
  function getStateDir(options = {}) {
17
- return options.stateDir || process.env.TFX_HUB_STATE_DIR?.trim() || join(homedir(), '.claude', 'cache', 'tfx-hub');
27
+ return (
28
+ options.stateDir ||
29
+ process.env.TFX_HUB_STATE_DIR?.trim() ||
30
+ join(homedir(), ".claude", "cache", "tfx-hub")
31
+ );
18
32
  }
19
33
 
20
34
  function getStatePath(options = {}) {
@@ -55,25 +69,32 @@ function safeReplaceFile(tempPath, targetPath) {
55
69
  try {
56
70
  renameSync(tempPath, targetPath);
57
71
  } catch (error) {
58
- if (!['EEXIST', 'EPERM', 'EACCES'].includes(error?.code)) {
59
- try { unlinkSync(tempPath); } catch {}
72
+ if (!["EEXIST", "EPERM", "EACCES"].includes(error?.code)) {
73
+ try {
74
+ unlinkSync(tempPath);
75
+ } catch {}
60
76
  throw error;
61
77
  }
62
- try { unlinkSync(targetPath); } catch {}
78
+ try {
79
+ unlinkSync(targetPath);
80
+ } catch {}
63
81
  renameSync(tempPath, targetPath);
64
82
  }
65
83
  }
66
84
 
67
85
  function writeJsonFile(targetPath, payload) {
68
86
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
69
- writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
87
+ writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {
88
+ encoding: "utf8",
89
+ mode: 0o600,
90
+ });
70
91
  safeReplaceFile(tempPath, targetPath);
71
92
  }
72
93
 
73
94
  function readJsonFile(filePath) {
74
95
  try {
75
96
  if (!existsSync(filePath)) return null;
76
- return parseJson(readFileSync(filePath, 'utf8'), null);
97
+ return parseJson(readFileSync(filePath, "utf8"), null);
77
98
  } catch {
78
99
  return null;
79
100
  }
@@ -82,7 +103,7 @@ function readJsonFile(filePath) {
82
103
  /**
83
104
  * 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
84
105
  * 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
85
- *
106
+ *
86
107
  * @param {object} payload - 상태 데이터
87
108
  * @param {number} payload.pid - 허브 프로세스 ID
88
109
  * @param {number} payload.port - 허브 서버 포트
@@ -93,31 +114,39 @@ function readJsonFile(filePath) {
93
114
  * @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
94
115
  * @returns {object} 기록된 상태 데이터
95
116
  */
96
- export function writeState({ pid, port, version, sessionId, startedAt, ...rest }, options = {}) {
117
+ export function writeState(
118
+ { pid, port, version, sessionId, startedAt, ...rest },
119
+ options = {},
120
+ ) {
97
121
  const stateDir = getStateDir(options);
98
122
  const statePath = getStatePath(options);
99
123
  const payload = { pid, port, version, sessionId, startedAt, ...rest };
100
124
 
101
125
  mkdirSync(stateDir, { recursive: true });
102
126
  writeJsonFile(statePath, payload);
103
- try { unlinkSync(getLegacyStatePath(options)); } catch {}
127
+ try {
128
+ unlinkSync(getLegacyStatePath(options));
129
+ } catch {}
104
130
  return payload;
105
131
  }
106
132
 
107
133
  /**
108
134
  * 파일로부터 허브의 현재 상태를 읽어옵니다.
109
- *
135
+ *
110
136
  * @param {object} [options] - 옵션
111
137
  * @param {string} [options.stateDir] - 상태 파일이 저장된 디렉토리
112
138
  * @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
113
139
  */
114
140
  export function readState(options = {}) {
115
- return readJsonFile(getStatePath(options)) ?? readJsonFile(getLegacyStatePath(options));
141
+ return (
142
+ readJsonFile(getStatePath(options)) ??
143
+ readJsonFile(getLegacyStatePath(options))
144
+ );
116
145
  }
117
146
 
118
147
  /**
119
148
  * 지정된 포트에서 실행 중인 허브 서버의 헬스 체크를 수행합니다.
120
- *
149
+ *
121
150
  * @param {number|string} port - 서버 포트
122
151
  * @param {object} [options] - 옵션
123
152
  * @param {number} [options.timeoutMs=1000] - 요청 타임아웃
@@ -133,7 +162,7 @@ export async function isServerHealthy(port, options = {}) {
133
162
 
134
163
  try {
135
164
  const response = await fetch(`${baseUrl}/health`, {
136
- method: 'GET',
165
+ method: "GET",
137
166
  signal: AbortSignal.timeout(timeoutMs),
138
167
  });
139
168
  if (!response.ok) return false;
@@ -147,7 +176,7 @@ export async function isServerHealthy(port, options = {}) {
147
176
  /**
148
177
  * 현재 프로젝트의 버전 해시를 생성합니다.
149
178
  * package.json의 버전과 Git commit SHA를 조합합니다.
150
- *
179
+ *
151
180
  * @param {object} [options] - 옵션
152
181
  * @param {boolean} [options.force=false] - 캐시를 무시하고 새로 생성할지 여부
153
182
  * @returns {string} 버전 해시 문자열
@@ -155,21 +184,21 @@ export async function isServerHealthy(port, options = {}) {
155
184
  export function getVersionHash(options = {}) {
156
185
  if (cachedVersionHash && !options.force) return cachedVersionHash;
157
186
 
158
- const packageJsonPath = join(PROJECT_ROOT, 'package.json');
159
- const pkg = parseJson(readFileSync(packageJsonPath, 'utf8'), {});
160
- const version = String(pkg?.version || '0.0.0').trim();
187
+ const packageJsonPath = join(PROJECT_ROOT, "package.json");
188
+ const pkg = parseJson(readFileSync(packageJsonPath, "utf8"), {});
189
+ const version = String(pkg?.version || "0.0.0").trim();
161
190
 
162
- let sha = String(process.env.TFX_HUB_GIT_SHA || '').trim();
191
+ let sha = String(process.env.TFX_HUB_GIT_SHA || "").trim();
163
192
  if (!sha) {
164
193
  try {
165
- sha = execSync('git rev-parse --short HEAD', {
194
+ sha = execSync("git rev-parse --short HEAD", {
166
195
  cwd: PROJECT_ROOT,
167
- encoding: 'utf8',
168
- stdio: ['ignore', 'pipe', 'ignore'],
196
+ encoding: "utf8",
197
+ stdio: ["ignore", "pipe", "ignore"],
169
198
  windowsHide: true,
170
199
  }).trim();
171
200
  } catch {
172
- sha = '';
201
+ sha = "";
173
202
  }
174
203
  }
175
204
 
@@ -180,7 +209,7 @@ export function getVersionHash(options = {}) {
180
209
  /**
181
210
  * 허브 시작 시 중복 실행을 방지하기 위한 잠금(lock)을 획득합니다.
182
211
  * 이미 실행 중인 다른 프로세스가 있는지 확인하고 유효한 잠금을 획득할 때까지 재시도합니다.
183
- *
212
+ *
184
213
  * @param {object} [options] - 옵션
185
214
  * @param {number} [options.timeoutMs=3000] - 최대 대기 시간
186
215
  * @param {number} [options.pollMs=50] - 재시도 간격
@@ -202,27 +231,38 @@ export async function acquireLock(options = {}) {
202
231
 
203
232
  while (Date.now() <= deadline) {
204
233
  try {
205
- const fd = openSync(lockPath, 'wx', 0o600);
206
- writeFileSync(fd, `${JSON.stringify({
207
- pid: process.pid,
208
- createdAt: new Date().toISOString(),
209
- }, null, 2)}\n`, 'utf8');
234
+ const fd = openSync(lockPath, "wx", 0o600);
235
+ writeFileSync(
236
+ fd,
237
+ `${JSON.stringify(
238
+ {
239
+ pid: process.pid,
240
+ createdAt: new Date().toISOString(),
241
+ },
242
+ null,
243
+ 2,
244
+ )}\n`,
245
+ "utf8",
246
+ );
210
247
  heldLockFd = fd;
211
248
  heldLockPath = lockPath;
212
249
  return { path: lockPath };
213
250
  } catch (error) {
214
- if (error?.code !== 'EEXIST') {
251
+ if (error?.code !== "EEXIST") {
215
252
  throw error;
216
253
  }
217
254
 
218
255
  try {
219
- const raw = readFileSync(lockPath, 'utf8');
256
+ const raw = readFileSync(lockPath, "utf8");
220
257
  const data = parseJson(raw, {});
221
258
  const stats = statSync(lockPath);
259
+ const STALE_LOCK_AGE_MS = 60_000;
222
260
  const staleByPid = !isPidAlive(data?.pid);
223
- const staleByAge = Date.now() - stats.mtimeMs > timeoutMs;
261
+ const staleByAge = Date.now() - stats.mtimeMs > STALE_LOCK_AGE_MS;
224
262
  if (staleByPid || staleByAge) {
225
- try { unlinkSync(lockPath); } catch {}
263
+ try {
264
+ unlinkSync(lockPath);
265
+ } catch {}
226
266
  continue;
227
267
  }
228
268
  } catch {}
@@ -236,7 +276,7 @@ export async function acquireLock(options = {}) {
236
276
 
237
277
  /**
238
278
  * 획득했던 잠금을 해제합니다. 잠금 파일을 삭제하고 관련 리소스를 정리합니다.
239
- *
279
+ *
240
280
  * @param {object} [options] - 옵션
241
281
  * @param {string} [options.lockPath] - 명시적인 잠금 파일 경로
242
282
  */
@@ -244,7 +284,9 @@ export function releaseLock(options = {}) {
244
284
  const lockPath = options.lockPath || heldLockPath || getLockPath(options);
245
285
 
246
286
  if (heldLockFd !== null) {
247
- try { closeSync(heldLockFd); } catch {}
287
+ try {
288
+ closeSync(heldLockFd);
289
+ } catch {}
248
290
  heldLockFd = null;
249
291
  }
250
292
 
@@ -0,0 +1,267 @@
1
+ // hub/team/retry-state-machine.mjs
2
+ // Phase 3 Step A — true ralph / auto-escalate retry state machine.
3
+ // 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
4
+ //
5
+ // Modes:
6
+ // bounded — --retry 1 (기본): maxIterations 도달 시 BUDGET_EXCEEDED.
7
+ // ralph — --retry ralph: maxIterations=0 이면 unlimited. stuck detector 만 종료.
8
+ // auto-escalate — --retry auto-escalate: BUDGET_EXCEEDED 시 다음 CLI 로 전이.
9
+ //
10
+ // 상태 전이:
11
+ // PLANNING → EXECUTING → VERIFYING.success → DONE
12
+ // → VERIFYING.fail → DIAGNOSING → EXECUTING …
13
+ // → stuckCounter≥3 → STUCK
14
+ // → iter≥max → BUDGET_EXCEEDED (escalate 모드는 다음 CLI)
15
+
16
+ import { EventEmitter } from "node:events";
17
+ import {
18
+ appendFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { dirname } from "node:path";
25
+
26
+ export const STATES = Object.freeze({
27
+ PLANNING: "PLANNING",
28
+ EXECUTING: "EXECUTING",
29
+ DIAGNOSING: "DIAGNOSING",
30
+ STUCK: "STUCK",
31
+ BUDGET_EXCEEDED: "BUDGET_EXCEEDED",
32
+ DONE: "DONE",
33
+ });
34
+
35
+ export const MODES = Object.freeze({
36
+ BOUNDED: "bounded",
37
+ RALPH: "ralph",
38
+ ESCALATE: "auto-escalate",
39
+ });
40
+
41
+ // Escalation chain (2026-04-25 정책):
42
+ // 1. gpt-5.4-mini — 비용 최저, fast tier, 단순 task 대부분 해결
43
+ // 2. gpt-5.3-codex — 가성비 중간, code specialized (Plus/free 모두 OK, fast 미지원)
44
+ // 3. gpt-5.5 — top reasoning + 코드 강함, fast tier
45
+ // 4. claude opus-4-7 — 최종 수단
46
+ // sonnet-4-6 단계는 제거: gpt-5.5 가 코드/추론/비용 모두 우위 + 5.3-codex 가성비
47
+ // 단계가 더 적합한 중간 격상.
48
+ const DEFAULT_ESCALATION_CHAIN = Object.freeze([
49
+ Object.freeze({ cli: "codex", model: "gpt-5.4-mini" }),
50
+ Object.freeze({ cli: "codex", model: "gpt-5.3-codex" }),
51
+ Object.freeze({ cli: "codex", model: "gpt-5.5" }),
52
+ Object.freeze({ cli: "claude", model: "opus-4-7" }),
53
+ ]);
54
+
55
+ const STUCK_THRESHOLD = 3;
56
+
57
+ export function createRetryStateMachine(options = {}) {
58
+ const mode = options.mode || MODES.BOUNDED;
59
+ const cliChain = Array.isArray(options.cliChain)
60
+ ? options.cliChain.slice()
61
+ : DEFAULT_ESCALATION_CHAIN.slice();
62
+ const maxIterationsInput =
63
+ typeof options.maxIterations === "number" ? options.maxIterations : null;
64
+ const maxIterations =
65
+ maxIterationsInput !== null
66
+ ? maxIterationsInput
67
+ : mode === MODES.BOUNDED
68
+ ? 3
69
+ : 0;
70
+ const stateFile = options.stateFile || null;
71
+ const sessionId = options.sessionId || null;
72
+
73
+ const emitter = new EventEmitter();
74
+ const state = {
75
+ current: STATES.PLANNING,
76
+ iterations: 0,
77
+ maxIterations,
78
+ stuckCounter: 0,
79
+ lastFailureReason: null,
80
+ cliIndex: 0,
81
+ cliChain,
82
+ mode,
83
+ sessionId,
84
+ stateFile,
85
+ history: [],
86
+ };
87
+
88
+ function transition(next, meta = {}) {
89
+ const prev = state.current;
90
+ state.current = next;
91
+ const entry = {
92
+ t: Date.now(),
93
+ from: prev,
94
+ to: next,
95
+ iteration: state.iterations,
96
+ cliIndex: state.cliIndex,
97
+ ...meta,
98
+ };
99
+ state.history.push(entry);
100
+ if (stateFile) persistTransition(stateFile, entry);
101
+ emitter.emit("transition", entry);
102
+ return entry;
103
+ }
104
+
105
+ function startIteration() {
106
+ state.iterations += 1;
107
+ return transition(STATES.EXECUTING, { iteration: state.iterations });
108
+ }
109
+
110
+ function reportVerifySuccess() {
111
+ return transition(STATES.DONE);
112
+ }
113
+
114
+ function reportVerifyFail(failureReason) {
115
+ const reason = String(failureReason || "unknown");
116
+ if (reason === state.lastFailureReason) {
117
+ state.stuckCounter += 1;
118
+ } else {
119
+ state.stuckCounter = 1;
120
+ state.lastFailureReason = reason;
121
+ }
122
+
123
+ if (state.stuckCounter >= STUCK_THRESHOLD) {
124
+ return transition(STATES.STUCK, {
125
+ reason,
126
+ stuckCounter: state.stuckCounter,
127
+ });
128
+ }
129
+
130
+ if (state.maxIterations > 0 && state.iterations >= state.maxIterations) {
131
+ if (state.mode === MODES.ESCALATE) {
132
+ return escalate();
133
+ }
134
+ return transition(STATES.BUDGET_EXCEEDED, {
135
+ reason,
136
+ iterations: state.iterations,
137
+ });
138
+ }
139
+
140
+ return transition(STATES.DIAGNOSING, { reason });
141
+ }
142
+
143
+ function escalate() {
144
+ if (state.cliIndex + 1 >= state.cliChain.length) {
145
+ return transition(STATES.BUDGET_EXCEEDED, {
146
+ reason: "escalation-chain-exhausted",
147
+ chain: state.cliChain,
148
+ });
149
+ }
150
+ state.cliIndex += 1;
151
+ state.iterations = 0;
152
+ state.stuckCounter = 0;
153
+ state.lastFailureReason = null;
154
+ return transition(STATES.EXECUTING, {
155
+ cli: state.cliChain[state.cliIndex],
156
+ escalated: true,
157
+ });
158
+ }
159
+
160
+ function getCurrent() {
161
+ return {
162
+ current: state.current,
163
+ iterations: state.iterations,
164
+ maxIterations: state.maxIterations,
165
+ stuckCounter: state.stuckCounter,
166
+ lastFailureReason: state.lastFailureReason,
167
+ cliIndex: state.cliIndex,
168
+ cliChain: state.cliChain.slice(),
169
+ mode: state.mode,
170
+ sessionId: state.sessionId,
171
+ history: state.history.slice(),
172
+ };
173
+ }
174
+
175
+ function on(event, listener) {
176
+ emitter.on(event, listener);
177
+ return () => emitter.off(event, listener);
178
+ }
179
+
180
+ function serialize() {
181
+ return {
182
+ version: 1,
183
+ current: state.current,
184
+ iterations: state.iterations,
185
+ maxIterations: state.maxIterations,
186
+ stuckCounter: state.stuckCounter,
187
+ lastFailureReason: state.lastFailureReason,
188
+ cliIndex: state.cliIndex,
189
+ cliChain: state.cliChain.slice(),
190
+ mode: state.mode,
191
+ sessionId: state.sessionId,
192
+ history: state.history.slice(),
193
+ };
194
+ }
195
+
196
+ function applySnapshot(snapshot) {
197
+ if (!snapshot || typeof snapshot !== "object") return;
198
+ state.current = snapshot.current || STATES.PLANNING;
199
+ state.iterations = Number(snapshot.iterations) || 0;
200
+ state.maxIterations =
201
+ snapshot.maxIterations !== undefined
202
+ ? Number(snapshot.maxIterations)
203
+ : state.maxIterations;
204
+ state.stuckCounter = Number(snapshot.stuckCounter) || 0;
205
+ state.lastFailureReason = snapshot.lastFailureReason || null;
206
+ state.cliIndex = Number(snapshot.cliIndex) || 0;
207
+ if (Array.isArray(snapshot.cliChain) && snapshot.cliChain.length > 0) {
208
+ state.cliChain = snapshot.cliChain.slice();
209
+ }
210
+ if (snapshot.mode) state.mode = snapshot.mode;
211
+ if (snapshot.sessionId !== undefined) state.sessionId = snapshot.sessionId;
212
+ if (Array.isArray(snapshot.history))
213
+ state.history = snapshot.history.slice();
214
+ }
215
+
216
+ return {
217
+ STATES,
218
+ MODES,
219
+ getCurrent,
220
+ startIteration,
221
+ reportVerifySuccess,
222
+ reportVerifyFail,
223
+ escalate,
224
+ on,
225
+ serialize,
226
+ applySnapshot,
227
+ };
228
+ }
229
+
230
+ function persistTransition(stateFile, entry) {
231
+ const dir = dirname(stateFile);
232
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
233
+ appendFileSync(stateFile, `${JSON.stringify(entry)}\n`, "utf8");
234
+ }
235
+
236
+ export function resumeFromStateFile(stateFile) {
237
+ if (!existsSync(stateFile)) return null;
238
+ const raw = readFileSync(stateFile, "utf8").trim();
239
+ if (!raw) return null;
240
+ const lines = raw.split("\n").filter(Boolean);
241
+ if (lines.length === 0) return null;
242
+ return JSON.parse(lines[lines.length - 1]);
243
+ }
244
+
245
+ // Full-snapshot 기반 stateFile I/O — bridge retry-run 에서 multi-process state 복원용.
246
+ // transition event log (resumeFromStateFile) 와 별도 파일로 관리하는 것을 권장.
247
+ export function loadSnapshot(snapshotFile) {
248
+ if (!existsSync(snapshotFile)) return null;
249
+ const raw = readFileSync(snapshotFile, "utf8").trim();
250
+ if (!raw) return null;
251
+ const parsed = JSON.parse(raw);
252
+ if (!parsed || typeof parsed !== "object") return null;
253
+ if (parsed.version !== 1) {
254
+ throw new Error(
255
+ `unsupported snapshot version: ${parsed.version} (expected 1)`,
256
+ );
257
+ }
258
+ return parsed;
259
+ }
260
+
261
+ export function saveSnapshot(snapshotFile, snapshot) {
262
+ const dir = dirname(snapshotFile);
263
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
264
+ writeFileSync(snapshotFile, JSON.stringify(snapshot), "utf8");
265
+ }
266
+
267
+ export { DEFAULT_ESCALATION_CHAIN, STUCK_THRESHOLD };