triflux 9.7.14 → 9.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bin/triflux.mjs +306 -47
- package/hooks/hook-registry.json +1 -1
- 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 +165 -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/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,68 @@ 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
|
+
// 패턴 A: 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
|
+
// 패턴 B: orchestrator 엔트리 내부의 개별 훅 제거
|
|
2374
|
+
for (const entry of fixedSettings.hooks[event]) {
|
|
2375
|
+
if (!Array.isArray(entry.hooks) || entry.hooks.length <= 1) continue;
|
|
2376
|
+
const beforeInner = entry.hooks.length;
|
|
2377
|
+
entry.hooks = entry.hooks.filter(
|
|
2378
|
+
(h) => typeof h?.command === "string" && h.command.includes("hook-orchestrator"),
|
|
2379
|
+
);
|
|
2380
|
+
removed += beforeInner - entry.hooks.length;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (removed > 0) {
|
|
2384
|
+
writeFileSync(settingsPath, JSON.stringify(fixedSettings, null, 2) + "\n", "utf8");
|
|
2385
|
+
ok(`중복 훅 ${removed}개 엔트리 제거됨 (orchestrator가 체이닝)`);
|
|
2386
|
+
const rechecked = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
2387
|
+
coverage = computeHookCoverage(rechecked, managedHooks);
|
|
2388
|
+
}
|
|
2389
|
+
} catch (error) {
|
|
2390
|
+
warn(`중복 훅 자동 제거 실패: ${error.message}`);
|
|
2391
|
+
}
|
|
2392
|
+
} else {
|
|
2393
|
+
warn(`중복 훅 ${coverage.duplicates.length}개 감지 (이중 실행됨): ${coverage.duplicates.join(", ")}`);
|
|
2394
|
+
warn("tfx doctor --fix 로 자동 제거하세요.");
|
|
2395
|
+
issues += coverage.duplicates.length;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2193
2399
|
report.hook_coverage = coverage;
|
|
2194
|
-
const coverageStatus = coverage.missing.length === 0 ? "ok" : "issues";
|
|
2400
|
+
const coverageStatus = coverage.missing.length === 0 && (!coverage.duplicates || coverage.duplicates.length === 0) ? "ok" : "issues";
|
|
2195
2401
|
addDoctorCheck(report, {
|
|
2196
2402
|
name: "hook-coverage",
|
|
2197
2403
|
status: coverageStatus,
|
|
2198
2404
|
total: coverage.total,
|
|
2199
2405
|
registered: coverage.registered,
|
|
2200
2406
|
missing: coverage.missing,
|
|
2407
|
+
duplicates: coverage.duplicates || [],
|
|
2201
2408
|
...(coverage.missing.length > 0 ? { fix: "tfx doctor --fix 또는 tfx setup" } : {}),
|
|
2409
|
+
...(coverage.duplicates?.length > 0 ? { fix: "tfx doctor --fix 로 중복 훅 제거" } : {}),
|
|
2202
2410
|
});
|
|
2203
2411
|
|
|
2204
|
-
if (coverage.missing.length === 0) {
|
|
2412
|
+
if (coverage.missing.length === 0 && (!coverage.duplicates || coverage.duplicates.length === 0)) {
|
|
2205
2413
|
ok(`Hook Coverage: ${coverage.registered}/${coverage.total} registered`);
|
|
2206
|
-
} else {
|
|
2414
|
+
} else if (coverage.missing.length > 0) {
|
|
2207
2415
|
fail(`Missing hooks: ${coverage.missing.join(", ")}`);
|
|
2208
2416
|
issues += coverage.missing.length;
|
|
2209
2417
|
}
|
|
@@ -2382,26 +2590,94 @@ function cmdUpdate() {
|
|
|
2382
2590
|
ok(`버전: v${oldVer} (이미 최신)`);
|
|
2383
2591
|
}
|
|
2384
2592
|
|
|
2385
|
-
//
|
|
2593
|
+
// ── Post-update: 캐시 갱신 (삭제 → 재생성) ──
|
|
2594
|
+
console.log(`\n${CYAN}── 캐시 갱신 ──${RESET}`);
|
|
2595
|
+
{
|
|
2596
|
+
const cacheDir = join(CLAUDE_DIR, "cache");
|
|
2597
|
+
// stale 캐시 삭제
|
|
2598
|
+
for (const name of ["tfx-preflight.json", "mcp-inventory.json"]) {
|
|
2599
|
+
const p = join(cacheDir, name);
|
|
2600
|
+
if (existsSync(p)) { try { unlinkSync(p); } catch {} }
|
|
2601
|
+
}
|
|
2602
|
+
// tmpdir 상태 파일 정리
|
|
2603
|
+
for (const name of ["tfx-multi-state.json"]) {
|
|
2604
|
+
const p = join(tmpdir(), name);
|
|
2605
|
+
if (existsSync(p)) { try { unlinkSync(p); } catch {} }
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// preflight 캐시 재생성
|
|
2609
|
+
const preflightScript = join(PKG_ROOT, "scripts", "preflight-cache.mjs");
|
|
2610
|
+
if (existsSync(preflightScript)) {
|
|
2611
|
+
try {
|
|
2612
|
+
execSync(`node "${preflightScript}"`, { encoding: "utf8", timeout: 15000, windowsHide: true, stdio: "pipe" });
|
|
2613
|
+
ok("preflight 캐시 재생성 완료");
|
|
2614
|
+
} catch (e) {
|
|
2615
|
+
warn(`preflight 캐시 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// MCP 인벤토리 캐시 재생성
|
|
2620
|
+
const mcpCheckScript = join(PKG_ROOT, "scripts", "mcp-check.mjs");
|
|
2621
|
+
if (existsSync(mcpCheckScript)) {
|
|
2622
|
+
try {
|
|
2623
|
+
execSync(`node "${mcpCheckScript}"`, { encoding: "utf8", timeout: 10000, windowsHide: true, stdio: "pipe" });
|
|
2624
|
+
ok("MCP 인벤토리 캐시 재생성 완료");
|
|
2625
|
+
} catch (e) {
|
|
2626
|
+
warn(`MCP 인벤토리 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// ── Post-update: 핵심 파일 무결성 검증 ──
|
|
2632
|
+
console.log(`\n${CYAN}── 무결성 검증 ──${RESET}`);
|
|
2633
|
+
{
|
|
2634
|
+
const criticalFiles = [
|
|
2635
|
+
{ path: join(PKG_ROOT, "hooks", "hook-orchestrator.mjs"), label: "hook-orchestrator" },
|
|
2636
|
+
{ path: join(PKG_ROOT, "hooks", "hook-registry.json"), label: "hook-registry" },
|
|
2637
|
+
{ path: join(PKG_ROOT, "hooks", "safety-guard.mjs"), label: "safety-guard" },
|
|
2638
|
+
{ path: join(PKG_ROOT, "scripts", "keyword-detector.mjs"), label: "keyword-detector" },
|
|
2639
|
+
{ path: join(PKG_ROOT, "scripts", "setup.mjs"), label: "setup" },
|
|
2640
|
+
{ path: join(PKG_ROOT, "bin", "triflux.mjs"), label: "triflux CLI" },
|
|
2641
|
+
];
|
|
2642
|
+
let missing = 0;
|
|
2643
|
+
for (const { path: fp, label } of criticalFiles) {
|
|
2644
|
+
if (!existsSync(fp)) {
|
|
2645
|
+
fail(`누락: ${label} (${formatPathForDisplay(fp)})`);
|
|
2646
|
+
missing++;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
if (missing > 0) {
|
|
2650
|
+
fail(`핵심 파일 ${missing}개 누락 — npm install -g triflux@latest 재설치 필요`);
|
|
2651
|
+
} else {
|
|
2652
|
+
ok(`핵심 파일 ${criticalFiles.length}개 확인 완료`);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
// ── Post-update: 설정 동기화 ──
|
|
2386
2657
|
console.log(`\n${CYAN}── 설정 동기화 ──${RESET}`);
|
|
2387
2658
|
cmdSetup({ fromUpdate: true, overrideVersion: newVer });
|
|
2388
2659
|
|
|
2389
|
-
//
|
|
2390
|
-
|
|
2660
|
+
// ── Post-update: 훅 오케스트레이터 적용 ──
|
|
2661
|
+
{
|
|
2391
2662
|
const hookMgrPath = join(PKG_ROOT, "hooks", "hook-manager.mjs");
|
|
2392
2663
|
if (existsSync(hookMgrPath)) {
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2664
|
+
try {
|
|
2665
|
+
const result = execSync(`node "${hookMgrPath}" apply`, {
|
|
2666
|
+
encoding: "utf8",
|
|
2667
|
+
timeout: 10000,
|
|
2668
|
+
windowsHide: true,
|
|
2669
|
+
}).trim();
|
|
2670
|
+
const parsed = JSON.parse(result);
|
|
2671
|
+
if (parsed?.status === "applied") {
|
|
2672
|
+
ok(`훅 오케스트레이터 적용 (${parsed.events?.length || 0}개 이벤트)`);
|
|
2673
|
+
}
|
|
2674
|
+
} catch (e) {
|
|
2675
|
+
warn(`훅 오케스트레이터 적용 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
|
|
2676
|
+
warn("tfx hooks apply 로 수동 적용하세요.");
|
|
2401
2677
|
}
|
|
2678
|
+
} else {
|
|
2679
|
+
fail("hook-manager.mjs 누락 — 훅 오케스트레이터 적용 불가");
|
|
2402
2680
|
}
|
|
2403
|
-
} catch {
|
|
2404
|
-
// apply 실패 시 무시 — ensureHooksInSettings이 개별 훅을 이미 등록함
|
|
2405
2681
|
}
|
|
2406
2682
|
|
|
2407
2683
|
if (stoppedHubInfo) {
|
|
@@ -2951,38 +3227,20 @@ function startHubAfterUpdate(info) {
|
|
|
2951
3227
|
}
|
|
2952
3228
|
|
|
2953
3229
|
// 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
|
|
2954
|
-
function autoRegisterMcp(mcpUrl) {
|
|
3230
|
+
function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
|
|
2955
3231
|
section("MCP 자동 등록");
|
|
2956
3232
|
|
|
2957
|
-
// Codex —
|
|
3233
|
+
// Codex — config.json에 기본 disabled 엔트리로 등록
|
|
2958
3234
|
if (which("codex")) {
|
|
2959
3235
|
try {
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
if (
|
|
2963
|
-
ok(
|
|
3236
|
+
const result = ensureCodexHubServerConfig({ mcpUrl, createIfMissing: true, enabled: codexEnabled });
|
|
3237
|
+
if (!result.ok) throw new Error(result.reason || "unknown");
|
|
3238
|
+
if (result.changed) {
|
|
3239
|
+
ok(`Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})`);
|
|
2964
3240
|
} else {
|
|
2965
|
-
|
|
2966
|
-
ok("Codex: MCP 등록 완료");
|
|
3241
|
+
ok(`Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})`);
|
|
2967
3242
|
}
|
|
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
|
-
}
|
|
3243
|
+
} catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
|
|
2986
3244
|
} else {
|
|
2987
3245
|
info("Codex: 미설치 (건너뜀)");
|
|
2988
3246
|
}
|
|
@@ -3072,6 +3330,7 @@ async function cmdHub(args = [], options = {}) {
|
|
|
3072
3330
|
try {
|
|
3073
3331
|
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
3074
3332
|
process.kill(info.pid, 0); // 프로세스 존재 확인
|
|
3333
|
+
autoRegisterMcp(info.url, { codexEnabled: true });
|
|
3075
3334
|
console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
|
|
3076
3335
|
return;
|
|
3077
3336
|
} catch {
|
|
@@ -3115,7 +3374,7 @@ async function cmdHub(args = [], options = {}) {
|
|
|
3115
3374
|
console.log(` PID: ${hubInfo.pid}`);
|
|
3116
3375
|
console.log(` DB: ${DIM}${getPipelineStateDbPath(PKG_ROOT)}${RESET}`);
|
|
3117
3376
|
console.log("");
|
|
3118
|
-
autoRegisterMcp(hubInfo.url);
|
|
3377
|
+
autoRegisterMcp(hubInfo.url, { codexEnabled: true });
|
|
3119
3378
|
console.log("");
|
|
3120
3379
|
} else {
|
|
3121
3380
|
// 직접 포그라운드 모드로 안내
|
package/hooks/hook-registry.json
CHANGED
|
@@ -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
|
+
}
|
package/hub/paths.mjs
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
// hub/paths.mjs — triflux 워킹 디렉토리 경로 상수
|
|
2
|
-
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
export const TFX_WORK_DIR = '.tfx';
|
|
7
|
-
export const TFX_PLANS_DIR = join(TFX_WORK_DIR, 'plans');
|
|
8
|
-
export const TFX_REPORTS_DIR = join(TFX_WORK_DIR, 'reports');
|
|
9
|
-
export const TFX_HANDOFFS_DIR = join(TFX_WORK_DIR, 'handoffs');
|
|
10
|
-
export const TFX_LOGS_DIR = join(TFX_WORK_DIR, 'logs');
|
|
11
|
-
export const TFX_STATE_DIR = join(TFX_WORK_DIR, 'state');
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
1
|
+
// hub/paths.mjs — triflux 워킹 디렉토리 경로 상수
|
|
2
|
+
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const TFX_WORK_DIR = '.tfx';
|
|
7
|
+
export const TFX_PLANS_DIR = join(TFX_WORK_DIR, 'plans');
|
|
8
|
+
export const TFX_REPORTS_DIR = join(TFX_WORK_DIR, 'reports');
|
|
9
|
+
export const TFX_HANDOFFS_DIR = join(TFX_WORK_DIR, 'handoffs');
|
|
10
|
+
export const TFX_LOGS_DIR = join(TFX_WORK_DIR, 'logs');
|
|
11
|
+
export const TFX_STATE_DIR = join(TFX_WORK_DIR, 'state');
|
|
12
|
+
export const TFX_FULLCYCLE_DIR = join(TFX_WORK_DIR, 'fullcycle');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* triflux 워킹 디렉토리 구조를 보장한다.
|
|
16
|
+
* @param {string} baseDir
|
|
17
|
+
*/
|
|
18
|
+
export function ensureTfxDirs(baseDir) {
|
|
19
|
+
for (const relativeDir of [
|
|
20
|
+
TFX_WORK_DIR,
|
|
21
|
+
TFX_PLANS_DIR,
|
|
22
|
+
TFX_REPORTS_DIR,
|
|
23
|
+
TFX_HANDOFFS_DIR,
|
|
24
|
+
TFX_LOGS_DIR,
|
|
25
|
+
TFX_STATE_DIR,
|
|
26
|
+
TFX_FULLCYCLE_DIR,
|
|
27
|
+
]) {
|
|
28
|
+
mkdirSync(join(baseDir, relativeDir), { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|