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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bin/triflux.mjs +297 -47
- package/hooks/hook-registry.json +4 -4
- package/hub/fullcycle.mjs +96 -0
- package/hub/paths.mjs +30 -28
- package/hub/pipeline/index.mjs +318 -318
- package/hub/schema.sql +146 -146
- package/hub/team/cli/commands/kill.mjs +37 -37
- package/hub/team/cli/commands/stop.mjs +31 -31
- package/hub/team/cli/commands/task.mjs +30 -30
- package/hub/team/cli/services/hub-client.mjs +208 -208
- package/hub/team/cli/services/native-control.mjs +118 -118
- package/hub/team/cli/services/runtime-mode.mjs +62 -62
- package/hub/team/cli/services/state-store.mjs +48 -48
- package/hub/team/dashboard.mjs +274 -274
- package/hub/team/native.mjs +649 -649
- package/hub/team/psmux.mjs +68 -13
- package/hub/tools.mjs +554 -554
- package/hub/workers/claude-worker.mjs +423 -423
- package/hub/workers/codex-mcp.mjs +410 -410
- package/hub/workers/gemini-worker.mjs +429 -429
- package/hub/workers/interface.mjs +40 -40
- package/package.json +1 -1
- package/scripts/__tests__/remote-spawn-transfer.test.mjs +1 -1
- package/scripts/cache-warmup.mjs +1 -0
- package/scripts/claude-logged.ps1 +54 -0
- package/scripts/demo-tui.mjs +59 -0
- package/scripts/headless-guard.mjs +4 -7
- package/scripts/hub-ensure.mjs +120 -120
- package/scripts/lib/psmux-info.mjs +119 -0
- package/scripts/lib/remote-spawn-transfer.mjs +1 -1
- package/scripts/setup.mjs +150 -6
- package/scripts/tfx-route-post.mjs +90 -13
- package/scripts/token-snapshot.mjs +575 -575
- package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
- package/skills/.omc/state/idle-notif-cooldown.json +3 -0
- package/skills/.omc/state/last-tool-error.json +7 -0
- package/skills/.omc/state/subagent-tracking.json +7 -0
- package/skills/tfx-codex-swarm/SKILL.md +40 -5
- package/skills/tfx-codex-swarm/mcp-daemon/register-autostart.ps1 +32 -0
- package/skills/tfx-doctor/SKILL.md +3 -0
- package/skills/tfx-fullcycle/SKILL.md +79 -4
- package/skills/tfx-hub/SKILL.md +3 -1
- package/skills/tfx-psmux-rules/SKILL.md +53 -31
- package/skills/tfx-remote-spawn/references/hosts.json +16 -16
- package/skills/tfx-setup/SKILL.md +9 -0
- 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.
|
|
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.
|
|
33
|
+
"version": "9.7.14"
|
|
34
34
|
}
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2390
|
-
|
|
2651
|
+
// ── Post-update: 훅 오케스트레이터 적용 ──
|
|
2652
|
+
{
|
|
2391
2653
|
const hookMgrPath = join(PKG_ROOT, "hooks", "hook-manager.mjs");
|
|
2392
2654
|
if (existsSync(hookMgrPath)) {
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
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 —
|
|
3224
|
+
// Codex — config.json에 기본 disabled 엔트리로 등록
|
|
2958
3225
|
if (which("codex")) {
|
|
2959
3226
|
try {
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
if (
|
|
2963
|
-
ok(
|
|
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
|
-
|
|
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
|
// 직접 포그라운드 모드로 안내
|
package/hooks/hook-registry.json
CHANGED
|
@@ -143,11 +143,11 @@
|
|
|
143
143
|
"source": "triflux",
|
|
144
144
|
"matcher": "*",
|
|
145
145
|
"command": "node \"${PLUGIN_ROOT}/scripts/preflight-cache.mjs\"",
|
|
146
|
-
"priority":
|
|
146
|
+
"priority": 3,
|
|
147
147
|
"enabled": true,
|
|
148
|
-
"timeout":
|
|
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":
|
|
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
|
+
}
|