triflux 10.10.0 → 10.11.0

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 CHANGED
@@ -882,7 +882,11 @@ function getClaudeRoutingSyncSummary(results) {
882
882
  (summary, result) => ({
883
883
  changed:
884
884
  summary.changed +
885
- (result.action === "created" || result.action === "updated" ? 1 : 0),
885
+ (result.action === "created" ||
886
+ result.action === "updated" ||
887
+ result.action === "removed"
888
+ ? 1
889
+ : 0),
886
890
  skipped: summary.skipped + (result.skipped ? 1 : 0),
887
891
  }),
888
892
  { changed: 0, skipped: 0 },
package/hub/bridge.mjs CHANGED
@@ -21,6 +21,12 @@ import { fileURLToPath } from "node:url";
21
21
  import { parseArgs as nodeParseArgs } from "node:util";
22
22
 
23
23
  import { getPipelineStateDbPath } from "./pipeline/state.mjs";
24
+ import {
25
+ createRetryStateMachine,
26
+ DEFAULT_ESCALATION_CHAIN,
27
+ loadSnapshot,
28
+ saveSnapshot,
29
+ } from "./team/retry-state-machine.mjs";
24
30
 
25
31
  const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
26
32
  const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
@@ -409,6 +415,10 @@ export function parseArgs(argv) {
409
415
  done: { type: "boolean" },
410
416
  "mcp-profile": { type: "string" },
411
417
  "session-key": { type: "string" },
418
+ snapshot: { type: "string" },
419
+ "snapshot-file": { type: "string" },
420
+ event: { type: "string" },
421
+ "max-iterations": { type: "string" },
412
422
  },
413
423
  allowPositionals: true,
414
424
  strict: false,
@@ -1079,6 +1089,124 @@ async function cmdHitlPending() {
1079
1089
  return emitJson(outcome?.result || unavailableResult());
1080
1090
  }
1081
1091
 
1092
+ // ---------------------------------------------------------------------------
1093
+ // retry-run / retry-status — Phase 3 Step C2 bridge 서브커맨드.
1094
+ // retry-state-machine.mjs 를 multi-process safe 하게 외부 호출용 wrap.
1095
+ // 사용자 워크플로우:
1096
+ // 1) 첫 호출: retry-run --snapshot X --mode ralph --event start
1097
+ // → 새 SM 생성, PLANNING → EXECUTING transition, snapshot 저장
1098
+ // 2) verify 성공 시: retry-run --snapshot X --event verify-success
1099
+ // → DONE, 종료 판단 반환
1100
+ // 3) verify 실패 시: retry-run --snapshot X --event verify-fail --reason R
1101
+ // → DIAGNOSING 또는 STUCK/BUDGET_EXCEEDED, 종료 판단 반환
1102
+ // 4) 다음 iter 시작: retry-run --snapshot X --event start
1103
+ // 출력: {ok, current, iterations, done, shouldStop, reason?, cli?} JSON.
1104
+ // ---------------------------------------------------------------------------
1105
+
1106
+ function buildRetrySmFromArgs(args, snapshot) {
1107
+ const mode = args.mode || snapshot?.mode || "bounded";
1108
+ const maxIterations =
1109
+ args["max-iterations"] !== undefined
1110
+ ? Number(args["max-iterations"])
1111
+ : snapshot?.maxIterations;
1112
+ const sessionId = args["session-id"] || snapshot?.sessionId || null;
1113
+ const cliChain = snapshot?.cliChain;
1114
+
1115
+ const sm = createRetryStateMachine({
1116
+ mode,
1117
+ maxIterations,
1118
+ sessionId,
1119
+ cliChain,
1120
+ });
1121
+ if (snapshot) sm.applySnapshot(snapshot);
1122
+ return sm;
1123
+ }
1124
+
1125
+ async function cmdRetryRun(args) {
1126
+ const snapshotFile = args.snapshot || args["snapshot-file"];
1127
+ const event = args.event;
1128
+ const reason = args.reason || "";
1129
+
1130
+ if (!snapshotFile) {
1131
+ console.error("--snapshot <path> required");
1132
+ return false;
1133
+ }
1134
+ if (!event) {
1135
+ console.error("--event <start|verify-success|verify-fail> required");
1136
+ return false;
1137
+ }
1138
+
1139
+ const existing = loadSnapshot(snapshotFile);
1140
+ const sm = buildRetrySmFromArgs(args, existing);
1141
+
1142
+ let result;
1143
+ switch (event) {
1144
+ case "start":
1145
+ result = sm.startIteration();
1146
+ break;
1147
+ case "verify-success":
1148
+ result = sm.reportVerifySuccess();
1149
+ break;
1150
+ case "verify-fail":
1151
+ result = sm.reportVerifyFail(reason || "unspecified");
1152
+ break;
1153
+ default:
1154
+ console.error(`unknown --event: ${event}`);
1155
+ return false;
1156
+ }
1157
+
1158
+ const snap = sm.serialize();
1159
+ saveSnapshot(snapshotFile, snap);
1160
+
1161
+ const terminal = ["DONE", "STUCK", "BUDGET_EXCEEDED"].includes(snap.current);
1162
+ const cli = snap.cliChain?.[snap.cliIndex] || null;
1163
+ const out = {
1164
+ ok: true,
1165
+ current: snap.current,
1166
+ iterations: snap.iterations,
1167
+ cliIndex: snap.cliIndex,
1168
+ cli,
1169
+ done: snap.current === "DONE",
1170
+ shouldStop: terminal,
1171
+ stuckCounter: snap.stuckCounter,
1172
+ lastFailureReason: snap.lastFailureReason,
1173
+ transition: result,
1174
+ };
1175
+ console.log(JSON.stringify(out));
1176
+ return true;
1177
+ }
1178
+
1179
+ async function cmdRetryStatus(args) {
1180
+ const snapshotFile = args.snapshot || args["snapshot-file"];
1181
+ if (!snapshotFile) {
1182
+ console.error("--snapshot <path> required");
1183
+ return false;
1184
+ }
1185
+ const snap = loadSnapshot(snapshotFile);
1186
+ if (!snap) {
1187
+ console.log(JSON.stringify({ ok: true, exists: false }));
1188
+ return true;
1189
+ }
1190
+ const terminal = ["DONE", "STUCK", "BUDGET_EXCEEDED"].includes(snap.current);
1191
+ const cli = snap.cliChain?.[snap.cliIndex] || null;
1192
+ console.log(
1193
+ JSON.stringify({
1194
+ ok: true,
1195
+ exists: true,
1196
+ current: snap.current,
1197
+ iterations: snap.iterations,
1198
+ maxIterations: snap.maxIterations,
1199
+ cliIndex: snap.cliIndex,
1200
+ cli,
1201
+ mode: snap.mode,
1202
+ shouldStop: terminal,
1203
+ stuckCounter: snap.stuckCounter,
1204
+ lastFailureReason: snap.lastFailureReason,
1205
+ }),
1206
+ );
1207
+ return true;
1208
+ }
1209
+
1082
1210
  export async function main(argv = process.argv.slice(2)) {
1083
1211
  const cmd = argv[0];
1084
1212
  const args = parseArgs(argv.slice(1));
@@ -1138,9 +1266,13 @@ export async function main(argv = process.argv.slice(2)) {
1138
1266
  return await cmdHitlSubmit(args);
1139
1267
  case "hitl-pending":
1140
1268
  return await cmdHitlPending(args);
1269
+ case "retry-run":
1270
+ return await cmdRetryRun(args);
1271
+ case "retry-status":
1272
+ return await cmdRetryStatus(args);
1141
1273
  default:
1142
1274
  console.error(
1143
- "사용법: bridge.mjs <register|result|control|handoff|publish|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending> [--옵션]",
1275
+ "사용법: bridge.mjs <register|result|control|handoff|publish|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|retry-run|retry-status> [--옵션]",
1144
1276
  );
1145
1277
  process.exit(1);
1146
1278
  }
@@ -0,0 +1,222 @@
1
+ // hub/lib/tfx-route-args.mjs
2
+ // Phase 3 Step B — tfx-auto / tfx-route 플래그 파서.
3
+ // 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
4
+ //
5
+ // 입력: ARGUMENTS 문자열 또는 토큰 배열.
6
+ // 출력: {cli, mode, parallel, retry, isolation, remote, lead, noClaudeNative,
7
+ // maxIterations, task, warnings}.
8
+ //
9
+ // 기존 플래그 (Phase 2 v10.9.33+):
10
+ // --cli {auto|codex|gemini|claude}
11
+ // --mode {quick|deep|consensus}
12
+ // --parallel {1|N|swarm}
13
+ // --retry {0|1|ralph|auto-escalate} (Phase 3 에서 ralph/auto-escalate 신규)
14
+ // --isolation {none|worktree}
15
+ // --remote {none|<host>}
16
+ //
17
+ // Phase 3 신규:
18
+ // --lead {claude|codex} (tfx-auto-codex 의미 흡수)
19
+ // --no-claude-native (Claude native sub-agent 경로 disable)
20
+ // --max-iterations <N> (ralph / auto-escalate 상한, 0=unlimited)
21
+
22
+ export const DEFAULT_OPTIONS = Object.freeze({
23
+ cli: "auto",
24
+ mode: "quick",
25
+ parallel: "1",
26
+ retry: "1",
27
+ isolation: "none",
28
+ remote: "none",
29
+ lead: "claude",
30
+ noClaudeNative: false,
31
+ maxIterations: 0,
32
+ });
33
+
34
+ const VALID_VALUES = Object.freeze({
35
+ cli: ["auto", "codex", "gemini", "claude"],
36
+ mode: ["quick", "deep", "consensus"],
37
+ retry: ["0", "1", "ralph", "auto-escalate"],
38
+ isolation: ["none", "worktree"],
39
+ lead: ["claude", "codex"],
40
+ });
41
+
42
+ const VALUE_FLAGS = new Set([
43
+ "--cli",
44
+ "--mode",
45
+ "--parallel",
46
+ "--retry",
47
+ "--isolation",
48
+ "--remote",
49
+ "--lead",
50
+ "--max-iterations",
51
+ ]);
52
+
53
+ const BOOL_FLAGS = new Set(["--no-claude-native"]);
54
+
55
+ export function parseArgs(input) {
56
+ const tokens = Array.isArray(input)
57
+ ? input.slice()
58
+ : tokenize(String(input || ""));
59
+ const opts = { ...DEFAULT_OPTIONS };
60
+ const warnings = [];
61
+ const taskTokens = [];
62
+
63
+ let i = 0;
64
+ while (i < tokens.length) {
65
+ const raw = tokens[i];
66
+
67
+ if (BOOL_FLAGS.has(raw)) {
68
+ applyBool(opts, raw);
69
+ i += 1;
70
+ continue;
71
+ }
72
+
73
+ // --flag=value 지원
74
+ const eqIdx = raw.indexOf("=");
75
+ let flag = raw;
76
+ let value = null;
77
+ if (eqIdx > 0 && raw.startsWith("--")) {
78
+ flag = raw.slice(0, eqIdx);
79
+ value = raw.slice(eqIdx + 1);
80
+ }
81
+
82
+ if (VALUE_FLAGS.has(flag)) {
83
+ if (value === null) {
84
+ const next = tokens[i + 1];
85
+ if (next === undefined || next.startsWith("--")) {
86
+ warnings.push(`${flag} needs a value`);
87
+ i += 1;
88
+ continue;
89
+ }
90
+ value = next;
91
+ i += 2;
92
+ } else {
93
+ i += 1;
94
+ }
95
+ applyValue(opts, flag, value, warnings);
96
+ continue;
97
+ }
98
+
99
+ if (raw.startsWith("--")) {
100
+ warnings.push(`unknown flag: ${raw}`);
101
+ i += 1;
102
+ continue;
103
+ }
104
+
105
+ taskTokens.push(raw);
106
+ i += 1;
107
+ }
108
+
109
+ validate(opts, warnings);
110
+
111
+ return {
112
+ ...opts,
113
+ task: taskTokens.join(" ").trim(),
114
+ warnings,
115
+ };
116
+ }
117
+
118
+ function tokenize(str) {
119
+ const tokens = [];
120
+ let cur = "";
121
+ let quote = null;
122
+ for (const ch of str) {
123
+ if (quote) {
124
+ if (ch === quote) {
125
+ quote = null;
126
+ } else {
127
+ cur += ch;
128
+ }
129
+ continue;
130
+ }
131
+ if (ch === '"' || ch === "'") {
132
+ quote = ch;
133
+ continue;
134
+ }
135
+ if (/\s/.test(ch)) {
136
+ if (cur) {
137
+ tokens.push(cur);
138
+ cur = "";
139
+ }
140
+ continue;
141
+ }
142
+ cur += ch;
143
+ }
144
+ if (cur) tokens.push(cur);
145
+ return tokens;
146
+ }
147
+
148
+ function applyBool(opts, flag) {
149
+ switch (flag) {
150
+ case "--no-claude-native":
151
+ opts.noClaudeNative = true;
152
+ break;
153
+ }
154
+ }
155
+
156
+ function applyValue(opts, flag, value, warnings) {
157
+ switch (flag) {
158
+ case "--cli":
159
+ opts.cli = value;
160
+ break;
161
+ case "--mode":
162
+ opts.mode = value;
163
+ break;
164
+ case "--parallel":
165
+ opts.parallel = value;
166
+ break;
167
+ case "--retry":
168
+ opts.retry = value;
169
+ break;
170
+ case "--isolation":
171
+ opts.isolation = value;
172
+ break;
173
+ case "--remote":
174
+ opts.remote = value;
175
+ break;
176
+ case "--lead":
177
+ opts.lead = value;
178
+ break;
179
+ case "--max-iterations": {
180
+ const n = Number.parseInt(value, 10);
181
+ if (Number.isNaN(n) || n < 0) {
182
+ warnings.push(
183
+ `invalid --max-iterations=${value}, expected non-negative integer (0=unlimited)`,
184
+ );
185
+ } else {
186
+ opts.maxIterations = n;
187
+ }
188
+ break;
189
+ }
190
+ }
191
+ }
192
+
193
+ function validate(opts, warnings) {
194
+ for (const key of Object.keys(VALID_VALUES)) {
195
+ if (!VALID_VALUES[key].includes(opts[key])) {
196
+ warnings.push(
197
+ `invalid --${key}=${opts[key]}, expected one of ${VALID_VALUES[key].join("|")}`,
198
+ );
199
+ }
200
+ }
201
+ if (
202
+ opts.parallel !== "1" &&
203
+ opts.parallel !== "swarm" &&
204
+ !/^\d+$/.test(opts.parallel)
205
+ ) {
206
+ warnings.push(`invalid --parallel=${opts.parallel}, expected 1|N|swarm`);
207
+ }
208
+ const parallelOne = opts.parallel === "1" || opts.parallel === 1;
209
+ if (parallelOne && opts.isolation === "worktree") {
210
+ warnings.push(
211
+ "--isolation worktree requires --parallel >=2 or swarm; forcing isolation=none",
212
+ );
213
+ opts.isolation = "none";
214
+ }
215
+ if (opts.remote !== "none" && opts.parallel !== "swarm") {
216
+ warnings.push(
217
+ `--remote ${opts.remote} ignored (requires --parallel swarm)`,
218
+ );
219
+ }
220
+ }
221
+
222
+ export { VALID_VALUES };
package/hub/server.mjs CHANGED
@@ -1057,7 +1057,7 @@ export async function startHub({
1057
1057
  if (path === "/synapse/heartbeat" && req.method === "POST") {
1058
1058
  try {
1059
1059
  const body = await parseBody(req);
1060
- const { sessionId, ...partial } = body || {};
1060
+ const { sessionId, partial } = body || {};
1061
1061
  const ok = synapseRegistry.heartbeat(sessionId, partial);
1062
1062
  if (!ok) {
1063
1063
  throw new Error("heartbeat failed");
@@ -80,12 +80,12 @@ export function createGitPreflight(opts = {}) {
80
80
  }
81
81
 
82
82
  function otherActiveSessions(active, sessionId) {
83
- return active.filter((s) => s && s.sessionId && s.sessionId !== sessionId);
83
+ return active.filter((s) => s?.sessionId && s.sessionId !== sessionId);
84
84
  }
85
85
 
86
86
  function otherLeases(snapshot, workerId) {
87
87
  return snapshot.filter(
88
- (entry) => entry && entry.workerId && entry.workerId !== workerId,
88
+ (entry) => entry?.workerId && entry.workerId !== workerId,
89
89
  );
90
90
  }
91
91
 
@@ -0,0 +1,260 @@
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
+ const DEFAULT_ESCALATION_CHAIN = Object.freeze([
42
+ Object.freeze({ cli: "codex", model: "gpt-5-mini" }),
43
+ Object.freeze({ cli: "codex", model: "gpt-5" }),
44
+ Object.freeze({ cli: "claude", model: "sonnet-4-6" }),
45
+ Object.freeze({ cli: "claude", model: "opus-4-7" }),
46
+ ]);
47
+
48
+ const STUCK_THRESHOLD = 3;
49
+
50
+ export function createRetryStateMachine(options = {}) {
51
+ const mode = options.mode || MODES.BOUNDED;
52
+ const cliChain = Array.isArray(options.cliChain)
53
+ ? options.cliChain.slice()
54
+ : DEFAULT_ESCALATION_CHAIN.slice();
55
+ const maxIterationsInput =
56
+ typeof options.maxIterations === "number" ? options.maxIterations : null;
57
+ const maxIterations =
58
+ maxIterationsInput !== null
59
+ ? maxIterationsInput
60
+ : mode === MODES.BOUNDED
61
+ ? 3
62
+ : 0;
63
+ const stateFile = options.stateFile || null;
64
+ const sessionId = options.sessionId || null;
65
+
66
+ const emitter = new EventEmitter();
67
+ const state = {
68
+ current: STATES.PLANNING,
69
+ iterations: 0,
70
+ maxIterations,
71
+ stuckCounter: 0,
72
+ lastFailureReason: null,
73
+ cliIndex: 0,
74
+ cliChain,
75
+ mode,
76
+ sessionId,
77
+ stateFile,
78
+ history: [],
79
+ };
80
+
81
+ function transition(next, meta = {}) {
82
+ const prev = state.current;
83
+ state.current = next;
84
+ const entry = {
85
+ t: Date.now(),
86
+ from: prev,
87
+ to: next,
88
+ iteration: state.iterations,
89
+ cliIndex: state.cliIndex,
90
+ ...meta,
91
+ };
92
+ state.history.push(entry);
93
+ if (stateFile) persistTransition(stateFile, entry);
94
+ emitter.emit("transition", entry);
95
+ return entry;
96
+ }
97
+
98
+ function startIteration() {
99
+ state.iterations += 1;
100
+ return transition(STATES.EXECUTING, { iteration: state.iterations });
101
+ }
102
+
103
+ function reportVerifySuccess() {
104
+ return transition(STATES.DONE);
105
+ }
106
+
107
+ function reportVerifyFail(failureReason) {
108
+ const reason = String(failureReason || "unknown");
109
+ if (reason === state.lastFailureReason) {
110
+ state.stuckCounter += 1;
111
+ } else {
112
+ state.stuckCounter = 1;
113
+ state.lastFailureReason = reason;
114
+ }
115
+
116
+ if (state.stuckCounter >= STUCK_THRESHOLD) {
117
+ return transition(STATES.STUCK, {
118
+ reason,
119
+ stuckCounter: state.stuckCounter,
120
+ });
121
+ }
122
+
123
+ if (state.maxIterations > 0 && state.iterations >= state.maxIterations) {
124
+ if (state.mode === MODES.ESCALATE) {
125
+ return escalate();
126
+ }
127
+ return transition(STATES.BUDGET_EXCEEDED, {
128
+ reason,
129
+ iterations: state.iterations,
130
+ });
131
+ }
132
+
133
+ return transition(STATES.DIAGNOSING, { reason });
134
+ }
135
+
136
+ function escalate() {
137
+ if (state.cliIndex + 1 >= state.cliChain.length) {
138
+ return transition(STATES.BUDGET_EXCEEDED, {
139
+ reason: "escalation-chain-exhausted",
140
+ chain: state.cliChain,
141
+ });
142
+ }
143
+ state.cliIndex += 1;
144
+ state.iterations = 0;
145
+ state.stuckCounter = 0;
146
+ state.lastFailureReason = null;
147
+ return transition(STATES.EXECUTING, {
148
+ cli: state.cliChain[state.cliIndex],
149
+ escalated: true,
150
+ });
151
+ }
152
+
153
+ function getCurrent() {
154
+ return {
155
+ current: state.current,
156
+ iterations: state.iterations,
157
+ maxIterations: state.maxIterations,
158
+ stuckCounter: state.stuckCounter,
159
+ lastFailureReason: state.lastFailureReason,
160
+ cliIndex: state.cliIndex,
161
+ cliChain: state.cliChain.slice(),
162
+ mode: state.mode,
163
+ sessionId: state.sessionId,
164
+ history: state.history.slice(),
165
+ };
166
+ }
167
+
168
+ function on(event, listener) {
169
+ emitter.on(event, listener);
170
+ return () => emitter.off(event, listener);
171
+ }
172
+
173
+ function serialize() {
174
+ return {
175
+ version: 1,
176
+ current: state.current,
177
+ iterations: state.iterations,
178
+ maxIterations: state.maxIterations,
179
+ stuckCounter: state.stuckCounter,
180
+ lastFailureReason: state.lastFailureReason,
181
+ cliIndex: state.cliIndex,
182
+ cliChain: state.cliChain.slice(),
183
+ mode: state.mode,
184
+ sessionId: state.sessionId,
185
+ history: state.history.slice(),
186
+ };
187
+ }
188
+
189
+ function applySnapshot(snapshot) {
190
+ if (!snapshot || typeof snapshot !== "object") return;
191
+ state.current = snapshot.current || STATES.PLANNING;
192
+ state.iterations = Number(snapshot.iterations) || 0;
193
+ state.maxIterations =
194
+ snapshot.maxIterations !== undefined
195
+ ? Number(snapshot.maxIterations)
196
+ : state.maxIterations;
197
+ state.stuckCounter = Number(snapshot.stuckCounter) || 0;
198
+ state.lastFailureReason = snapshot.lastFailureReason || null;
199
+ state.cliIndex = Number(snapshot.cliIndex) || 0;
200
+ if (Array.isArray(snapshot.cliChain) && snapshot.cliChain.length > 0) {
201
+ state.cliChain = snapshot.cliChain.slice();
202
+ }
203
+ if (snapshot.mode) state.mode = snapshot.mode;
204
+ if (snapshot.sessionId !== undefined) state.sessionId = snapshot.sessionId;
205
+ if (Array.isArray(snapshot.history))
206
+ state.history = snapshot.history.slice();
207
+ }
208
+
209
+ return {
210
+ STATES,
211
+ MODES,
212
+ getCurrent,
213
+ startIteration,
214
+ reportVerifySuccess,
215
+ reportVerifyFail,
216
+ escalate,
217
+ on,
218
+ serialize,
219
+ applySnapshot,
220
+ };
221
+ }
222
+
223
+ function persistTransition(stateFile, entry) {
224
+ const dir = dirname(stateFile);
225
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
226
+ appendFileSync(stateFile, `${JSON.stringify(entry)}\n`, "utf8");
227
+ }
228
+
229
+ export function resumeFromStateFile(stateFile) {
230
+ if (!existsSync(stateFile)) return null;
231
+ const raw = readFileSync(stateFile, "utf8").trim();
232
+ if (!raw) return null;
233
+ const lines = raw.split("\n").filter(Boolean);
234
+ if (lines.length === 0) return null;
235
+ return JSON.parse(lines[lines.length - 1]);
236
+ }
237
+
238
+ // Full-snapshot 기반 stateFile I/O — bridge retry-run 에서 multi-process state 복원용.
239
+ // transition event log (resumeFromStateFile) 와 별도 파일로 관리하는 것을 권장.
240
+ export function loadSnapshot(snapshotFile) {
241
+ if (!existsSync(snapshotFile)) return null;
242
+ const raw = readFileSync(snapshotFile, "utf8").trim();
243
+ if (!raw) return null;
244
+ const parsed = JSON.parse(raw);
245
+ if (!parsed || typeof parsed !== "object") return null;
246
+ if (parsed.version !== 1) {
247
+ throw new Error(
248
+ `unsupported snapshot version: ${parsed.version} (expected 1)`,
249
+ );
250
+ }
251
+ return parsed;
252
+ }
253
+
254
+ export function saveSnapshot(snapshotFile, snapshot) {
255
+ const dir = dirname(snapshotFile);
256
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
257
+ writeFileSync(snapshotFile, JSON.stringify(snapshot), "utf8");
258
+ }
259
+
260
+ export { DEFAULT_ESCALATION_CHAIN, STUCK_THRESHOLD };
@@ -3,11 +3,7 @@ import {
3
3
  killPsmuxSession,
4
4
  psmuxSessionExists,
5
5
  } from "./psmux.mjs";
6
- import {
7
- killSession as killTmuxSession,
8
- listSessions,
9
- tmuxExec,
10
- } from "./session.mjs";
6
+ import { tmuxExec } from "./session.mjs";
11
7
 
12
8
  /**
13
9
  * @typedef {object} RuntimeStatus