triflux 3.2.0-dev.13 → 3.2.0-dev.14
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 +5 -0
- package/hub/team/native.mjs +29 -4
- package/hub/team/psmux.mjs +185 -0
- package/package.json +1 -1
- package/scripts/preflight-cache.mjs +72 -0
- package/skills/tfx-multi/SKILL.md +16 -2
package/hooks/hooks.json
CHANGED
package/hub/team/native.mjs
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* role/mcp_profile별 tfx-route.sh 기본 timeout (초)
|
|
13
|
+
* analyze/review 프로필이나 설계·분석 역할은 더 긴 timeout을 부여한다.
|
|
14
|
+
* @param {string} role — 워커 역할
|
|
15
|
+
* @param {string} mcpProfile — MCP 프로필
|
|
16
|
+
* @returns {number} timeout(초)
|
|
17
|
+
*/
|
|
18
|
+
function getRouteTimeout(role, mcpProfile) {
|
|
19
|
+
if (mcpProfile === "analyze" || mcpProfile === "review") return 3600;
|
|
20
|
+
if (role === "architect" || role === "analyst") return 3600;
|
|
21
|
+
return 1080; // 기본 18분
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
/**
|
|
12
25
|
* v2.2 슬림 래퍼 프롬프트 생성
|
|
13
26
|
* Agent spawn으로 네비게이션에 등록하되, 실제 작업은 tfx-route.sh가 수행.
|
|
@@ -22,6 +35,7 @@ const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
|
|
|
22
35
|
* @param {string} [opts.agentName] — 워커 표시 이름
|
|
23
36
|
* @param {string} [opts.leadName] — 리드 수신자 이름
|
|
24
37
|
* @param {string} [opts.mcp_profile] — MCP 프로필
|
|
38
|
+
* @param {number} [opts.bashTimeout] — Bash timeout(ms). 미지정 시 role/profile 기반 자동 산출.
|
|
25
39
|
* @returns {string} 슬림 래퍼 프롬프트
|
|
26
40
|
*/
|
|
27
41
|
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
@@ -34,22 +48,33 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
|
34
48
|
leadName = "team-lead",
|
|
35
49
|
mcp_profile = "auto",
|
|
36
50
|
pipelinePhase = "",
|
|
51
|
+
bashTimeout,
|
|
37
52
|
} = opts;
|
|
38
53
|
|
|
54
|
+
// role/profile 기반 timeout 산출 (기본 timeout + 60초 여유, ms 변환)
|
|
55
|
+
const bashTimeoutMs = bashTimeout ?? (getRouteTimeout(role, mcp_profile) + 60) * 1000;
|
|
56
|
+
|
|
39
57
|
// 셸 이스케이프
|
|
40
58
|
const escaped = subtask.replace(/'/g, "'\\''");
|
|
41
59
|
const pipelineHint = pipelinePhase
|
|
42
60
|
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
43
61
|
: '';
|
|
44
62
|
|
|
45
|
-
|
|
63
|
+
const taskIdRef = taskId ? `taskId: "${taskId}"` : "";
|
|
64
|
+
|
|
65
|
+
return `인터럽트 프로토콜:
|
|
66
|
+
1. TaskUpdate(${taskIdRef ? `${taskIdRef}, ` : ""}status: in_progress) — task claim
|
|
67
|
+
2. SendMessage(to: ${leadName}, "작업 시작: ${agentName}") — 시작 보고 (턴 경계 생성)
|
|
68
|
+
3. Bash(command, timeout: ${bashTimeoutMs}) — 아래 명령 1회 실행
|
|
69
|
+
4. 결과 보고 후 반드시 종료${pipelineHint}
|
|
70
|
+
|
|
46
71
|
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
47
72
|
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
48
73
|
|
|
49
|
-
TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}" bash ${ROUTE_SCRIPT} "${role}" '${escaped}' ${mcp_profile}
|
|
74
|
+
Bash(command: 'TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}" bash ${ROUTE_SCRIPT} "${role}" '"'"'${escaped}'"'"' ${mcp_profile}', timeout: ${bashTimeoutMs})
|
|
50
75
|
|
|
51
|
-
성공 → TaskUpdate(status: completed, metadata: {result: "success"}) + SendMessage(to: ${leadName}).
|
|
52
|
-
실패 → TaskUpdate(status: completed, metadata: {result: "failed", error: "에러 요약"}) + SendMessage(to: ${leadName}).
|
|
76
|
+
성공 → TaskUpdate(${taskIdRef ? `${taskIdRef}, ` : ""}status: completed, metadata: {result: "success"}) + SendMessage(to: ${leadName}).
|
|
77
|
+
실패 → TaskUpdate(${taskIdRef ? `${taskIdRef}, ` : ""}status: completed, metadata: {result: "failed", error: "에러 요약"}) + SendMessage(to: ${leadName}).
|
|
53
78
|
|
|
54
79
|
중요: TaskUpdate의 status는 "completed"만 사용. "failed"는 API 미지원.
|
|
55
80
|
실패 여부는 metadata.result로 구분. Bash 실패 시에도 반드시 TaskUpdate + SendMessage 후 종료.`;
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -295,3 +295,188 @@ export function configurePsmuxKeybindings(sessionName, opts = {}) {
|
|
|
295
295
|
);
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
|
+
|
|
299
|
+
// ─── 하이브리드 모드 워커 관리 함수 ───
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* psmux 세션의 새 pane에서 워커 실행
|
|
303
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
304
|
+
* @param {string} workerName - 워커 식별용 pane 타이틀
|
|
305
|
+
* @param {string} cmd - 실행할 커맨드
|
|
306
|
+
* @returns {{ paneId: string, workerName: string }}
|
|
307
|
+
*/
|
|
308
|
+
export function spawnWorker(sessionName, workerName, cmd) {
|
|
309
|
+
if (!hasPsmux()) {
|
|
310
|
+
throw new Error("psmux가 설치되어 있지 않습니다. psmux를 먼저 설치하세요.");
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const paneId = psmuxExec(
|
|
314
|
+
`split-window -t ${quoteArg(sessionName)} -P -F "#{pane_id}" ${quoteArg(cmd)}`
|
|
315
|
+
);
|
|
316
|
+
psmuxExec(`select-pane -t ${quoteArg(paneId)} -T ${quoteArg(workerName)}`);
|
|
317
|
+
return { paneId, workerName };
|
|
318
|
+
} catch (err) {
|
|
319
|
+
throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 워커 pane 실행 상태 확인
|
|
325
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
326
|
+
* @param {string} workerName - 워커 pane 타이틀
|
|
327
|
+
* @returns {{ status: "running"|"exited", exitCode: number|null, paneId: string }}
|
|
328
|
+
*/
|
|
329
|
+
export function getWorkerStatus(sessionName, workerName) {
|
|
330
|
+
if (!hasPsmux()) {
|
|
331
|
+
throw new Error("psmux가 설치되어 있지 않습니다.");
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const output = psmuxExec(
|
|
335
|
+
`list-panes -t ${quoteArg(sessionName)} -F "#{pane_title}\t#{pane_id}\t#{pane_dead}\t#{pane_dead_status}"`
|
|
336
|
+
);
|
|
337
|
+
const lines = output.split("\n").filter(Boolean);
|
|
338
|
+
for (const line of lines) {
|
|
339
|
+
const [title, paneId, dead, deadStatus] = line.split("\t");
|
|
340
|
+
if (title === workerName) {
|
|
341
|
+
const isDead = dead === "1";
|
|
342
|
+
return {
|
|
343
|
+
status: isDead ? "exited" : "running",
|
|
344
|
+
exitCode: isDead ? parseInt(deadStatus, 10) || 0 : null,
|
|
345
|
+
paneId,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
|
|
352
|
+
throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 워커 pane 프로세스 강제 종료
|
|
358
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
359
|
+
* @param {string} workerName - 워커 pane 타이틀
|
|
360
|
+
* @returns {{ killed: boolean }}
|
|
361
|
+
*/
|
|
362
|
+
export function killWorker(sessionName, workerName) {
|
|
363
|
+
if (!hasPsmux()) {
|
|
364
|
+
throw new Error("psmux가 설치되어 있지 않습니다.");
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
// paneId 찾기
|
|
368
|
+
const { paneId } = getWorkerStatus(sessionName, workerName);
|
|
369
|
+
// C-c로 우아한 종료 시도
|
|
370
|
+
try {
|
|
371
|
+
psmuxExec(`send-keys -t ${quoteArg(paneId)} C-c`);
|
|
372
|
+
} catch {
|
|
373
|
+
// send-keys 실패 무시
|
|
374
|
+
}
|
|
375
|
+
// 1초 대기 후 pane 강제 종료
|
|
376
|
+
spawnSync("sleep", ["1"], { stdio: "ignore", windowsHide: true });
|
|
377
|
+
try {
|
|
378
|
+
psmuxExec(`kill-pane -t ${quoteArg(paneId)}`);
|
|
379
|
+
} catch {
|
|
380
|
+
// 이미 종료된 pane — 무시
|
|
381
|
+
}
|
|
382
|
+
return { killed: true };
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (err.message.includes("워커를 찾을 수 없습니다")) {
|
|
385
|
+
return { killed: false };
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 워커 pane 출력 마지막 N줄 캡처
|
|
393
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
394
|
+
* @param {string} workerName - 워커 pane 타이틀
|
|
395
|
+
* @param {number} lines - 캡처할 줄 수 (기본 50)
|
|
396
|
+
* @returns {string} 캡처된 출력
|
|
397
|
+
*/
|
|
398
|
+
export function captureWorkerOutput(sessionName, workerName, lines = 50) {
|
|
399
|
+
if (!hasPsmux()) {
|
|
400
|
+
throw new Error("psmux가 설치되어 있지 않습니다.");
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const { paneId } = getWorkerStatus(sessionName, workerName);
|
|
404
|
+
return psmuxExec(`capture-pane -t ${quoteArg(paneId)} -p -S -${lines}`);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
|
|
407
|
+
throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ─── CLI 진입점 ───
|
|
412
|
+
|
|
413
|
+
if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
|
|
414
|
+
const [,, cmd, ...args] = process.argv;
|
|
415
|
+
|
|
416
|
+
// CLI 인자 파싱 헬퍼
|
|
417
|
+
function getArg(name) {
|
|
418
|
+
const idx = args.indexOf(`--${name}`);
|
|
419
|
+
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
switch (cmd) {
|
|
424
|
+
case "spawn": {
|
|
425
|
+
const session = getArg("session");
|
|
426
|
+
const name = getArg("name");
|
|
427
|
+
const workerCmd = getArg("cmd");
|
|
428
|
+
if (!session || !name || !workerCmd) {
|
|
429
|
+
console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
const result = spawnWorker(session, name, workerCmd);
|
|
433
|
+
console.log(JSON.stringify(result, null, 2));
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case "status": {
|
|
437
|
+
const session = getArg("session");
|
|
438
|
+
const name = getArg("name");
|
|
439
|
+
if (!session || !name) {
|
|
440
|
+
console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
const result = getWorkerStatus(session, name);
|
|
444
|
+
console.log(JSON.stringify(result, null, 2));
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case "kill": {
|
|
448
|
+
const session = getArg("session");
|
|
449
|
+
const name = getArg("name");
|
|
450
|
+
if (!session || !name) {
|
|
451
|
+
console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
const result = killWorker(session, name);
|
|
455
|
+
console.log(JSON.stringify(result, null, 2));
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
case "output": {
|
|
459
|
+
const session = getArg("session");
|
|
460
|
+
const name = getArg("name");
|
|
461
|
+
const lines = parseInt(getArg("lines") || "50", 10);
|
|
462
|
+
if (!session || !name) {
|
|
463
|
+
console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
console.log(captureWorkerOutput(session, name, lines));
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
default:
|
|
470
|
+
console.error("사용법: node psmux.mjs spawn|status|kill|output [args]");
|
|
471
|
+
console.error("");
|
|
472
|
+
console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
|
|
473
|
+
console.error(" status --session <세션> --name <워커명>");
|
|
474
|
+
console.error(" kill --session <세션> --name <워커명>");
|
|
475
|
+
console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.error(`오류: ${err.message}`);
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/preflight-cache.mjs — 세션 시작 시 preflight 점검 캐싱
|
|
3
|
+
|
|
4
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const CACHE_DIR = join(homedir(), ".claude", "cache");
|
|
10
|
+
const CACHE_FILE = join(CACHE_DIR, "tfx-preflight.json");
|
|
11
|
+
const CACHE_TTL_MS = 30_000; // 30초
|
|
12
|
+
|
|
13
|
+
function checkHub() {
|
|
14
|
+
try {
|
|
15
|
+
const res = execSync("curl -sf http://127.0.0.1:27888/status", { timeout: 3000, encoding: "utf8" });
|
|
16
|
+
const data = JSON.parse(res);
|
|
17
|
+
return { ok: true, state: data?.hub?.state || "unknown", pid: data?.pid };
|
|
18
|
+
} catch {
|
|
19
|
+
return { ok: false, state: "unreachable" };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkRoute() {
|
|
24
|
+
const routePath = join(homedir(), ".claude", "scripts", "tfx-route.sh");
|
|
25
|
+
return { ok: existsSync(routePath), path: routePath };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkCli(name) {
|
|
29
|
+
try {
|
|
30
|
+
const path = execSync(`which ${name} 2>/dev/null || where ${name} 2>nul`, { encoding: "utf8", timeout: 2000 }).trim();
|
|
31
|
+
return { ok: !!path, path };
|
|
32
|
+
} catch {
|
|
33
|
+
return { ok: false };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runPreflight() {
|
|
38
|
+
const result = {
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
hub: checkHub(),
|
|
41
|
+
route: checkRoute(),
|
|
42
|
+
codex: checkCli("codex"),
|
|
43
|
+
gemini: checkCli("gemini"),
|
|
44
|
+
ok: false,
|
|
45
|
+
};
|
|
46
|
+
result.ok = result.hub.ok && result.route.ok;
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 캐시 읽기 (TTL 검증 포함)
|
|
51
|
+
export function readPreflightCache() {
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
|
|
54
|
+
if (Date.now() - data.timestamp < CACHE_TTL_MS) return data;
|
|
55
|
+
} catch {}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 메인 실행
|
|
60
|
+
if (process.argv[1]?.endsWith("preflight-cache.mjs")) {
|
|
61
|
+
const result = runPreflight();
|
|
62
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
63
|
+
writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2));
|
|
64
|
+
// 간결 출력 (hook stdout)
|
|
65
|
+
const summary = result.ok ? "preflight: ok" : "preflight: FAIL";
|
|
66
|
+
const details = [];
|
|
67
|
+
if (!result.hub.ok) details.push("hub:" + result.hub.state);
|
|
68
|
+
if (!result.route.ok) details.push("route:missing");
|
|
69
|
+
console.log(details.length ? `${summary} (${details.join(", ")})` : summary);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { runPreflight, CACHE_FILE, CACHE_TTL_MS };
|
|
@@ -192,8 +192,23 @@ status는 "completed"만 사용. 실패 여부는 `metadata.result`로 구분.
|
|
|
192
192
|
> Windows 호환 경로, 타임아웃, 후처리(토큰 추적/이슈 로깅)가 모두 누락된다.
|
|
193
193
|
> 반드시 `bash ~/.claude/scripts/tfx-route.sh {role} '{subtask}' {mcp_profile}`을 통해 실행해야 한다.
|
|
194
194
|
|
|
195
|
+
**Bash timeout 동적 상속:** Bash timeout은 tfx-route.sh의 role/profile별 timeout + 60초 여유를 ms로 변환하여 동적 상속한다. `getRouteTimeout(role, mcpProfile)` 기준: analyze/review 프로필 또는 architect/analyst 역할은 3600초, 그 외 기본 1080초(18분).
|
|
196
|
+
|
|
195
197
|
**핵심 차이 vs v2:** 프롬프트 ~100 토큰 (v2의 ~500), task claim/complete/report는 tfx-route.sh가 Named Pipe(우선)/HTTP(fallback) 경유로 수행.
|
|
196
198
|
|
|
199
|
+
#### 인터럽트 프로토콜
|
|
200
|
+
|
|
201
|
+
워커가 Bash 실행 전에 SendMessage로 시작을 보고하면 턴 경계가 생겨 리드가 방향 전환 메시지를 보낼 수 있다.
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
1. TaskUpdate(taskId, status: in_progress) — task claim
|
|
205
|
+
2. SendMessage(to: team-lead, "작업 시작: {agentName}") — 시작 보고 (턴 경계 생성)
|
|
206
|
+
3. Bash(command: tfx-route.sh ..., timeout: {bashTimeoutMs}) — 1회 실행
|
|
207
|
+
4. TaskUpdate(status: completed, metadata: {result}) + SendMessage → 종료
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
리드는 워커의 Step 2 시점에 턴 경계를 인식하고, 필요 시 방향 전환/중단 메시지를 보낼 수 있다.
|
|
211
|
+
|
|
197
212
|
`tfx-route.sh` 팀 통합 동작(이미 구현됨, `TFX_TEAM_*` 기반):
|
|
198
213
|
- `TFX_TEAM_NAME`: 팀 식별자
|
|
199
214
|
- `TFX_TEAM_TASK_ID`: 작업 식별자
|
|
@@ -235,8 +250,7 @@ Agent({
|
|
|
235
250
|
```
|
|
236
251
|
"팀 '{teamName}' 생성 완료.
|
|
237
252
|
Codex/Gemini 워커가 슬림 래퍼 Agent로 네비게이션에 등록되었습니다.
|
|
238
|
-
Shift+Down으로 다음
|
|
239
|
-
(Shift+Up은 Claude Code 미지원 — 대부분 터미널에서 scroll-up으로 먹힘)"
|
|
253
|
+
Shift+Down으로 다음 워커로 전환 (마지막→리드 wrap). Shift+Tab으로 이전 워커 전환."
|
|
240
254
|
```
|
|
241
255
|
|
|
242
256
|
### Phase 3.5–3.7: Verify/Fix Loop (`--thorough` 전용)
|