triflux 9.7.13 → 9.8.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +2 -0
  4. package/README.md +2 -0
  5. package/bin/triflux.mjs +297 -47
  6. package/hooks/hook-registry.json +4 -4
  7. package/hub/fullcycle.mjs +96 -0
  8. package/hub/paths.mjs +30 -28
  9. package/hub/pipeline/index.mjs +318 -318
  10. package/hub/schema.sql +146 -146
  11. package/hub/team/cli/commands/kill.mjs +37 -37
  12. package/hub/team/cli/commands/stop.mjs +31 -31
  13. package/hub/team/cli/commands/task.mjs +30 -30
  14. package/hub/team/cli/services/hub-client.mjs +208 -208
  15. package/hub/team/cli/services/native-control.mjs +118 -118
  16. package/hub/team/cli/services/runtime-mode.mjs +62 -62
  17. package/hub/team/cli/services/state-store.mjs +48 -48
  18. package/hub/team/dashboard.mjs +274 -274
  19. package/hub/team/native.mjs +649 -649
  20. package/hub/team/psmux.mjs +68 -13
  21. package/hub/tools.mjs +554 -554
  22. package/hub/workers/claude-worker.mjs +423 -423
  23. package/hub/workers/codex-mcp.mjs +410 -410
  24. package/hub/workers/gemini-worker.mjs +429 -429
  25. package/hub/workers/interface.mjs +40 -40
  26. package/package.json +1 -1
  27. package/scripts/__tests__/remote-spawn-transfer.test.mjs +1 -1
  28. package/scripts/cache-warmup.mjs +1 -0
  29. package/scripts/claude-logged.ps1 +54 -0
  30. package/scripts/demo-tui.mjs +59 -0
  31. package/scripts/headless-guard.mjs +4 -7
  32. package/scripts/hub-ensure.mjs +120 -120
  33. package/scripts/lib/psmux-info.mjs +119 -0
  34. package/scripts/lib/remote-spawn-transfer.mjs +1 -1
  35. package/scripts/setup.mjs +150 -6
  36. package/scripts/tfx-route-post.mjs +90 -13
  37. package/scripts/token-snapshot.mjs +575 -575
  38. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
  39. package/skills/.omc/state/idle-notif-cooldown.json +3 -0
  40. package/skills/.omc/state/last-tool-error.json +7 -0
  41. package/skills/.omc/state/subagent-tracking.json +7 -0
  42. package/skills/tfx-codex-swarm/SKILL.md +40 -5
  43. package/skills/tfx-codex-swarm/mcp-daemon/register-autostart.ps1 +32 -0
  44. package/skills/tfx-doctor/SKILL.md +3 -0
  45. package/skills/tfx-fullcycle/SKILL.md +79 -4
  46. package/skills/tfx-hub/SKILL.md +3 -1
  47. package/skills/tfx-psmux-rules/SKILL.md +53 -31
  48. package/skills/tfx-remote-spawn/references/hosts.json +16 -16
  49. package/skills/tfx-setup/SKILL.md +9 -0
  50. package/tui/doctor.mjs +1 -0
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "triflux",
11
11
  "description": "CLI-first multi-model orchestrator for Claude Code. Routes tasks to Codex, Gemini, and Claude CLIs with automatic triage, DAG-based parallel execution, headless psmux sessions, and cost-optimized routing. Includes 41 skills, HUD status bar, hook orchestrator, and shell-based CLI routing.",
12
- "version": "9.7.13",
12
+ "version": "9.7.14",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "9.7.13"
33
+ "version": "9.7.14"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.7.13",
3
+ "version": "9.7.14",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
package/README.ko.md CHANGED
@@ -319,6 +319,8 @@ tfx setup
319
319
  /tfx-multi "refactor auth + update UI + add tests"
320
320
  ```
321
321
  > **참고**: Deep 스킬(`/tfx-deep-*`, `/tfx-persist`, `/tfx-ralph`)은 완전한 Tri-CLI 합의(Tier 1)를 위해 **psmux**(또는 tmux), **triflux Hub**, **Codex CLI**, **Gemini CLI**가 필요합니다. 전제조건이 충족되지 않으면 Tier 3(Claude 단독, single-model) 모드로 자동 전환됩니다. `tfx doctor`로 환경을 확인하세요.
322
+ >
323
+ > **Serena 참고**: Serena MCP는 stateful합니다. 따라서 **같은 프로젝트**를 다루는 에이전트끼리만 하나의 Serena 인스턴스를 공유하는 것이 안전합니다. 서로 다른 프로젝트를 병렬로 작업할 때는 Serena 인스턴스를 분리하세요. Serena가 `No active project`를 보고하면 Codex Serena 설정의 `--project-from-cwd`(또는 `--project <path>`)를 확인하고 `tfx doctor`를 다시 실행하세요.
322
324
 
323
325
  ---
324
326
 
package/README.md CHANGED
@@ -319,6 +319,8 @@ tfx setup
319
319
  /tfx-multi "refactor auth + update UI + add tests"
320
320
  ```
321
321
  > **Note**: Deep skills (`/tfx-deep-*`, `/tfx-persist`, `/tfx-ralph`) require **psmux** (or tmux), **triflux Hub**, **Codex CLI**, and **Gemini CLI** for full Tri-CLI consensus (Tier 1). Without these prerequisites, skills automatically degrade to Tier 3 (Claude-only, single-model) mode. Run `tfx doctor` to check your environment.
322
+ >
323
+ > **Serena note**: Serena MCP is stateful. Share one Serena instance only across agents working on the **same project**. For parallel work across different projects, prefer separate Serena instances. If Serena reports `No active project`, check your Codex Serena config for `--project-from-cwd` (or `--project <path>`) and rerun `tfx doctor`.
322
324
 
323
325
  ---
324
326
 
package/bin/triflux.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // triflux CLI — setup, doctor, version
3
3
  import { copyFileSync, existsSync, readFileSync, readSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, statSync, openSync, closeSync } from "fs";
4
4
  import { join, dirname, basename } from "path";
5
- import { homedir } from "os";
5
+ import { homedir, tmpdir } from "os";
6
6
  import { execSync, execFileSync, spawn } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
8
  import { setTimeout as delay } from "node:timers/promises";
@@ -12,6 +12,7 @@ import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
12
12
  import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
13
13
  import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
14
14
  import { ensureGeminiProfiles } from "../scripts/lib/gemini-profiles.mjs";
15
+ import { probePsmuxSupport, formatPsmuxInstallGuidance, formatPsmuxUpdateGuidance } from "../scripts/lib/psmux-info.mjs";
15
16
  import {
16
17
  addRegistryServer,
17
18
  inspectRegistry,
@@ -25,6 +26,7 @@ import {
25
26
  syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
26
27
  ensureCodexProfiles, getVersion, cleanupStaleSkills, DEPRECATED_SKILLS,
27
28
  extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
29
+ ensureCodexHubServerConfig,
28
30
  } from "../scripts/setup.mjs";
29
31
 
30
32
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -809,7 +811,7 @@ function cmdSetup(options = {}) {
809
811
  // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
810
812
  if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
811
813
  const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
812
- autoRegisterMcp(defaultHubUrl);
814
+ autoRegisterMcp(defaultHubUrl, { codexEnabled: false });
813
815
  summary.push({ item: "Hub MCP", status: "✅", detail: "등록됨" });
814
816
  console.log("");
815
817
  }
@@ -954,11 +956,41 @@ function computeHookCoverage(settings, managedHooks) {
954
956
  total: managedHooks.length,
955
957
  registered: 0,
956
958
  missing: [],
959
+ duplicates: [],
957
960
  };
958
961
 
959
962
  const hooksByEvent = settings?.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
963
+
964
+ // 이벤트별 orchestrator 존재 여부를 캐시
965
+ const orchestratorByEvent = {};
966
+ for (const [event, entries] of Object.entries(hooksByEvent)) {
967
+ orchestratorByEvent[event] = Array.isArray(entries) && entries.some((entry) =>
968
+ Array.isArray(entry?.hooks) &&
969
+ entry.hooks.some((hook) =>
970
+ typeof hook?.command === "string" && hook.command.includes("hook-orchestrator"),
971
+ ),
972
+ );
973
+ }
974
+
960
975
  for (const spec of managedHooks) {
961
976
  const eventEntries = Array.isArray(hooksByEvent[spec.event]) ? hooksByEvent[spec.event] : [];
977
+
978
+ // orchestrator가 있으면 registry 훅을 체이닝하므로 "registered"로 간주
979
+ if (orchestratorByEvent[spec.event]) {
980
+ coverage.registered++;
981
+
982
+ // 동시에 개별 훅도 직접 등록되어 있으면 → 이중 실행 (duplicate)
983
+ const directlyRegistered = eventEntries.some((entry) =>
984
+ Array.isArray(entry?.hooks) &&
985
+ entry.hooks.some((hook) => extractManagedHookFilename(hook?.command) === spec.fileName),
986
+ );
987
+ if (directlyRegistered) {
988
+ coverage.duplicates.push(toHookCoverageName(spec.fileName, spec.id));
989
+ }
990
+ continue;
991
+ }
992
+
993
+ // orchestrator 없으면 기존 방식: 개별 훅 직접 등록 확인
962
994
  const found = eventEntries.some((entry) =>
963
995
  Array.isArray(entry?.hooks) &&
964
996
  entry.hooks.some((hook) => extractManagedHookFilename(hook?.command) === spec.fileName),
@@ -1004,6 +1036,48 @@ function getOptionValue(args, optionName) {
1004
1036
  return args[index + 1] ?? null;
1005
1037
  }
1006
1038
 
1039
+ function extractTomlSection(content, sectionName) {
1040
+ const lines = String(content ?? "").split(/\r?\n/);
1041
+ const header = `[${sectionName}]`;
1042
+ const start = lines.findIndex((line) => line.trim() === header);
1043
+ if (start === -1) return "";
1044
+ const collected = [];
1045
+ for (let i = start + 1; i < lines.length; i++) {
1046
+ const line = lines[i];
1047
+ if (/^\s*\[.+\]\s*$/.test(line)) break;
1048
+ collected.push(line);
1049
+ }
1050
+ return collected.join("\n");
1051
+ }
1052
+
1053
+ function inspectSerenaMcpConfig(configContent) {
1054
+ const section = extractTomlSection(configContent, "mcp_servers.serena");
1055
+ if (!section.trim()) {
1056
+ return {
1057
+ present: false,
1058
+ hasProjectBinding: false,
1059
+ hasContextCodex: false,
1060
+ startupTimeoutSec: null,
1061
+ timeoutRecommended: false,
1062
+ };
1063
+ }
1064
+
1065
+ const hasProjectBinding = section.includes("--project-from-cwd")
1066
+ || /--project(?:\s|=|")/.test(section);
1067
+ const hasContextCodex = /--context(?:\s|",\s*")?codex/i.test(section) || /"codex"/i.test(section);
1068
+ const timeoutMatch = section.match(/startup_timeout_sec\s*=\s*([0-9.]+)/i);
1069
+ const startupTimeoutSec = timeoutMatch ? Number(timeoutMatch[1]) : null;
1070
+ const timeoutRecommended = startupTimeoutSec !== null && startupTimeoutSec >= 30;
1071
+
1072
+ return {
1073
+ present: true,
1074
+ hasProjectBinding,
1075
+ hasContextCodex,
1076
+ startupTimeoutSec,
1077
+ timeoutRecommended,
1078
+ };
1079
+ }
1080
+
1007
1081
  function statusBadge(status) {
1008
1082
  switch (status) {
1009
1083
  case "present":
@@ -1348,6 +1422,69 @@ async function cmdDoctor(options = {}) {
1348
1422
  }
1349
1423
  }
1350
1424
 
1425
+ // 4.5 Serena MCP
1426
+ section("Serena MCP");
1427
+ if (existsSync(CODEX_CONFIG_PATH)) {
1428
+ const codexConfig = readFileSync(CODEX_CONFIG_PATH, "utf8");
1429
+ const serenaConfig = inspectSerenaMcpConfig(codexConfig);
1430
+ if (!serenaConfig.present) {
1431
+ warn("serena MCP 설정 없음");
1432
+ info("권장: [mcp_servers.serena]에 --project-from-cwd, --context codex, startup_timeout_sec=30+ 설정");
1433
+ addDoctorCheck(report, {
1434
+ name: "serena-mcp",
1435
+ status: "missing",
1436
+ path: CODEX_CONFIG_PATH,
1437
+ fix: "Codex config에 Serena MCP 설정을 추가하세요.",
1438
+ });
1439
+ issues++;
1440
+ } else {
1441
+ const hasSerenaIssues = !serenaConfig.hasProjectBinding || !serenaConfig.timeoutRecommended;
1442
+
1443
+ if (serenaConfig.hasProjectBinding) ok("project binding: 정상");
1444
+ else {
1445
+ warn("project binding 없음");
1446
+ info("권장: --project-from-cwd 또는 --project <path>");
1447
+ issues++;
1448
+ }
1449
+
1450
+ if (serenaConfig.hasContextCodex) info("context codex: 설정됨");
1451
+ else info("context codex: 미설정");
1452
+
1453
+ if (serenaConfig.startupTimeoutSec === null) {
1454
+ warn("startup_timeout_sec 미설정");
1455
+ info("권장: startup_timeout_sec = 30 이상");
1456
+ issues++;
1457
+ } else if (serenaConfig.timeoutRecommended) {
1458
+ ok(`startup timeout: ${serenaConfig.startupTimeoutSec}s`);
1459
+ } else {
1460
+ warn(`startup timeout 낮음: ${serenaConfig.startupTimeoutSec}s`);
1461
+ info("권장: startup_timeout_sec = 30 이상");
1462
+ issues++;
1463
+ }
1464
+
1465
+ addDoctorCheck(report, {
1466
+ name: "serena-mcp",
1467
+ status: hasSerenaIssues ? "issues" : "ok",
1468
+ path: CODEX_CONFIG_PATH,
1469
+ project_binding: serenaConfig.hasProjectBinding,
1470
+ context_codex: serenaConfig.hasContextCodex,
1471
+ startup_timeout_sec: serenaConfig.startupTimeoutSec,
1472
+ ...(hasSerenaIssues
1473
+ ? { fix: "Serena MCP에 --project-from-cwd 와 startup_timeout_sec=30+ 를 설정하세요." }
1474
+ : {}),
1475
+ });
1476
+ }
1477
+ } else {
1478
+ addDoctorCheck(report, {
1479
+ name: "serena-mcp",
1480
+ status: "missing",
1481
+ path: CODEX_CONFIG_PATH,
1482
+ fix: "Codex config를 생성하고 Serena MCP 설정을 추가하세요.",
1483
+ });
1484
+ warn("config.toml 미존재 — Serena MCP 진단 건너뜀");
1485
+ issues++;
1486
+ }
1487
+
1351
1488
  // 5. Gemini CLI
1352
1489
  section(`Gemini CLI ${BLUE}●${RESET}`);
1353
1490
  const geminiCli = checkCliCrossShell("gemini", "npm install -g @google/gemini-cli");
@@ -1394,6 +1531,29 @@ async function cmdDoctor(options = {}) {
1394
1531
  const psmuxPath = which("psmux");
1395
1532
  if (psmuxPath) {
1396
1533
  ok("설치됨");
1534
+ const psmuxSupport = probePsmuxSupport({ execFileSyncFn: execFileSync });
1535
+ const supportOk = psmuxSupport.ok;
1536
+ info(`버전: ${psmuxSupport.version || "unknown"}`);
1537
+ if (!supportOk) {
1538
+ warn(`capability 부족: ${psmuxSupport.missingCommands.join(", ")}`);
1539
+ info(`업데이트 권장:\n${formatPsmuxUpdateGuidance(" ")}`);
1540
+ addDoctorCheck(report, {
1541
+ name: "psmux",
1542
+ status: "issues",
1543
+ path: psmuxPath,
1544
+ version: psmuxSupport.version || "unknown",
1545
+ missing_commands: psmuxSupport.missingCommands,
1546
+ fix: "tfx setup 또는 psmux 업그레이드",
1547
+ });
1548
+ issues++;
1549
+ } else if (!psmuxSupport.recommended) {
1550
+ warn(`권장 버전 미만: v${psmuxSupport.version || "unknown"} (권장: v${psmuxSupport.recommendedVersion}+)`);
1551
+ info(`업데이트 권장:\n${formatPsmuxUpdateGuidance(" ")}`);
1552
+ }
1553
+ if (psmuxSupport.missingOptionalCommands?.length > 0) {
1554
+ info(`선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")} (detach-first hardening 경로에서만 사용)`);
1555
+ }
1556
+
1397
1557
  // 기본 셸 확인: psmux 세션의 기본 셸이 PowerShell인지 cmd.exe인지
1398
1558
  let shellOk = false;
1399
1559
  try {
@@ -1403,7 +1563,7 @@ async function cmdDoctor(options = {}) {
1403
1563
  // show-options 실패 시 pwsh/powershell 존재 여부로 판단
1404
1564
  shellOk = !!which("pwsh") || !!which("powershell.exe");
1405
1565
  }
1406
- if (shellOk) {
1566
+ if (supportOk && shellOk) {
1407
1567
  ok("기본 셸: PowerShell");
1408
1568
  addDoctorCheck(report, { name: "psmux", status: "ok", path: psmuxPath, shell: "powershell" });
1409
1569
  } else {
@@ -1429,7 +1589,7 @@ async function cmdDoctor(options = {}) {
1429
1589
  }
1430
1590
  } else {
1431
1591
  info(`미설치 ${GRAY}(선택 — 멀티모델 병렬 실행에 필요)${RESET}`);
1432
- info(`설치: winget install marlocarlo.psmux`);
1592
+ info(`설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1433
1593
  addDoctorCheck(report, { name: "psmux", status: "skipped", detail: "미설치 (선택)", fix: "winget install marlocarlo.psmux" });
1434
1594
  }
1435
1595
  }
@@ -2190,20 +2350,59 @@ async function cmdDoctor(options = {}) {
2190
2350
  }
2191
2351
  }
2192
2352
 
2353
+ // 중복 훅 감지 + 자동 수정 (orchestrator와 개별 훅이 동시 등록된 경우)
2354
+ if (coverage.duplicates && coverage.duplicates.length > 0) {
2355
+ if (fix) {
2356
+ try {
2357
+ const fixedSettings = JSON.parse(readFileSync(settingsPath, "utf8"));
2358
+ let removed = 0;
2359
+ for (const [event, entries] of Object.entries(fixedSettings.hooks || {})) {
2360
+ if (!Array.isArray(entries)) continue;
2361
+ const hasOrch = entries.some((e) =>
2362
+ Array.isArray(e?.hooks) &&
2363
+ e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("hook-orchestrator")),
2364
+ );
2365
+ if (!hasOrch) continue;
2366
+ // orchestrator가 아닌 엔트리 제거
2367
+ const before = entries.length;
2368
+ fixedSettings.hooks[event] = entries.filter((e) =>
2369
+ Array.isArray(e?.hooks) &&
2370
+ e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("hook-orchestrator")),
2371
+ );
2372
+ removed += before - fixedSettings.hooks[event].length;
2373
+ }
2374
+ if (removed > 0) {
2375
+ writeFileSync(settingsPath, JSON.stringify(fixedSettings, null, 2) + "\n", "utf8");
2376
+ ok(`중복 훅 ${removed}개 엔트리 제거됨 (orchestrator가 체이닝)`);
2377
+ const rechecked = JSON.parse(readFileSync(settingsPath, "utf8"));
2378
+ coverage = computeHookCoverage(rechecked, managedHooks);
2379
+ }
2380
+ } catch (error) {
2381
+ warn(`중복 훅 자동 제거 실패: ${error.message}`);
2382
+ }
2383
+ } else {
2384
+ warn(`중복 훅 ${coverage.duplicates.length}개 감지 (이중 실행됨): ${coverage.duplicates.join(", ")}`);
2385
+ warn("tfx doctor --fix 로 자동 제거하세요.");
2386
+ issues += coverage.duplicates.length;
2387
+ }
2388
+ }
2389
+
2193
2390
  report.hook_coverage = coverage;
2194
- const coverageStatus = coverage.missing.length === 0 ? "ok" : "issues";
2391
+ const coverageStatus = coverage.missing.length === 0 && (!coverage.duplicates || coverage.duplicates.length === 0) ? "ok" : "issues";
2195
2392
  addDoctorCheck(report, {
2196
2393
  name: "hook-coverage",
2197
2394
  status: coverageStatus,
2198
2395
  total: coverage.total,
2199
2396
  registered: coverage.registered,
2200
2397
  missing: coverage.missing,
2398
+ duplicates: coverage.duplicates || [],
2201
2399
  ...(coverage.missing.length > 0 ? { fix: "tfx doctor --fix 또는 tfx setup" } : {}),
2400
+ ...(coverage.duplicates?.length > 0 ? { fix: "tfx doctor --fix 로 중복 훅 제거" } : {}),
2202
2401
  });
2203
2402
 
2204
- if (coverage.missing.length === 0) {
2403
+ if (coverage.missing.length === 0 && (!coverage.duplicates || coverage.duplicates.length === 0)) {
2205
2404
  ok(`Hook Coverage: ${coverage.registered}/${coverage.total} registered`);
2206
- } else {
2405
+ } else if (coverage.missing.length > 0) {
2207
2406
  fail(`Missing hooks: ${coverage.missing.join(", ")}`);
2208
2407
  issues += coverage.missing.length;
2209
2408
  }
@@ -2382,26 +2581,94 @@ function cmdUpdate() {
2382
2581
  ok(`버전: v${oldVer} (이미 최신)`);
2383
2582
  }
2384
2583
 
2385
- // setup 재실행 개선된 cmdSetup()이 Gemini 프로필, CLI 확인, 요약 테이블 포함
2584
+ // ── Post-update: 캐시 갱신 (삭제 재생성) ──
2585
+ console.log(`\n${CYAN}── 캐시 갱신 ──${RESET}`);
2586
+ {
2587
+ const cacheDir = join(CLAUDE_DIR, "cache");
2588
+ // stale 캐시 삭제
2589
+ for (const name of ["tfx-preflight.json", "mcp-inventory.json"]) {
2590
+ const p = join(cacheDir, name);
2591
+ if (existsSync(p)) { try { unlinkSync(p); } catch {} }
2592
+ }
2593
+ // tmpdir 상태 파일 정리
2594
+ for (const name of ["tfx-multi-state.json"]) {
2595
+ const p = join(tmpdir(), name);
2596
+ if (existsSync(p)) { try { unlinkSync(p); } catch {} }
2597
+ }
2598
+
2599
+ // preflight 캐시 재생성
2600
+ const preflightScript = join(PKG_ROOT, "scripts", "preflight-cache.mjs");
2601
+ if (existsSync(preflightScript)) {
2602
+ try {
2603
+ execSync(`node "${preflightScript}"`, { encoding: "utf8", timeout: 15000, windowsHide: true, stdio: "pipe" });
2604
+ ok("preflight 캐시 재생성 완료");
2605
+ } catch (e) {
2606
+ warn(`preflight 캐시 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
2607
+ }
2608
+ }
2609
+
2610
+ // MCP 인벤토리 캐시 재생성
2611
+ const mcpCheckScript = join(PKG_ROOT, "scripts", "mcp-check.mjs");
2612
+ if (existsSync(mcpCheckScript)) {
2613
+ try {
2614
+ execSync(`node "${mcpCheckScript}"`, { encoding: "utf8", timeout: 10000, windowsHide: true, stdio: "pipe" });
2615
+ ok("MCP 인벤토리 캐시 재생성 완료");
2616
+ } catch (e) {
2617
+ warn(`MCP 인벤토리 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
2618
+ }
2619
+ }
2620
+ }
2621
+
2622
+ // ── Post-update: 핵심 파일 무결성 검증 ──
2623
+ console.log(`\n${CYAN}── 무결성 검증 ──${RESET}`);
2624
+ {
2625
+ const criticalFiles = [
2626
+ { path: join(PKG_ROOT, "hooks", "hook-orchestrator.mjs"), label: "hook-orchestrator" },
2627
+ { path: join(PKG_ROOT, "hooks", "hook-registry.json"), label: "hook-registry" },
2628
+ { path: join(PKG_ROOT, "hooks", "safety-guard.mjs"), label: "safety-guard" },
2629
+ { path: join(PKG_ROOT, "scripts", "keyword-detector.mjs"), label: "keyword-detector" },
2630
+ { path: join(PKG_ROOT, "scripts", "setup.mjs"), label: "setup" },
2631
+ { path: join(PKG_ROOT, "bin", "triflux.mjs"), label: "triflux CLI" },
2632
+ ];
2633
+ let missing = 0;
2634
+ for (const { path: fp, label } of criticalFiles) {
2635
+ if (!existsSync(fp)) {
2636
+ fail(`누락: ${label} (${formatPathForDisplay(fp)})`);
2637
+ missing++;
2638
+ }
2639
+ }
2640
+ if (missing > 0) {
2641
+ fail(`핵심 파일 ${missing}개 누락 — npm install -g triflux@latest 재설치 필요`);
2642
+ } else {
2643
+ ok(`핵심 파일 ${criticalFiles.length}개 확인 완료`);
2644
+ }
2645
+ }
2646
+
2647
+ // ── Post-update: 설정 동기화 ──
2386
2648
  console.log(`\n${CYAN}── 설정 동기화 ──${RESET}`);
2387
2649
  cmdSetup({ fromUpdate: true, overrideVersion: newVer });
2388
2650
 
2389
- // hook-orchestrator apply — settings.json 경로를 올바른 절대경로로 갱신
2390
- try {
2651
+ // ── Post-update:오케스트레이터 적용 ──
2652
+ {
2391
2653
  const hookMgrPath = join(PKG_ROOT, "hooks", "hook-manager.mjs");
2392
2654
  if (existsSync(hookMgrPath)) {
2393
- const result = execSync(`node "${hookMgrPath}" apply`, {
2394
- encoding: "utf8",
2395
- timeout: 10000,
2396
- windowsHide: true,
2397
- }).trim();
2398
- const parsed = JSON.parse(result);
2399
- if (parsed?.status === "applied") {
2400
- ok(`훅 오케스트레이터 적용 (${parsed.events?.length || 0}개 이벤트)`);
2655
+ try {
2656
+ const result = execSync(`node "${hookMgrPath}" apply`, {
2657
+ encoding: "utf8",
2658
+ timeout: 10000,
2659
+ windowsHide: true,
2660
+ }).trim();
2661
+ const parsed = JSON.parse(result);
2662
+ if (parsed?.status === "applied") {
2663
+ ok(`훅 오케스트레이터 적용 (${parsed.events?.length || 0}개 이벤트)`);
2664
+ }
2665
+ } catch (e) {
2666
+ warn(`훅 오케스트레이터 적용 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
2667
+ warn("tfx hooks apply 로 수동 적용하세요.");
2401
2668
  }
2669
+ } else {
2670
+ fail("hook-manager.mjs 누락 — 훅 오케스트레이터 적용 불가");
2402
2671
  }
2403
- } catch {
2404
- // apply 실패 시 무시 — ensureHooksInSettings이 개별 훅을 이미 등록함
2405
2672
  }
2406
2673
 
2407
2674
  if (stoppedHubInfo) {
@@ -2951,38 +3218,20 @@ function startHubAfterUpdate(info) {
2951
3218
  }
2952
3219
 
2953
3220
  // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
2954
- function autoRegisterMcp(mcpUrl) {
3221
+ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
2955
3222
  section("MCP 자동 등록");
2956
3223
 
2957
- // Codex — codex mcp add
3224
+ // Codex — config.json에 기본 disabled 엔트리로 등록
2958
3225
  if (which("codex")) {
2959
3226
  try {
2960
- // 이미 등록됐는지 확인
2961
- const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
2962
- if (list.includes("tfx-hub")) {
2963
- ok("Codex: 이미 등록됨");
3227
+ const result = ensureCodexHubServerConfig({ mcpUrl, createIfMissing: true, enabled: codexEnabled });
3228
+ if (!result.ok) throw new Error(result.reason || "unknown");
3229
+ if (result.changed) {
3230
+ ok(`Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})`);
2964
3231
  } else {
2965
- execFileSync("codex", ["mcp", "add", "tfx-hub", "--url", mcpUrl], { timeout: 10000, stdio: "ignore", windowsHide: true });
2966
- ok("Codex: MCP 등록 완료");
3232
+ ok(`Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})`);
2967
3233
  }
2968
- } catch {
2969
- // mcp list/add 미지원 → 설정 파일 직접 수정
2970
- try {
2971
- const codexDir = join(homedir(), ".codex");
2972
- const configFile = join(codexDir, "config.json");
2973
- let config = {};
2974
- if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
2975
- if (!config.mcpServers) config.mcpServers = {};
2976
- if (!config.mcpServers["tfx-hub"]) {
2977
- config.mcpServers["tfx-hub"] = { url: mcpUrl };
2978
- if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
2979
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
2980
- ok("Codex: config.json에 등록 완료");
2981
- } else {
2982
- ok("Codex: 이미 등록됨");
2983
- }
2984
- } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
2985
- }
3234
+ } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
2986
3235
  } else {
2987
3236
  info("Codex: 미설치 (건너뜀)");
2988
3237
  }
@@ -3072,6 +3321,7 @@ async function cmdHub(args = [], options = {}) {
3072
3321
  try {
3073
3322
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
3074
3323
  process.kill(info.pid, 0); // 프로세스 존재 확인
3324
+ autoRegisterMcp(info.url, { codexEnabled: true });
3075
3325
  console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
3076
3326
  return;
3077
3327
  } catch {
@@ -3115,7 +3365,7 @@ async function cmdHub(args = [], options = {}) {
3115
3365
  console.log(` PID: ${hubInfo.pid}`);
3116
3366
  console.log(` DB: ${DIM}${getPipelineStateDbPath(PKG_ROOT)}${RESET}`);
3117
3367
  console.log("");
3118
- autoRegisterMcp(hubInfo.url);
3368
+ autoRegisterMcp(hubInfo.url, { codexEnabled: true });
3119
3369
  console.log("");
3120
3370
  } else {
3121
3371
  // 직접 포그라운드 모드로 안내
@@ -143,11 +143,11 @@
143
143
  "source": "triflux",
144
144
  "matcher": "*",
145
145
  "command": "node \"${PLUGIN_ROOT}/scripts/preflight-cache.mjs\"",
146
- "priority": 2,
146
+ "priority": 3,
147
147
  "enabled": true,
148
- "timeout": 8,
148
+ "timeout": 5,
149
149
  "blocking": false,
150
- "description": "CLI/Hub 가용성 캐시 (hub-ensure 병렬 실행)"
150
+ "description": "CLI/Hub 가용성 캐시 (hub-ensure 완료 실행)"
151
151
  },
152
152
  {
153
153
  "id": "ext-session-vault-start",
@@ -190,7 +190,7 @@
190
190
  "matcher": "*",
191
191
  "command": "powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"${HOME}/.claude/scripts/mcp-cleanup.ps1\"",
192
192
  "priority": 100,
193
- "enabled": false,
193
+ "enabled": true,
194
194
  "timeout": 8,
195
195
  "blocking": false,
196
196
  "description": "MCP 고아 프로세스 정리"
@@ -0,0 +1,96 @@
1
+ // hub/fullcycle.mjs — tfx-fullcycle runtime artifact/state helpers
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { ensureTfxDirs, TFX_FULLCYCLE_DIR, TFX_PLANS_DIR } from './paths.mjs';
6
+
7
+ function safeResolve(baseDir, relativePath) {
8
+ const base = resolve(baseDir);
9
+ const target = resolve(join(baseDir, relativePath));
10
+ if (!target.startsWith(base)) {
11
+ throw new Error('Invalid fullcycle path: path traversal detected');
12
+ }
13
+ return target;
14
+ }
15
+
16
+ export function createFullcycleRunId(now = new Date()) {
17
+ return now
18
+ .toISOString()
19
+ .replace(/[:.]/g, '-')
20
+ .replace('T', '_')
21
+ .replace('Z', 'Z');
22
+ }
23
+
24
+ export function getFullcycleRunDir(runId, baseDir = process.cwd()) {
25
+ return safeResolve(baseDir, join(TFX_FULLCYCLE_DIR, runId));
26
+ }
27
+
28
+ export function ensureFullcycleRunDir(runId, baseDir = process.cwd()) {
29
+ ensureTfxDirs(baseDir);
30
+ const dir = getFullcycleRunDir(runId, baseDir);
31
+ mkdirSync(dir, { recursive: true });
32
+ return dir;
33
+ }
34
+
35
+ export function saveFullcycleArtifact(runId, filename, content, baseDir = process.cwd()) {
36
+ if (!filename || typeof filename !== 'string') {
37
+ throw new Error('Artifact filename is required');
38
+ }
39
+
40
+ const dir = ensureFullcycleRunDir(runId, baseDir);
41
+ const path = safeResolve(dir, filename);
42
+ writeFileSync(path, content, 'utf8');
43
+ return path;
44
+ }
45
+
46
+ export function readFullcycleArtifact(runId, filename, baseDir = process.cwd()) {
47
+ const dir = getFullcycleRunDir(runId, baseDir);
48
+ const path = safeResolve(dir, filename);
49
+ if (!existsSync(path)) return null;
50
+ return readFileSync(path, 'utf8');
51
+ }
52
+
53
+ export function writeFullcycleState(runId, state, baseDir = process.cwd()) {
54
+ const payload = typeof state === 'object' && state !== null ? state : {};
55
+ const serialized = JSON.stringify(payload, null, 2);
56
+ return saveFullcycleArtifact(runId, 'state.json', serialized, baseDir);
57
+ }
58
+
59
+ export function readFullcycleState(runId, baseDir = process.cwd()) {
60
+ const content = readFullcycleArtifact(runId, 'state.json', baseDir);
61
+ if (!content) return null;
62
+ try {
63
+ return JSON.parse(content);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function findLatestInterviewPlan(baseDir = process.cwd()) {
70
+ const plansDir = safeResolve(baseDir, TFX_PLANS_DIR);
71
+ if (!existsSync(plansDir)) return null;
72
+
73
+ const candidates = readdirSync(plansDir)
74
+ .filter((name) => /^interview-.*\.md$/i.test(name))
75
+ .map((name) => {
76
+ const path = join(plansDir, name);
77
+ const stats = statSync(path);
78
+ return { name, path, mtimeMs: stats.mtimeMs };
79
+ })
80
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
81
+
82
+ return candidates[0]?.path || null;
83
+ }
84
+
85
+ export function shouldStopQaLoop(failureHistory = [], maxRepeats = 3) {
86
+ if (!Array.isArray(failureHistory) || maxRepeats <= 1) return false;
87
+
88
+ const normalized = failureHistory
89
+ .map((entry) => String(entry ?? '').trim())
90
+ .filter(Boolean);
91
+
92
+ if (normalized.length < maxRepeats) return false;
93
+ const target = normalized.at(-1);
94
+ const tail = normalized.slice(-maxRepeats);
95
+ return tail.every((entry) => entry === target);
96
+ }