triflux 8.11.2 → 8.12.2

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 (103) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/hooks/hooks.json +56 -51
  5. package/hub/pipeline/index.mjs +318 -318
  6. package/hub/routing/q-learning.mjs +2 -1
  7. package/hub/schema.sql +146 -146
  8. package/hub/team/cli/commands/kill.mjs +37 -37
  9. package/hub/team/cli/commands/start/index.mjs +3 -2
  10. package/hub/team/cli/commands/start/parse-args.mjs +5 -0
  11. package/hub/team/cli/commands/start/start-headless.mjs +2 -1
  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/help.mjs +41 -40
  15. package/hub/team/cli/services/hub-client.mjs +208 -208
  16. package/hub/team/cli/services/native-control.mjs +118 -118
  17. package/hub/team/cli/services/runtime-mode.mjs +62 -62
  18. package/hub/team/cli/services/state-store.mjs +48 -48
  19. package/hub/team/dashboard-anchor.mjs +14 -0
  20. package/hub/team/dashboard.mjs +274 -274
  21. package/hub/team/headless.mjs +44 -19
  22. package/hub/team/native.mjs +649 -649
  23. package/hub/tools.mjs +554 -554
  24. package/hub/workers/delegator-mcp.mjs +21 -1
  25. package/package.json +1 -1
  26. package/scripts/__tests__/remote-spawn-transfer.test.mjs +117 -0
  27. package/scripts/__tests__/remote-spawn.test.mjs +78 -0
  28. package/scripts/cache-buildup.mjs +401 -0
  29. package/scripts/headless-guard.mjs +10 -3
  30. package/scripts/hub-ensure.mjs +120 -120
  31. package/scripts/lib/mcp-filter.mjs +720 -720
  32. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
  33. package/scripts/mcp-check.mjs +237 -127
  34. package/scripts/mcp-gateway-ensure.mjs +6 -8
  35. package/scripts/mcp-gateway-integration-test.mjs +228 -0
  36. package/scripts/mcp-gateway-start.mjs +42 -16
  37. package/scripts/mcp-gateway-start.ps1 +17 -14
  38. package/scripts/mcp-gateway-verify.mjs +1 -1
  39. package/scripts/preflight-cache.mjs +137 -137
  40. package/scripts/remote-spawn.mjs +404 -40
  41. package/scripts/setup.mjs +18 -0
  42. package/scripts/tfx-route-worker.mjs +165 -165
  43. package/scripts/tfx-route.sh +85 -16
  44. package/scripts/token-snapshot.mjs +575 -575
  45. package/skills/tfx-analysis/SKILL.md +101 -101
  46. package/skills/tfx-autopilot/SKILL.md +112 -112
  47. package/skills/tfx-autoresearch/SKILL.md +1 -0
  48. package/skills/tfx-autoroute/SKILL.md +184 -184
  49. package/skills/tfx-codex-swarm/SKILL.md +449 -0
  50. package/skills/tfx-codex-swarm/evals/evals.json +26 -0
  51. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +33 -0
  52. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/eval_metadata.json +42 -0
  53. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +11 -0
  54. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/analysis.md +87 -0
  55. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/classification.md +35 -0
  56. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/commands.sh +275 -0
  57. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/routing.md +56 -0
  58. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/timing.json +5 -0
  59. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +11 -0
  60. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/analysis.md +92 -0
  61. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/classification.md +71 -0
  62. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/commands.sh +264 -0
  63. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/routing.md +113 -0
  64. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/timing.json +5 -0
  65. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/eval_metadata.json +32 -0
  66. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +9 -0
  67. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/analysis.md +96 -0
  68. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/classification.md +38 -0
  69. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/commands.sh +151 -0
  70. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/routing.md +51 -0
  71. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/timing.json +5 -0
  72. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +9 -0
  73. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/analysis.md +127 -0
  74. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/classification.md +57 -0
  75. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/commands.sh +129 -0
  76. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/routing.md +84 -0
  77. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/timing.json +5 -0
  78. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/eval_metadata.json +27 -0
  79. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +8 -0
  80. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/analysis.md +98 -0
  81. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/classification.md +65 -0
  82. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/commands.sh +123 -0
  83. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/routing.md +66 -0
  84. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/timing.json +5 -0
  85. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +8 -0
  86. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/analysis.md +88 -0
  87. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/classification.md +40 -0
  88. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/commands.sh +130 -0
  89. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/routing.md +61 -0
  90. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/timing.json +5 -0
  91. package/skills/tfx-profile/SKILL.md +141 -51
  92. package/skills/tfx-qa/SKILL.md +117 -117
  93. package/skills/tfx-remote-spawn/references/hosts.json +16 -16
  94. package/skills/tfx-review/SKILL.md +51 -51
  95. package/skills/tfx-setup/SKILL.md +20 -0
  96. package/tui/gemini-profile.mjs +254 -0
  97. package/tui/setup.mjs +36 -1
  98. package/scripts/claude-logged.ps1 +0 -54
  99. package/scripts/demo-tui.mjs +0 -59
  100. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +0 -1
  101. package/skills/.omc/state/idle-notif-cooldown.json +0 -3
  102. package/skills/.omc/state/last-tool-error.json +0 -7
  103. package/skills/.omc/state/subagent-tracking.json +0 -7
@@ -39,6 +39,7 @@ const DEFAULT_ROUTE_TIMEOUT_SEC = 120;
39
39
  const DIRECT_PROGRESS_START = 5;
40
40
  const DIRECT_PROGRESS_RUNNING = 60;
41
41
  const DIRECT_PROGRESS_DONE = 100;
42
+ const SEARCH_ENGINE_CACHE_PATH = ['.omc', 'state', 'search-engines.json'];
42
43
 
43
44
  const AGENT_TIMEOUT_SEC = Object.freeze({
44
45
  executor: 1080,
@@ -197,6 +198,22 @@ function joinInstructions(...values) {
197
198
  .trim();
198
199
  }
199
200
 
201
+ function loadAvailableServersFromSearchEngineCache(cwd = process.cwd()) {
202
+ const cacheFile = resolve(cwd, ...SEARCH_ENGINE_CACHE_PATH);
203
+ if (!existsSync(cacheFile)) return null;
204
+
205
+ try {
206
+ const parsed = JSON.parse(readFileSync(cacheFile, 'utf8'));
207
+ if (!Array.isArray(parsed?.engines)) return [];
208
+ return parsed.engines
209
+ .filter((engine) => engine?.status === 'available')
210
+ .map((engine) => (typeof engine?.name === 'string' ? engine.name.trim() : ''))
211
+ .filter(Boolean);
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
200
217
  function parseRouteType(stderr = '') {
201
218
  const match = stderr.match(/type=([a-z-]+)/);
202
219
  if (!match) return null;
@@ -630,13 +647,16 @@ export class DelegatorMcpWorker {
630
647
  }
631
648
 
632
649
  _getMcpPolicyOptions(args) {
650
+ const cachedAvailableServers = loadAvailableServersFromSearchEngineCache(args.cwd || this.cwd);
633
651
  return {
634
652
  agentType: args.agentType || 'executor',
635
653
  requestedProfile: args.mcpProfile || 'auto',
636
654
  searchTool: args.searchTool,
637
655
  workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
638
656
  taskText: withContext(String(args.prompt ?? ''), args.contextFile),
639
- availableServers: Array.isArray(args.availableServers) ? args.availableServers : undefined,
657
+ availableServers: Array.isArray(args.availableServers)
658
+ ? args.availableServers
659
+ : cachedAvailableServers ?? undefined,
640
660
  };
641
661
  }
642
662
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "8.11.2",
3
+ "version": "8.12.2",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,117 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import {
8
+ buildRemoteTransferPlan,
9
+ extractExplicitFileTokens,
10
+ } from "../lib/remote-spawn-transfer.mjs";
11
+
12
+ function makeTempDir() {
13
+ return mkdtempSync(join(tmpdir(), "tfx-remote-spawn-transfer-"));
14
+ }
15
+
16
+ describe("remote-spawn transfer plan", () => {
17
+ it("extractExplicitFileTokens는 명시적 파일 토큰만 추출한다", () => {
18
+ const source = [
19
+ "Read [PRD](.omx/plans/prd.md) and `docs/spec.md`.",
20
+ "Also open \"./notes.txt\" and 'http://example.com'.",
21
+ ].join("\n");
22
+
23
+ const tokens = extractExplicitFileTokens(source);
24
+
25
+ assert.deepEqual(tokens.sort(), ["./notes.txt", ".omx/plans/prd.md", "docs/spec.md"].sort());
26
+ });
27
+
28
+ it("handoff + 참조 파일 전송 계획과 프롬프트 재작성", () => {
29
+ const root = makeTempDir();
30
+ try {
31
+ mkdirSync(join(root, "docs"), { recursive: true });
32
+ writeFileSync(join(root, "prd.md"), "prd body", "utf8");
33
+ writeFileSync(join(root, "docs", "spec.md"), "spec body", "utf8");
34
+ writeFileSync(
35
+ join(root, "handoff.md"),
36
+ [
37
+ "Open \"./prd.md\" before anything else.",
38
+ "Then inspect `docs/spec.md` and [again](docs/spec.md).",
39
+ ].join("\n"),
40
+ "utf8",
41
+ );
42
+
43
+ const plan = buildRemoteTransferPlan({
44
+ cwd: root,
45
+ handoffPath: "handoff.md",
46
+ maxBytes: 1024 * 1024,
47
+ remoteStageRoot: "/remote/stage/abc123",
48
+ userPrompt: "continue with execution",
49
+ });
50
+
51
+ assert.equal(plan.transfers.length, 3);
52
+ assert.equal(plan.transfers[0].type, "handoff");
53
+ assert.equal(plan.transfers[1].type, "reference");
54
+ assert.equal(plan.transfers[2].type, "reference");
55
+
56
+ assert.match(plan.prompt, /Staged handoff file: \/remote\/stage\/abc123\/handoff\/handoff\.md/);
57
+ assert.match(plan.prompt, /\/remote\/stage\/abc123\/refs\/\d{2}-prd\.md/);
58
+ assert.match(plan.prompt, /\/remote\/stage\/abc123\/refs\/\d{2}-spec\.md/);
59
+ assert.equal(plan.prompt.includes("./prd.md"), false);
60
+ assert.equal(plan.prompt.includes("docs/spec.md"), false);
61
+ assert.match(plan.prompt, /---\n\ncontinue with execution$/);
62
+ } finally {
63
+ rmSync(root, { force: true, recursive: true });
64
+ }
65
+ });
66
+
67
+ it("누락된 참조 파일은 SSH 전 로컬에서 실패한다", () => {
68
+ const root = makeTempDir();
69
+ try {
70
+ writeFileSync(join(root, "handoff.md"), "Read `./missing.md`", "utf8");
71
+ assert.throws(
72
+ () => buildRemoteTransferPlan({
73
+ cwd: root,
74
+ handoffPath: "handoff.md",
75
+ maxBytes: 1024 * 1024,
76
+ remoteStageRoot: "/remote/stage/abc123",
77
+ }),
78
+ /referenced file not found:/,
79
+ );
80
+ } finally {
81
+ rmSync(root, { force: true, recursive: true });
82
+ }
83
+ });
84
+
85
+ it("MAX_HANDOFF_BYTES 제한을 handoff와 참조 파일 모두에 적용한다", () => {
86
+ const root = makeTempDir();
87
+ try {
88
+ writeFileSync(join(root, "big-ref.txt"), "x".repeat(32), "utf8");
89
+ writeFileSync(join(root, "handoff.md"), "Open ./big-ref.txt", "utf8");
90
+
91
+ assert.throws(
92
+ () => buildRemoteTransferPlan({
93
+ cwd: root,
94
+ handoffPath: "handoff.md",
95
+ maxBytes: 16,
96
+ remoteStageRoot: "/remote/stage/abc123",
97
+ }),
98
+ /referenced file too large:/,
99
+ );
100
+ } finally {
101
+ rmSync(root, { force: true, recursive: true });
102
+ }
103
+ });
104
+
105
+ it("handoff가 없으면 기존 prompt 전달 동작을 유지한다", () => {
106
+ const plan = buildRemoteTransferPlan({
107
+ handoffPath: null,
108
+ maxBytes: 1024,
109
+ remoteStageRoot: "/remote/stage/abc123",
110
+ userPrompt: "only prompt",
111
+ });
112
+
113
+ assert.equal(plan.prompt, "only prompt");
114
+ assert.deepEqual(plan.transfers, []);
115
+ assert.equal(plan.stagedHandoffPath, null);
116
+ });
117
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { startSpawnExitWatcher, watchSpawnSessionExit } from "../remote-spawn.mjs";
5
+
6
+ describe("remote-spawn watcher", () => {
7
+ it("lead pane dead 상태가 grace 기간 유지되면 세션을 정리한다", async () => {
8
+ let nowMs = 0;
9
+ let pollIndex = 0;
10
+ const killed = [];
11
+ const statuses = [
12
+ { isDead: false, exitCode: null },
13
+ { isDead: true, exitCode: 0 },
14
+ { isDead: true, exitCode: 0 },
15
+ { isDead: true, exitCode: 0 },
16
+ ];
17
+
18
+ const result = await watchSpawnSessionExit("tfx-spawn-unit", {
19
+ pollMs: 100,
20
+ graceMs: 200,
21
+ maxWaitMs: 1000,
22
+ sessionExists: () => true,
23
+ getPaneStatus: () => statuses[Math.min(pollIndex++, statuses.length - 1)],
24
+ killSession: (name) => killed.push(name),
25
+ now: () => nowMs,
26
+ sleep: async (ms) => { nowMs += ms; },
27
+ });
28
+
29
+ assert.equal(result.cleaned, true);
30
+ assert.equal(result.reason, "pane-dead");
31
+ assert.deepEqual(killed, ["tfx-spawn-unit"]);
32
+ });
33
+
34
+ it("live session은 정리하지 않고 timeout으로 종료한다", async () => {
35
+ let nowMs = 0;
36
+ let killCount = 0;
37
+
38
+ const result = await watchSpawnSessionExit("tfx-spawn-timeout", {
39
+ pollMs: 100,
40
+ graceMs: 200,
41
+ maxWaitMs: 250,
42
+ sessionExists: () => true,
43
+ getPaneStatus: () => ({ isDead: false, exitCode: null }),
44
+ killSession: () => { killCount += 1; },
45
+ now: () => nowMs,
46
+ sleep: async (ms) => { nowMs += ms; },
47
+ });
48
+
49
+ assert.equal(result.cleaned, false);
50
+ assert.equal(result.reason, "timeout");
51
+ assert.equal(killCount, 0);
52
+ });
53
+
54
+ it("detached watcher를 --watch-exit 인자로 실행한다", () => {
55
+ const calls = [];
56
+ const started = startSpawnExitWatcher("tfx-spawn-detached", {
57
+ force: true,
58
+ execPath: "node-test",
59
+ scriptPath: "C:/tmp/remote-spawn.mjs",
60
+ spawnFn: (file, args, options) => {
61
+ calls.push({ file, args, options, unrefCalled: false });
62
+ return {
63
+ unref() {
64
+ calls[0].unrefCalled = true;
65
+ },
66
+ };
67
+ },
68
+ });
69
+
70
+ assert.equal(started, true);
71
+ assert.equal(calls.length, 1);
72
+ assert.equal(calls[0].file, "node-test");
73
+ assert.deepEqual(calls[0].args, ["C:/tmp/remote-spawn.mjs", "--watch-exit", "tfx-spawn-detached"]);
74
+ assert.equal(calls[0].options.detached, true);
75
+ assert.equal(calls[0].options.stdio, "ignore");
76
+ assert.equal(calls[0].unrefCalled, true);
77
+ });
78
+ });
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env node
2
+ // scripts/cache-buildup.mjs — Phase 1 캐시 빌드업
3
+ // setup.mjs에서 백그라운드 스폰. CWD의 .omc/cache/ 및 .omc/state/에 기록.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { execSync } from "node:child_process";
7
+ import { join, basename } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ import { readPreflightCache } from "./preflight-cache.mjs";
11
+ import { SEARCH_SERVER_ORDER, MCP_SERVER_DOMAIN_TAGS } from "./lib/mcp-server-catalog.mjs";
12
+
13
+ const CWD = process.cwd();
14
+ const CACHE_DIR = join(CWD, ".omc", "cache");
15
+ const STATE_DIR = join(CWD, ".omc", "state");
16
+ const HOME = homedir();
17
+
18
+ function ensureDirs() {
19
+ mkdirSync(CACHE_DIR, { recursive: true });
20
+ mkdirSync(STATE_DIR, { recursive: true });
21
+ }
22
+
23
+ function writeJSON(path, data) {
24
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
25
+ }
26
+
27
+ // ── 1. Codex 스킬 스캔 (Task D: 일반화된 발견) ──
28
+
29
+ const ROLE_KEYWORDS = {
30
+ plan: ["plan", "계획", "decompos", "strategy", "설계"],
31
+ auto: ["autonomous", "자율", "auto-execute", "autopilot", "full auto"],
32
+ persist: ["loop", "반복", "completion", "persist", "until", "끝까지"],
33
+ investigate: ["investigate", "research", "조사", "분석", "analysis"],
34
+ review: ["review", "리뷰", "inspect", "검수"],
35
+ };
36
+
37
+ function classifyRole(description) {
38
+ const lower = (description || "").toLowerCase();
39
+ for (const [role, keywords] of Object.entries(ROLE_KEYWORDS)) {
40
+ if (keywords.some((kw) => lower.includes(kw))) return role;
41
+ }
42
+ return "general";
43
+ }
44
+
45
+ function parseSkillFrontmatter(content) {
46
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
47
+ if (!match) return null;
48
+
49
+ const fm = match[1];
50
+ const name = fm.match(/^name:\s*(.+)$/m)?.[1]?.trim() || null;
51
+ const desc = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || null;
52
+ return name ? { name, description: desc } : null;
53
+ }
54
+
55
+ export function scanCodexSkills() {
56
+ const codexSkillsDir = join(HOME, ".codex", "skills");
57
+ const skills = [];
58
+
59
+ // 1) OMX/커스텀 스킬 (~/.codex/skills/*)
60
+ if (existsSync(codexSkillsDir)) {
61
+ for (const entry of readdirSync(codexSkillsDir, { withFileTypes: true })) {
62
+ if (!entry.isDirectory()) continue;
63
+ const skillMd = join(codexSkillsDir, entry.name, "SKILL.md");
64
+ if (!existsSync(skillMd)) continue;
65
+
66
+ try {
67
+ const content = readFileSync(skillMd, "utf8");
68
+ const fm = parseSkillFrontmatter(content);
69
+ const name = fm?.name || entry.name;
70
+ const description = fm?.description || "";
71
+ skills.push({
72
+ name,
73
+ role: classifyRole(description),
74
+ description: description.slice(0, 200),
75
+ source: "custom",
76
+ path: skillMd,
77
+ });
78
+ } catch {
79
+ skills.push({ name: entry.name, role: "general", description: "", source: "custom", path: skillMd });
80
+ }
81
+ }
82
+ }
83
+
84
+ // 2) OpenAI 빌트인 스킬 (codex에 내장된 /명령들)
85
+ const BUILTIN_SKILLS = [
86
+ { name: "web-clone", role: "general", description: "Clone and analyze web pages" },
87
+ { name: "help", role: "general", description: "Show available commands" },
88
+ { name: "note", role: "general", description: "Save notes during session" },
89
+ { name: "worker", role: "auto", description: "Spawn background worker for tasks" },
90
+ ];
91
+
92
+ for (const builtin of BUILTIN_SKILLS) {
93
+ // 이미 커스텀으로 오버라이드된 경우 스킵
94
+ if (skills.some((s) => s.name === builtin.name)) continue;
95
+ // 실제 디렉토리 존재 확인
96
+ if (existsSync(join(codexSkillsDir, builtin.name))) continue;
97
+ skills.push({ ...builtin, source: "builtin" });
98
+ }
99
+
100
+ return {
101
+ scanned_at: new Date().toISOString(),
102
+ codex_skills_dir: codexSkillsDir,
103
+ total: skills.length,
104
+ skills,
105
+ };
106
+ }
107
+
108
+ // ── 2. Tier 환경 프로브 ──
109
+
110
+ function probeCliVersion(command, runExec = execSync) {
111
+ try {
112
+ runExec(command, { stdio: "ignore", timeout: 3000, windowsHide: true });
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function resolveCachedCliCheck(preflightCheck, command, runExec = execSync) {
120
+ if (typeof preflightCheck?.ok === "boolean") {
121
+ return preflightCheck.ok;
122
+ }
123
+ return probeCliVersion(command, runExec);
124
+ }
125
+
126
+ export function probeTierEnvironment({
127
+ readPreflight = readPreflightCache,
128
+ runExec = execSync,
129
+ platform = process.platform,
130
+ } = {}) {
131
+ const preflight = readPreflight();
132
+ const checks = {
133
+ psmux: false,
134
+ hub: false,
135
+ codex: resolveCachedCliCheck(preflight?.codex, "codex --version", runExec),
136
+ gemini: resolveCachedCliCheck(preflight?.gemini, "gemini --version", runExec),
137
+ wt: false,
138
+ };
139
+
140
+ // psmux
141
+ try {
142
+ runExec("psmux --version", { stdio: "ignore", timeout: 3000, windowsHide: true });
143
+ checks.psmux = true;
144
+ } catch {}
145
+
146
+ // hub (preflight에서 가져오거나 직접 확인)
147
+ if (preflight?.hub?.ok) {
148
+ checks.hub = true;
149
+ } else {
150
+ try {
151
+ runExec("curl -sf http://127.0.0.1:27888/status", { stdio: "ignore", timeout: 2000, windowsHide: true });
152
+ checks.hub = true;
153
+ } catch {}
154
+ }
155
+
156
+ // Windows Terminal
157
+ if (platform === "win32") {
158
+ try {
159
+ runExec("where wt.exe", { stdio: "ignore", timeout: 2000, windowsHide: true });
160
+ checks.wt = true;
161
+ } catch {}
162
+ }
163
+
164
+ // Tier 분류
165
+ let tier = "minimal"; // claude-only
166
+ if (checks.codex || checks.gemini) tier = "standard"; // multi-cli
167
+ if (checks.psmux && checks.hub && (checks.codex || checks.gemini)) tier = "full"; // orchestration-ready
168
+
169
+ const agents = [];
170
+ agents.push("claude");
171
+ if (checks.codex) agents.push("codex");
172
+ if (checks.gemini) agents.push("gemini");
173
+
174
+ return {
175
+ probed_at: new Date().toISOString(),
176
+ tier,
177
+ checks,
178
+ available_agents: agents,
179
+ codex_plan: preflight?.codex_plan || { plan: "unknown" },
180
+ };
181
+ }
182
+
183
+ // ── 3. 프로젝트 메타 추출 ──
184
+
185
+ export function extractProjectMeta() {
186
+ let name = basename(CWD);
187
+ let description = "";
188
+ let lang = "unknown";
189
+ let testCmd = null;
190
+ let isGit = false;
191
+
192
+ // git
193
+ try {
194
+ const toplevel = execSync("git rev-parse --show-toplevel", {
195
+ encoding: "utf8", timeout: 3000, windowsHide: true, stdio: ["pipe", "pipe", "ignore"],
196
+ }).trim();
197
+ name = basename(toplevel);
198
+ isGit = true;
199
+ } catch {}
200
+
201
+ // package.json
202
+ const pkgPath = join(CWD, "package.json");
203
+ if (existsSync(pkgPath)) {
204
+ try {
205
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
206
+ description = pkg.description || "";
207
+ testCmd = pkg.scripts?.test || null;
208
+ lang = "JavaScript/ESM (Node.js)";
209
+ } catch {}
210
+ } else if (existsSync(join(CWD, "pyproject.toml")) || existsSync(join(CWD, "setup.py"))) {
211
+ lang = "Python";
212
+ } else if (existsSync(join(CWD, "Cargo.toml"))) {
213
+ lang = "Rust";
214
+ } else if (existsSync(join(CWD, "go.mod"))) {
215
+ lang = "Go";
216
+ }
217
+
218
+ return {
219
+ extracted_at: new Date().toISOString(),
220
+ name,
221
+ description: description.slice(0, 300),
222
+ lang,
223
+ test_cmd: testCmd,
224
+ is_git: isGit,
225
+ };
226
+ }
227
+
228
+ // ── 4. 검색 엔진 확인 ──
229
+
230
+ function loadMcpInventory() {
231
+ const invPath = join(HOME, ".claude", "cache", "mcp-inventory.json");
232
+ try {
233
+ return JSON.parse(readFileSync(invPath, "utf8"));
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function loadMcpConfigs() {
240
+ const servers = {};
241
+ // Claude 설정 파일들에서 MCP 서버 읽기
242
+ const configPaths = [
243
+ join(HOME, ".claude", "settings.json"),
244
+ join(HOME, ".claude", "settings.local.json"),
245
+ join(CWD, ".mcp.json"),
246
+ join(CWD, ".claude", ".mcp.json"),
247
+ ];
248
+
249
+ for (const cfgPath of configPaths) {
250
+ try {
251
+ const data = JSON.parse(readFileSync(cfgPath, "utf8"));
252
+ const mcpServers = data.mcpServers || {};
253
+ for (const [name, config] of Object.entries(mcpServers)) {
254
+ servers[name] = { configured: true, source: basename(cfgPath), ...config };
255
+ }
256
+ } catch {}
257
+ }
258
+ return servers;
259
+ }
260
+
261
+ export function checkSearchEngines() {
262
+ const inventory = loadMcpInventory();
263
+ const configuredServers = loadMcpConfigs();
264
+ const engines = [];
265
+
266
+ // 알려진 검색 서버 (우선순위순)
267
+ const KNOWN_SEARCH_SERVERS = [...SEARCH_SERVER_ORDER, "context7"];
268
+
269
+ // MCP 인벤토리에서 검색 가능 서버 탐지
270
+ const allServerNames = new Set(KNOWN_SEARCH_SERVERS);
271
+
272
+ // 인벤토리의 Claude MCP 서버들도 확인
273
+ if (inventory) {
274
+ for (const scope of ["codex", "gemini", "claude"]) {
275
+ const scopeData = inventory[scope];
276
+ if (!scopeData?.servers) continue;
277
+ for (const srv of scopeData.servers) {
278
+ const tags = srv.domain_tags || MCP_SERVER_DOMAIN_TAGS[srv.name] || [];
279
+ if (tags.includes("search") || tags.includes("web") || tags.includes("research")) {
280
+ allServerNames.add(srv.name);
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ // 설정된 서버도 추가
287
+ for (const name of Object.keys(configuredServers)) {
288
+ const tags = MCP_SERVER_DOMAIN_TAGS[name] || [];
289
+ if (tags.includes("search") || tags.includes("web") || KNOWN_SEARCH_SERVERS.includes(name)) {
290
+ allServerNames.add(name);
291
+ }
292
+ }
293
+
294
+ // 각 검색 서버의 상태 판정
295
+ for (const name of allServerNames) {
296
+ const configured = !!configuredServers[name];
297
+ const tags = MCP_SERVER_DOMAIN_TAGS[name] || [];
298
+
299
+ // 인벤토리에서 실제 활성 상태 확인
300
+ let inventoryStatus = null;
301
+ if (inventory) {
302
+ for (const scope of ["codex", "gemini", "claude"]) {
303
+ const srv = inventory[scope]?.servers?.find((s) => s.name === name);
304
+ if (srv) {
305
+ inventoryStatus = { scope, status: srv.status, tool_count: srv.tool_count };
306
+ break;
307
+ }
308
+ }
309
+ }
310
+
311
+ let status = "unavailable";
312
+ if (inventoryStatus?.status === "enabled" || inventoryStatus?.status === "configured" || inventoryStatus?.status === "available") {
313
+ status = "available";
314
+ } else if (configured) {
315
+ status = "configured"; // 설정은 있지만 인벤토리 미확인
316
+ }
317
+
318
+ engines.push({
319
+ name,
320
+ status,
321
+ domain_tags: tags,
322
+ configured,
323
+ inventory: inventoryStatus,
324
+ source: configuredServers[name]?.source || null,
325
+ });
326
+ }
327
+
328
+ // 우선순위순 정렬: SEARCH_SERVER_ORDER 먼저, 나머지 알파벳순
329
+ engines.sort((a, b) => {
330
+ const ai = SEARCH_SERVER_ORDER.indexOf(a.name);
331
+ const bi = SEARCH_SERVER_ORDER.indexOf(b.name);
332
+ if (ai !== -1 && bi !== -1) return ai - bi;
333
+ if (ai !== -1) return -1;
334
+ if (bi !== -1) return 1;
335
+ return a.name.localeCompare(b.name);
336
+ });
337
+
338
+ const available = engines.filter((e) => e.status === "available");
339
+ const primary = available[0]?.name || null;
340
+
341
+ return {
342
+ checked_at: new Date().toISOString(),
343
+ primary_engine: primary,
344
+ available_count: available.length,
345
+ total_count: engines.length,
346
+ engines,
347
+ };
348
+ }
349
+
350
+ // ── 메인 실행 ──
351
+
352
+ function main() {
353
+ ensureDirs();
354
+
355
+ const results = {};
356
+
357
+ try {
358
+ const skills = scanCodexSkills();
359
+ writeJSON(join(CACHE_DIR, "codex-skills.json"), skills);
360
+ results.codex_skills = { ok: true, count: skills.total };
361
+ } catch (e) {
362
+ results.codex_skills = { ok: false, error: e.message };
363
+ }
364
+
365
+ try {
366
+ const tier = probeTierEnvironment();
367
+ writeJSON(join(STATE_DIR, "tier-environment.json"), tier);
368
+ results.tier = { ok: true, tier: tier.tier };
369
+ } catch (e) {
370
+ results.tier = { ok: false, error: e.message };
371
+ }
372
+
373
+ try {
374
+ const meta = extractProjectMeta();
375
+ writeJSON(join(CACHE_DIR, "project-meta.json"), meta);
376
+ results.project_meta = { ok: true, name: meta.name };
377
+ } catch (e) {
378
+ results.project_meta = { ok: false, error: e.message };
379
+ }
380
+
381
+ try {
382
+ const search = checkSearchEngines();
383
+ writeJSON(join(STATE_DIR, "search-engines.json"), search);
384
+ results.search_engines = { ok: true, primary: search.primary_engine, available: search.available_count };
385
+ } catch (e) {
386
+ results.search_engines = { ok: false, error: e.message };
387
+ }
388
+
389
+ // 간결 stdout (hook/로그용)
390
+ const ok = Object.values(results).every((r) => r.ok);
391
+ const summary = ok ? "cache-buildup: ok" : "cache-buildup: partial";
392
+ const details = [];
393
+ if (results.codex_skills?.ok) details.push(`skills:${results.codex_skills.count}`);
394
+ if (results.tier?.ok) details.push(`tier:${results.tier.tier}`);
395
+ if (results.search_engines?.ok) details.push(`search:${results.search_engines.primary || "none"}(${results.search_engines.available})`);
396
+ console.log(details.length ? `${summary} (${details.join(", ")})` : summary);
397
+ }
398
+
399
+ if (process.argv[1]?.endsWith("cache-buildup.mjs")) {
400
+ main();
401
+ }
@@ -152,6 +152,11 @@ function deny(reason) {
152
152
  process.exit(2);
153
153
  }
154
154
 
155
+ const HEADLESS_FALLBACK_COMMAND =
156
+ 'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")';
157
+ const DIRECT_CLI_BYPASS_HINT =
158
+ "로컬 디버깅이 목적이면 TFX_ALLOW_DIRECT_CLI=1로 일시 우회할 수 있습니다.";
159
+
155
160
  async function main() {
156
161
  // P0: TFX_ALLOW_DIRECT_CLI 환경변수 바이패스 — psmux 세션 생성 불가 시 수동 활성화
157
162
  if (process.env.TFX_ALLOW_DIRECT_CLI === "1") {
@@ -202,8 +207,9 @@ async function main() {
202
207
  // codex/gemini 직접 CLI 호출 → deny
203
208
  if (/\bcodex\s+exec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
204
209
  deny(
205
- "[headless-guard] codex/gemini 직접 호출 대신 headless를 사용하세요. " +
206
- 'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")',
210
+ "[headless-guard] codex/gemini 직접 호출은 spawn-session에서 차단됩니다. " +
211
+ `승인된 경로: ${HEADLESS_FALLBACK_COMMAND}. ` +
212
+ DIRECT_CLI_BYPASS_HINT,
207
213
  );
208
214
  }
209
215
 
@@ -310,7 +316,8 @@ async function main() {
310
316
  if (cliPatterns.some((p) => p.test(combined))) {
311
317
  deny(
312
318
  "[headless-guard] Codex/Gemini를 Agent()로 래핑하지 마세요. " +
313
- 'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")',
319
+ `승인된 경로: ${HEADLESS_FALLBACK_COMMAND}. ` +
320
+ DIRECT_CLI_BYPASS_HINT,
314
321
  );
315
322
  }
316
323
  }