triflux 10.14.2 → 10.15.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.md CHANGED
@@ -159,3 +159,11 @@ background로 실행한 headless 결과는 **반드시 task-notification 완료
159
159
  | `.claude/rules/tfx-stack-coexistence.md` | gstack / superpowers / triflux 공존 원칙, 레이어 분리, 의존 방향, 충돌 해소 |
160
160
 
161
161
  Claude Code는 `.claude/rules/*.md` 를 자동 로드한다. Codex CLI는 `@import` 미지원이므로 필요 시 `AGENTS.md` 를 독립 유지한다.
162
+
163
+ ## GBrain Configuration (configured by /setup-gbrain)
164
+ - Engine: pglite
165
+ - Config file: ~/.gbrain/config.json (mode 0600)
166
+ - Setup date: 2026-04-25
167
+ - MCP registered: yes (user scope, absolute path)
168
+ - Memory sync: artifacts-only (repo: github.com/tellang/gstack-brain-tellang)
169
+ - Current repo policy: read-write (github.com/tellang/triflux)
package/hub/intent.mjs CHANGED
@@ -61,17 +61,22 @@ function _tryCodexClassify(prompt) {
61
61
  * triflux 특화 의도 카테고리 (10개)
62
62
  */
63
63
  export const INTENT_CATEGORIES = {
64
- implement: { agent: "executor", mcp: "implement", effort: "codex53_high" },
65
- debug: { agent: "debugger", mcp: "implement", effort: "codex53_high" },
66
- analyze: { agent: "analyst", mcp: "analyze", effort: "gpt54_xhigh" },
67
- design: { agent: "architect", mcp: "analyze", effort: "gpt54_xhigh" },
68
- review: { agent: "code-reviewer", mcp: "review", effort: "codex53_high" },
64
+ // 모델 정책 (2026-04-25):
65
+ // - gpt-5.5 = 메인 (코드 포함 모든 메인 직무, fast tier 가능)
66
+ // - gpt-5.4-mini = 자잘/부가/가성비 (fast tier 가능)
67
+ // - gpt-5.3-codex = escalation 가성비 중간 (fast 미지원)
68
+ // - gpt-5.4 폐기, gpt-5.5 격상
69
+ implement: { agent: "executor", mcp: "implement", effort: "gpt55_high" },
70
+ debug: { agent: "debugger", mcp: "implement", effort: "gpt55_xhigh" },
71
+ analyze: { agent: "analyst", mcp: "analyze", effort: "gpt55_xhigh" },
72
+ design: { agent: "architect", mcp: "analyze", effort: "gpt55_xhigh" },
73
+ review: { agent: "code-reviewer", mcp: "review", effort: "gpt55_high" },
69
74
  document: { agent: "writer", mcp: "docs", effort: "pro" },
70
- research: { agent: "scientist", mcp: "analyze", effort: "codex53_high" },
75
+ research: { agent: "scientist", mcp: "analyze", effort: "gpt55_high" },
71
76
  "quick-fix": {
72
77
  agent: "build-fixer",
73
78
  mcp: "implement",
74
- effort: "codex53_low",
79
+ effort: "gpt55_low",
75
80
  },
76
81
  explain: { agent: "writer", mcp: "docs", effort: "flash" },
77
82
  test: { agent: "test-engineer", mcp: null, effort: null },
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
3
4
 
4
5
  const HOSTS_LOCATIONS = [
5
6
  ["references", "hosts.json"],
@@ -7,13 +8,70 @@ const HOSTS_LOCATIONS = [
7
8
  ["packages", "triflux", "references", "hosts.json"],
8
9
  ];
9
10
 
11
+ let migrated = false;
12
+
10
13
  function readJsonFile(path) {
11
14
  return JSON.parse(readFileSync(path, "utf8"));
12
15
  }
13
16
 
17
+ export function userStateHostsPath() {
18
+ if (process.env.TFX_HOSTS_USER_STATE_DISABLE === "1") return null;
19
+ if (process.env.TFX_HOSTS_USER_STATE) return process.env.TFX_HOSTS_USER_STATE;
20
+ if (process.platform === "win32") {
21
+ return join(
22
+ process.env.APPDATA || join(homedir(), "AppData", "Roaming"),
23
+ "triflux",
24
+ "hosts.json",
25
+ );
26
+ }
27
+ return join(homedir(), ".config", "triflux", "hosts.json");
28
+ }
29
+
14
30
  function candidatePaths(repoRoot) {
15
31
  const root = repoRoot || process.cwd();
16
- return HOSTS_LOCATIONS.map((segments) => join(root, ...segments));
32
+ const repoRootCandidates = HOSTS_LOCATIONS.map((segments) =>
33
+ join(root, ...segments),
34
+ );
35
+ const userPath = userStateHostsPath();
36
+ return userPath ? [userPath, ...repoRootCandidates] : repoRootCandidates;
37
+ }
38
+
39
+ export function migrateLegacyHosts(repoRoot) {
40
+ const to = userStateHostsPath();
41
+ let from = null;
42
+ if (!to) {
43
+ return {
44
+ migrated: false,
45
+ from: null,
46
+ to: null,
47
+ reason: "user-state-disabled",
48
+ };
49
+ }
50
+ try {
51
+ if (existsSync(to)) {
52
+ return { migrated: false, from: null, to, reason: "already-exists" };
53
+ }
54
+
55
+ const root = repoRoot || process.cwd();
56
+ from =
57
+ HOSTS_LOCATIONS.map((segments) => join(root, ...segments)).find((path) =>
58
+ existsSync(path),
59
+ ) || null;
60
+ if (!from) {
61
+ return { migrated: false, from: null, to, reason: "not-found" };
62
+ }
63
+
64
+ mkdirSync(dirname(to), { recursive: true });
65
+ copyFileSync(from, to);
66
+ return { migrated: true, from, to };
67
+ } catch (error) {
68
+ return {
69
+ migrated: false,
70
+ from,
71
+ to,
72
+ reason: error instanceof Error ? error.message : String(error),
73
+ };
74
+ }
17
75
  }
18
76
 
19
77
  function canonicalOs(rawOs) {
@@ -123,6 +181,11 @@ export function normalizeHost(rawHost = {}, name = "") {
123
181
  }
124
182
 
125
183
  export function readHosts(repoRoot) {
184
+ if (!migrated) {
185
+ migrateLegacyHosts(repoRoot);
186
+ migrated = true;
187
+ }
188
+
126
189
  for (const path of candidatePaths(repoRoot)) {
127
190
  if (!existsSync(path)) continue;
128
191
  const parsed = readJsonFile(path);
@@ -0,0 +1,49 @@
1
+ // hub/team/check-mcp-hub.mjs — health-probe L2 용 hub /health ping 체커.
2
+ // health-probe.mjs 의 checkMcp 로 주입되어 `mcp_initializing` state 판정에 사용.
3
+ // hub /health 가 200 이면 OK (MCP transport 인프라 살아있음), 그 외는 fail.
4
+
5
+ const DEFAULT_HUB_URL = "http://127.0.0.1:27888";
6
+ const DEFAULT_TIMEOUT_MS = 3000;
7
+
8
+ function resolveHubHealthUrl(hubUrl) {
9
+ const base = hubUrl || process.env.TFX_HUB_URL || DEFAULT_HUB_URL;
10
+ // `/mcp` suffix 제거 (triflux convention — bridge.mjs:54, hub-client.mjs,
11
+ // lead-control.mjs, session-sync.mjs 전부 TFX_HUB_URL 을 MCP transport URL
12
+ // 로 쓰고 base URL 이 필요할 때 `/mcp$` 를 strip 한다). 제거하지 않으면
13
+ // `.../mcp/health` 가 되어 hub 가 404 로 응답 → L2 영구 fail → heartbeat
14
+ // 이 지속적으로 `mcp_initializing` 로 grace — #173 Codex P2 review.
15
+ return base.replace(/\/mcp\/?$/, "").replace(/\/+$/, "") + "/health";
16
+ }
17
+
18
+ /**
19
+ * Hub /health 기반 L2 checker factory.
20
+ * @param {object} [opts]
21
+ * @param {string} [opts.hubUrl] — override. 미지정 시 TFX_HUB_URL env 또는 default.
22
+ * @param {number} [opts.timeoutMs=3000] — fetch timeout (ms).
23
+ * @param {typeof fetch} [opts.fetchFn] — fetch 오버라이드 (테스트용).
24
+ * @returns {() => Promise<boolean>} — true = hub healthy, false = degraded/down/timeout.
25
+ */
26
+ export function createHubHealthChecker(opts = {}) {
27
+ const url = resolveHubHealthUrl(opts.hubUrl);
28
+ const timeoutMs = Number.isFinite(opts.timeoutMs)
29
+ ? opts.timeoutMs
30
+ : DEFAULT_TIMEOUT_MS;
31
+ const fetchFn = opts.fetchFn || globalThis.fetch;
32
+
33
+ return async function checkMcpHubHealth() {
34
+ if (typeof fetchFn !== "function") return false;
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
37
+ try {
38
+ const res = await fetchFn(url, {
39
+ method: "GET",
40
+ signal: controller.signal,
41
+ });
42
+ return res.ok === true;
43
+ } catch {
44
+ return false;
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ };
49
+ }
@@ -22,6 +22,7 @@ import { createRegistry } from "../../mesh/mesh-registry.mjs";
22
22
  import { broker } from "../account-broker.mjs";
23
23
  import { execFile, spawn } from "../lib/spawn-trace.mjs";
24
24
  import { killProcess } from "../platform.mjs";
25
+ import { createHubHealthChecker } from "./check-mcp-hub.mjs";
25
26
  import { createConductorMeshBridge } from "./conductor-mesh-bridge.mjs";
26
27
  import {
27
28
  ensureConductorRegistry,
@@ -700,6 +701,16 @@ export function createConductor(opts = {}) {
700
701
  // opt-out: TFX_PROBE_WRITE_STATE=0 명시.
701
702
  writeStateFile:
702
703
  probeOpts.writeStateFile ?? process.env.TFX_PROBE_WRITE_STATE !== "0",
704
+ // #168 P3: L2 (hub /health) checker wiring. probeOpts 가 명시 주입하면
705
+ // 그걸 우선. 아니면 TFX_PROBE_L2=0 로 opt-out. 나머지 경우 hub URL 기반
706
+ // default checker 주입 → deriveState 가 `mcp_initializing` 라벨을 생산 →
707
+ // heartbeat (read_probe_state) 가 probe-grace 분기로 감.
708
+ enableL2: probeOpts.enableL2 ?? process.env.TFX_PROBE_L2 !== "0",
709
+ checkMcp:
710
+ probeOpts.checkMcp ||
711
+ (process.env.TFX_PROBE_L2 === "0"
712
+ ? undefined
713
+ : createHubHealthChecker({ hubUrl: process.env.TFX_HUB_URL })),
703
714
  onProbe: (result) => handleProbeResult(session, result),
704
715
  },
705
716
  );
@@ -28,7 +28,10 @@ export const PROBE_DEFAULTS = Object.freeze({
28
28
  l1ThresholdMs: 30_000,
29
29
  l2ThresholdMs: 30_000,
30
30
  l3ThresholdMs: 120_000,
31
- enableL2: false,
31
+ // #168 P3: default off → on. checkMcp 미주입 시에도 probeL2 가 skip 반환하므로
32
+ // safe. conductor wiring 이 checkMcp 를 주입하면 실제 L2 판정이 활성화된다.
33
+ // opt-out: TFX_PROBE_L2=0 (conductor 에서 false 주입).
34
+ enableL2: true,
32
35
  writeStateFile: false,
33
36
  stateDir: join(tmpdir(), "tfx-probe"),
34
37
  });
@@ -38,10 +38,17 @@ export const MODES = Object.freeze({
38
38
  ESCALATE: "auto-escalate",
39
39
  });
40
40
 
41
+ // Escalation chain (2026-04-25 정책):
42
+ // 1. gpt-5.4-mini — 비용 최저, fast tier, 단순 task 대부분 해결
43
+ // 2. gpt-5.3-codex — 가성비 중간, code specialized (Plus/free 모두 OK, fast 미지원)
44
+ // 3. gpt-5.5 — top reasoning + 코드 강함, fast tier
45
+ // 4. claude opus-4-7 — 최종 수단
46
+ // sonnet-4-6 단계는 제거: gpt-5.5 가 코드/추론/비용 모두 우위 + 5.3-codex 가성비
47
+ // 단계가 더 적합한 중간 격상.
41
48
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
42
49
  Object.freeze({ cli: "codex", model: "gpt-5.4-mini" }),
50
+ Object.freeze({ cli: "codex", model: "gpt-5.3-codex" }),
43
51
  Object.freeze({ cli: "codex", model: "gpt-5.5" }),
44
- Object.freeze({ cli: "claude", model: "sonnet-4-6" }),
45
52
  Object.freeze({ cli: "claude", model: "opus-4-7" }),
46
53
  ]);
47
54
 
@@ -855,7 +855,14 @@ export function createSwarmHypervisor(opts) {
855
855
  * @returns {{ ok: boolean, violations: Array }}
856
856
  */
857
857
  function validateResult(shardName, changedFiles) {
858
- const violations = lockManager.validateChanges(shardName, changedFiles);
858
+ // Pass the shard's own lease so validateChanges can flag out-of-lease
859
+ // writes to distribution-critical paths (regression of #115/#34 —
860
+ // recovery patches were carrying `+++ /dev/null` deletions of
861
+ // .claude-plugin/marketplace.json, undetected by lease-only checks).
862
+ const ownLease = plan.leaseMap.get(shardName) || [];
863
+ const violations = lockManager.validateChanges(shardName, changedFiles, {
864
+ ownLease,
865
+ });
859
866
 
860
867
  eventLog.append("validate_result", {
861
868
  shard: shardName,
@@ -8,6 +8,26 @@ import { dirname, relative, resolve } from "node:path";
8
8
 
9
9
  const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
10
10
 
11
+ // Distribution-critical paths. A swarm worker that modifies these without
12
+ // having them explicitly listed in its shard lease is almost certainly a
13
+ // recovery-patch fallout (worker exited before commit, hypervisor saved
14
+ // patch with destructive `+++ /dev/null` deletions). Block by default;
15
+ // override via validateChanges options.sensitiveDeny.
16
+ //
17
+ // Paths use POSIX separators (matches normalizePath output).
18
+ const SENSITIVE_PATH_PREFIXES = [
19
+ ".claude-plugin/",
20
+ "bin/",
21
+ ".github/workflows/",
22
+ ];
23
+ const SENSITIVE_PATH_FILES = [
24
+ ".gitignore",
25
+ ".npmignore",
26
+ "package.json",
27
+ "package-lock.json",
28
+ "biome.json",
29
+ ];
30
+
11
31
  /**
12
32
  * Swarm lock manager factory.
13
33
  * @param {object} [opts]
@@ -205,20 +225,52 @@ export function createSwarmLocks(opts = {}) {
205
225
  /**
206
226
  * Validate a set of changed files against the lease map.
207
227
  * Returns all violations found.
228
+ *
229
+ * Beyond the basic cross-lease check, when `options.ownLease` is supplied
230
+ * (the caller's known lease set), this also flags out-of-lease writes to
231
+ * distribution-critical paths — e.g. recovery-patch fallouts where a
232
+ * worker's recorded diff carries `+++ /dev/null` deletions of files like
233
+ * `.claude-plugin/marketplace.json` (regression of #115 / #34).
234
+ *
235
+ * `options.ownLease` is opt-in to preserve existing callers; when omitted,
236
+ * legacy behavior (cross-lease check only) is kept.
237
+ *
208
238
  * @param {string} workerId
209
239
  * @param {string[]} changedFiles
210
- * @returns {Array<{ file: string, holder: string }>}
240
+ * @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
241
+ * @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
211
242
  */
212
- function validateChanges(workerId, changedFiles) {
243
+ function validateChanges(workerId, changedFiles, options = {}) {
213
244
  pruneExpired();
214
245
  const violations = [];
246
+ const ownLeaseSet = Array.isArray(options.ownLease)
247
+ ? new Set(options.ownLease.map((path) => normalizePath(path)))
248
+ : null;
249
+ const sensitivePrefixes =
250
+ options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
251
+ const sensitiveFiles = new Set(
252
+ options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
253
+ );
215
254
 
216
255
  for (const file of changedFiles) {
217
256
  const path = normalizePath(file);
218
257
  const entry = locks.get(path);
219
258
 
220
259
  if (entry && entry.workerId !== workerId && !isExpired(entry)) {
221
- violations.push({ file, holder: entry.workerId });
260
+ violations.push({ file, holder: entry.workerId, kind: "other-lease" });
261
+ continue;
262
+ }
263
+
264
+ // Sensitive-path guard only runs when caller supplied ownLease.
265
+ // We only flag when the worker did NOT explicitly lease the file —
266
+ // an explicit lease means the shard intentionally owns it.
267
+ if (ownLeaseSet && !ownLeaseSet.has(path)) {
268
+ const isSensitive =
269
+ sensitiveFiles.has(path) ||
270
+ sensitivePrefixes.some((prefix) => path.startsWith(prefix));
271
+ if (isSensitive) {
272
+ violations.push({ file, kind: "sensitive-out-of-lease" });
273
+ }
222
274
  }
223
275
  }
224
276
 
@@ -5,16 +5,12 @@
5
5
  // 완료/실패 시 notify.mjs 자동 호출.
6
6
 
7
7
  import { EventEmitter } from "node:events";
8
- import { readFileSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
8
 
9
+ import { readHosts } from "../lib/hosts-compat.mjs";
12
10
  import { STATES } from "./conductor.mjs";
13
11
 
14
12
  // ── 상수 ─────────────────────────────────────────────────────────────────────
15
13
 
16
- const HOSTS_JSON_REL = "../../references/hosts.json";
17
-
18
14
  const CONDUCTOR_STATE_TO_TUI_STATUS = Object.freeze({
19
15
  [STATES.INIT]: "pending",
20
16
  [STATES.STARTING]: "pending",
@@ -31,21 +27,6 @@ const SESSION_PREFIX = "tfx-spawn-";
31
27
 
32
28
  // ── 유틸 ─────────────────────────────────────────────────────────────────────
33
29
 
34
- function loadHostsJson(hostsJsonPath) {
35
- try {
36
- const raw = readFileSync(hostsJsonPath, "utf8");
37
- return JSON.parse(raw);
38
- } catch {
39
- return { hosts: {} };
40
- }
41
- }
42
-
43
- function resolveHostsJsonPath(overridePath) {
44
- if (overridePath) return overridePath;
45
- const thisDir = fileURLToPath(new URL(".", import.meta.url));
46
- return join(thisDir, HOSTS_JSON_REL);
47
- }
48
-
49
30
  function resolveSshUser(hostsData, host) {
50
31
  if (!host || !hostsData?.hosts) return null;
51
32
  return hostsData.hosts[host]?.ssh_user || null;
@@ -144,7 +125,7 @@ function buildWatcherOnlyWorkerData(watcherRecord, hostsData) {
144
125
  * @param {object} opts.conductor — createConductor() 인스턴스
145
126
  * @param {object} [opts.watcher] — createRemoteWatcher() 인스턴스 (nullable)
146
127
  * @param {object} [opts.notifier] — createNotifier() 인스턴스 (nullable)
147
- * @param {string} [opts.hostsJsonPath] — hosts.json 경로 override
128
+ * @param {string} [opts.repoRoot] — hosts fallback 탐색용 repo root override
148
129
  * @param {number} [opts.pollMs=10000] — conductor snapshot 폴링 간격
149
130
  * @param {object} [opts.deps] — 테스트용 의존성 주입
150
131
  * @returns {{ start, stop, getWorkers, on, off }}
@@ -160,14 +141,13 @@ export function createRemoteAdapter(opts = {}) {
160
141
 
161
142
  if (!conductor) throw new Error("conductor is required");
162
143
 
163
- const hostsJsonPath = resolveHostsJsonPath(opts.hostsJsonPath);
164
- const loadHosts = deps.loadHostsJson || loadHostsJson;
144
+ const loadHosts = deps.readHosts || readHosts;
165
145
  const setIntervalFn = deps.setInterval || setInterval;
166
146
  const clearIntervalFn = deps.clearInterval || clearInterval;
167
147
  const _nowFn = deps.now || Date.now;
168
148
 
169
149
  const emitter = new EventEmitter();
170
- let hostsData = loadHosts(hostsJsonPath);
150
+ let hostsData = loadHosts(opts.repoRoot);
171
151
  let workers = new Map();
172
152
  let pollHandle = null;
173
153
  let running = false;
@@ -343,7 +323,7 @@ export function createRemoteAdapter(opts = {}) {
343
323
  running = true;
344
324
 
345
325
  // hosts.json 리로드
346
- hostsData = loadHosts(hostsJsonPath);
326
+ hostsData = loadHosts(opts.repoRoot);
347
327
 
348
328
  // conductor stateChange 구독
349
329
  conductor.on("stateChange", handleStateChange);
@@ -533,13 +533,28 @@ export async function runCodexMcpCli(argv = process.argv.slice(2)) {
533
533
 
534
534
  try {
535
535
  const result = await worker.execute(options.prompt, options);
536
- if (result.output) {
536
+ const hasOutput =
537
+ typeof result.output === "string" && result.output.trim().length > 0;
538
+ if (hasOutput) {
537
539
  process.stdout.write(result.output);
538
540
  if (!result.output.endsWith("\n")) {
539
541
  process.stdout.write("\n");
540
542
  }
541
543
  }
542
- process.exitCode = result.exitCode;
544
+ // Silent-success regression guard (codex 0.124.0+):
545
+ // codex MCP can return exitCode=0 with empty/whitespace `output` when the
546
+ // result-extraction path fails to read the assistant content from the MCP
547
+ // response. Without this guard, tfx-route.sh sees STDOUT_LOG=0 bytes and
548
+ // treats it as success — caller gets nothing back.
549
+ // Promote to a transport failure so the wrapper falls back to `codex exec`.
550
+ if (!hasOutput && result.exitCode === 0) {
551
+ console.error(
552
+ "[codex-mcp] WARNING: empty output with exit 0 — treating as transport failure (codex 0.124.0 silent-flush regression). Wrapper will fall back to exec path.",
553
+ );
554
+ process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
555
+ } else {
556
+ process.exitCode = result.exitCode;
557
+ }
543
558
  } catch (error) {
544
559
  const lines = [error instanceof Error ? error.message : String(error)];
545
560
  if (error instanceof CodexMcpTransportError && error.stderr) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.14.2",
3
+ "version": "10.15.0",
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": {
@@ -101,14 +101,16 @@ describe("mcp guard engine", () => {
101
101
  );
102
102
  });
103
103
 
104
- it("replaces stdio MCP entries with tfx-hub and writes a backup", () => {
104
+ it("replaces stdio MCP entries with tfx-hub and writes a backup (TFX_HUB_PORT env overrides)", () => {
105
105
  const homeDir = createHomeDir();
106
106
  withHome(homeDir);
107
+ process.env.TFX_HUB_PORT = "30123";
107
108
 
109
+ // hub.pid port 는 무시되어야 한다 (PR #158: pid = host hint only).
108
110
  const pidPath = join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid");
109
111
  writeFileSync(
110
112
  pidPath,
111
- JSON.stringify({ host: "127.0.0.1", port: 30123 }),
113
+ JSON.stringify({ host: "127.0.0.1", port: 40404 }),
112
114
  "utf8",
113
115
  );
114
116
 
@@ -141,9 +143,18 @@ describe("mcp guard engine", () => {
141
143
  assert.equal(Object.hasOwn(updated.mcpServers, "unsafe-stdio"), false);
142
144
  });
143
145
 
144
- it("uses hub.pid port before registry fallback when resolving Hub URL", () => {
146
+ it("uses TFX_HUB_PORT env as single source when resolving Hub URL", () => {
145
147
  const homeDir = createHomeDir();
146
148
  withHome(homeDir);
149
+ process.env.TFX_HUB_PORT = "29991";
150
+
151
+ assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
152
+ });
153
+
154
+ it("ignores hub.pid port (pid is host hint only, PR #158 policy)", () => {
155
+ const homeDir = createHomeDir();
156
+ withHome(homeDir);
157
+ delete process.env.TFX_HUB_PORT;
147
158
 
148
159
  writeFileSync(
149
160
  join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid"),
@@ -151,6 +162,8 @@ describe("mcp guard engine", () => {
151
162
  "utf8",
152
163
  );
153
164
 
154
- assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
165
+ // env 없음 + hub.pid port 존재 → registry/default 27888 fallback.
166
+ // pid port cascade 가 제거되어 29991 이 쓰이면 안 됨.
167
+ assert.equal(resolveHubUrl(), "http://127.0.0.1:27888/mcp");
155
168
  });
156
169
  });
@@ -277,11 +277,15 @@ async function main() {
277
277
  cmdSanitized,
278
278
  );
279
279
  } else {
280
+ // $()/${} 와 eval 직후 첫 토큰만 검사한다.
281
+ // .* 매칭은 `result=$(grep "codex exec" file)` 같은 grep 인자 패턴까지
282
+ // 차단하는 오탐을 일으킨다. 진짜 위협은 `$(codex exec ...)` 처럼
283
+ // command substitution / eval 의 첫 명령이 codex/gemini 인 경우뿐이다.
280
284
  hasDirectCli =
281
- /\beval\b.*\b(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(
285
+ /\beval\s+(?:["']\s*)?(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(
282
286
  cmdSanitized,
283
287
  ) ||
284
- /\$[({].*\b(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(
288
+ /\$[({]\s*(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(
285
289
  cmdSanitized,
286
290
  );
287
291
  }
@@ -851,14 +851,13 @@ export function resolveHubUrl() {
851
851
  : DEFAULT_HUB_PATH,
852
852
  };
853
853
 
854
+ // PR #158 정책: port = TFX_HUB_PORT env (없으면 registry/default 27888) single source.
855
+ // hub.pid 는 loopback host 힌트 전용. 과거 pid port cascade 는 오염된 port 영속화의
856
+ // 원인이었고 hub-ensure.resolveHubTarget 에서 이미 제거됨. 여기도 일관 적용.
854
857
  const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
855
858
  if (existsSync(hubPidPath)) {
856
859
  try {
857
860
  const info = readJsonFile(hubPidPath);
858
- if (!envPort) {
859
- const pidPort = Number(info?.port);
860
- if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
861
- }
862
861
  if (typeof info?.host === "string") {
863
862
  const host = info.host.trim();
864
863
  if (LOOPBACK_HOSTS.has(host)) target.host = host;
package/scripts/setup.mjs CHANGED
@@ -117,6 +117,11 @@ const REQUIRED_CODEX_PROFILES = [
117
117
  ];
118
118
 
119
119
  const HUD_SYNC_EXCLUDES = new Set(["omc-hud.mjs", "omc-hud.mjs.bak"]);
120
+ const SETUP_USER_STATE_FILES = new Set(["hosts.json"]);
121
+
122
+ function isSetupUserStateFile(fileName) {
123
+ return SETUP_USER_STATE_FILES.has(fileName);
124
+ }
120
125
 
121
126
  /**
122
127
  * scripts/lib/*.mjs 자동 스캔.
@@ -409,6 +414,7 @@ function syncAliasedSkillDir(srcDir, dstDir, { alias, source }) {
409
414
  for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
410
415
  const srcPath = join(srcDir, entry.name);
411
416
  const dstPath = join(dstDir, entry.name);
417
+ if (isSetupUserStateFile(entry.name)) continue;
412
418
 
413
419
  if (entry.isDirectory()) {
414
420
  count += syncAliasedSkillDir(srcPath, dstPath, { alias, source });
@@ -762,6 +768,64 @@ function buildWindowsHubAutostartCommand({
762
768
  ].join(" ");
763
769
  }
764
770
 
771
+ // #161 P2: schtasks /Query 실패 시 stderr 를 해석해 미등록/권한거부/기타 실패를 구분한다.
772
+ // 기존 구현은 stdio=ignore + catch 후 항상 registered:false 였기 때문에
773
+ // Access Denied 같은 해결 가능한 문제가 "미등록" 으로 묻혔다.
774
+ const WINDOWS_SCHTASKS_NOT_FOUND_PATTERNS = [
775
+ "cannot find the file",
776
+ "does not exist",
777
+ "지정된 파일",
778
+ "찾을 수 없",
779
+ ];
780
+ const WINDOWS_SCHTASKS_ACCESS_DENIED_PATTERNS = [
781
+ "access is denied",
782
+ "access denied",
783
+ "permission",
784
+ "액세스가 거부",
785
+ "권한",
786
+ ];
787
+
788
+ // #161 P3: schtasks /TR 인자는 실질적으로 262자 미만으로 제한된다.
789
+ // 초과 시 Create 자체는 성공해도 task 실행에서 인자 잘림/실행 실패 재발.
790
+ // 따라서 Create 전에 사전 검증해 조기 실패를 보장한다.
791
+ const SCHTASKS_TR_MAX_LENGTH = 261;
792
+
793
+ // #161 P3: /TR 길이 검증 공용 함수.
794
+ // schtasks 는 Windows 내부에서 wide-char 문자 수로 제한하므로 UTF-8 byte 가 아닌
795
+ // JavaScript string .length (UTF-16 code units) 기준으로 비교한다.
796
+ // Codex Round 1 P1 반영: UTF-8 byte 검증은 한글 경로에서 정상 명령을 오차단했다
797
+ // (예: ~218자 한글 경로 = 578 bytes → false positive throw).
798
+ // Codex Round 3 P2 반영: 테스트가 실행 경로와 동일한 이 함수를 exercise 하므로
799
+ // 내부 구현이 회귀해 byte 기반으로 돌아가면 테스트가 즉시 포착한다.
800
+ function validateSchtasksTrLength(command) {
801
+ const commandChars = command.length;
802
+ if (commandChars > SCHTASKS_TR_MAX_LENGTH) {
803
+ throw new Error(
804
+ `schtasks /TR 인자가 ${SCHTASKS_TR_MAX_LENGTH} 문자를 초과합니다 ` +
805
+ `(${commandChars} chars): ${command}`,
806
+ );
807
+ }
808
+ }
809
+
810
+ function classifySchtasksStderr(stderr) {
811
+ const lower = String(stderr || "").toLowerCase();
812
+ if (
813
+ WINDOWS_SCHTASKS_NOT_FOUND_PATTERNS.some((p) =>
814
+ lower.includes(p.toLowerCase()),
815
+ )
816
+ ) {
817
+ return "not_registered";
818
+ }
819
+ if (
820
+ WINDOWS_SCHTASKS_ACCESS_DENIED_PATTERNS.some((p) =>
821
+ lower.includes(p.toLowerCase()),
822
+ )
823
+ ) {
824
+ return "access_denied";
825
+ }
826
+ return "unknown";
827
+ }
828
+
765
829
  function getWindowsHubAutostartStatus({
766
830
  taskName = WINDOWS_HUB_AUTOSTART_TASK,
767
831
  } = {}) {
@@ -770,12 +834,20 @@ function getWindowsHubAutostartStatus({
770
834
  }
771
835
  try {
772
836
  execFileSync("schtasks.exe", ["/Query", "/TN", taskName], {
773
- stdio: "ignore",
837
+ stdio: ["ignore", "pipe", "pipe"],
774
838
  windowsHide: true,
775
839
  });
776
840
  return { supported: true, registered: true, taskName };
777
- } catch {
778
- return { supported: true, registered: false, taskName };
841
+ } catch (error) {
842
+ const stderr = String(error?.stderr || "").trim();
843
+ const reason = classifySchtasksStderr(stderr);
844
+ return {
845
+ supported: true,
846
+ registered: false,
847
+ taskName,
848
+ reason,
849
+ stderr: stderr.slice(0, 200),
850
+ };
779
851
  }
780
852
  }
781
853
 
@@ -796,6 +868,10 @@ function ensureWindowsHubAutostart({
796
868
  }
797
869
 
798
870
  const command = buildWindowsHubAutostartCommand({ nodePath, pluginRoot });
871
+
872
+ // #161 P3: /TR 262자 제한 사전 검증을 공용 함수로 위임해 테스트/실행 로직 일관성 보장.
873
+ validateSchtasksTrLength(command);
874
+
799
875
  const args = [
800
876
  "/Create",
801
877
  "/TN",
@@ -809,10 +885,23 @@ function ensureWindowsHubAutostart({
809
885
  ];
810
886
  if (force) args.push("/F");
811
887
 
812
- execFileSync("schtasks.exe", args, {
813
- stdio: ["ignore", "pipe", "pipe"],
814
- windowsHide: true,
815
- });
888
+ try {
889
+ execFileSync("schtasks.exe", args, {
890
+ stdio: ["ignore", "pipe", "pipe"],
891
+ windowsHide: true,
892
+ });
893
+ } catch (error) {
894
+ // #161 P3: stderr 를 error.message 에 노출해 호출자가 원인을 볼 수 있게 한다.
895
+ const stderr = String(error?.stderr || "").trim();
896
+ if (stderr) {
897
+ const wrapped = new Error(
898
+ `schtasks /Create 실패: ${stderr.slice(0, 200)}`,
899
+ );
900
+ wrapped.cause = error;
901
+ throw wrapped;
902
+ }
903
+ throw error;
904
+ }
816
905
 
817
906
  return {
818
907
  supported: true,
@@ -1058,6 +1147,7 @@ export {
1058
1147
  BREADCRUMB_PATH,
1059
1148
  buildWindowsHubAutostartCommand,
1060
1149
  CLAUDE_DIR,
1150
+ classifySchtasksStderr,
1061
1151
  cleanupStaleSkills,
1062
1152
  DEPRECATED_SKILLS,
1063
1153
  detectDevMode,
@@ -1070,17 +1160,21 @@ export {
1070
1160
  getVersion,
1071
1161
  getWindowsHubAutostartStatus,
1072
1162
  hasProfileSection,
1163
+ isSetupUserStateFile,
1073
1164
  LEGACY_CODEX_MODELS,
1074
1165
  PLUGIN_ROOT,
1075
1166
  REQUIRED_CODEX_PROFILES,
1076
1167
  REQUIRED_TOP_LEVEL_SETTINGS,
1077
1168
  readMarker,
1078
1169
  replaceProfileSection,
1170
+ SCHTASKS_TR_MAX_LENGTH,
1079
1171
  SETUP_MARKER_PATH,
1172
+ SETUP_USER_STATE_FILES,
1080
1173
  SKILL_ALIASES,
1081
1174
  SYNC_MAP,
1082
1175
  scanHudFiles,
1083
1176
  syncAliasedSkillDir,
1177
+ validateSchtasksTrLength,
1084
1178
  WINDOWS_HUB_AUTOSTART_TASK,
1085
1179
  writeMarker,
1086
1180
  };
@@ -1291,7 +1385,8 @@ export async function runDeferred(stdinData) {
1291
1385
  }
1292
1386
 
1293
1387
  // ── 스킬 동기화 ──
1294
- // SKILL.md + 하위 디렉토리(references/ 등)를 재귀적으로 동기화
1388
+ // SKILL.md + 하위 디렉토리(references/ 등)를 재귀적으로 동기화.
1389
+ // hosts.json 같은 user-state 파일은 설치/동기화 대상이 아니다.
1295
1390
 
1296
1391
  const skillsSrc = join(PLUGIN_ROOT, "skills");
1297
1392
  const skillsDst = join(CLAUDE_DIR, "skills");
@@ -1303,6 +1398,7 @@ export async function runDeferred(stdinData) {
1303
1398
  for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
1304
1399
  const srcPath = join(srcDir, entry.name);
1305
1400
  const dstPath = join(dstDir, entry.name);
1401
+ if (isSetupUserStateFile(entry.name)) continue;
1306
1402
 
1307
1403
  if (entry.isDirectory()) {
1308
1404
  count += syncSkillDir(srcPath, dstPath);
@@ -82,30 +82,89 @@ async function fileExists(filePath) {
82
82
  }
83
83
 
84
84
  async function writeTextAtomic(filePath, payload) {
85
+ // #164 MEDIUM 1: rename fallback 비원자성 개선.
86
+ // 기존: rename 실패 시 원본을 먼저 rm → rename(tmp, dest) 이므로 2차 실패/프로세스 중단 시 원본 유실.
87
+ // 개선: 원본을 backup 경로로 먼저 옮기고 (atomic rename), tmp → dest 성공 후에만 backup 삭제.
88
+ // tmp → dest 실패 시 backup 을 다시 dest 로 복원해 원자성 보장.
89
+ // backup 복원 자체가 실패하면 backup 을 **절대 삭제하지 않는다** (수동 복구용 보존).
85
90
  const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
91
+ const backupPath = `${filePath}.bak-${process.pid}-${Date.now()}`;
92
+ let hasBackup = false;
86
93
 
87
94
  try {
88
95
  await writeFile(tmpPath, payload, "utf8");
89
96
 
97
+ // 1) 원본이 있으면 backup 으로 rename (원본 유실 위험 제거)
98
+ try {
99
+ await rename(filePath, backupPath);
100
+ hasBackup = true;
101
+ } catch (error) {
102
+ if (error?.code !== "ENOENT") throw error;
103
+ }
104
+
105
+ // 2) tmp → dest
90
106
  try {
91
107
  await rename(tmpPath, filePath);
92
108
  } catch (error) {
109
+ // Windows 에서 dest 에 stale lock 이 남아있으면 EEXIST/EPERM/EACCES 가 여전히 발생 가능.
110
+ // 이 경우 dest 를 제거한 뒤 재시도. backup 은 아직 살아있으므로 복원 가능.
93
111
  if (
94
- error?.code !== "EEXIST" &&
95
- error?.code !== "EPERM" &&
96
- error?.code !== "EACCES"
112
+ error?.code === "EEXIST" ||
113
+ error?.code === "EPERM" ||
114
+ error?.code === "EACCES"
97
115
  ) {
116
+ await rm(filePath, { force: true }).catch(() => {});
117
+ await rename(tmpPath, filePath);
118
+ } else {
98
119
  throw error;
99
120
  }
121
+ }
100
122
 
101
- await rm(filePath, { force: true });
102
- await rename(tmpPath, filePath);
123
+ // 3) 성공 backup 정리
124
+ if (hasBackup) {
125
+ await rm(backupPath, { force: true }).catch(() => {});
126
+ hasBackup = false;
103
127
  }
128
+ } catch (error) {
129
+ // 실패 — backup 복원 시도. 복원 성공 시에만 hasBackup=false 로 내려 cleanup 경로 진입 허용.
130
+ // 복원 실패 시에는 hasBackup=true 유지 → finally 에서도 backup 을 **삭제하지 않아** 수동 복구 가능.
131
+ if (hasBackup) {
132
+ try {
133
+ await rename(backupPath, filePath);
134
+ hasBackup = false;
135
+ } catch (rollbackError) {
136
+ // eslint-disable-next-line no-console — 사용자가 backup 존재를 인지해야 복구 가능
137
+ console.warn(
138
+ `[sync-hub-mcp-settings] atomic write rollback failed for ${filePath}: ${rollbackError?.message || rollbackError}. ` +
139
+ `Original content preserved at: ${backupPath}`,
140
+ );
141
+ }
142
+ }
143
+ throw error;
104
144
  } finally {
145
+ // tmp 는 항상 정리. backup 은 성공 경로/복원 경로에서만 명시적으로 rm 한다
146
+ // (rollback 실패 시 hasBackup=true 상태로 남음 → 이 블록에서 절대 삭제하지 않음)
105
147
  await rm(tmpPath, { force: true }).catch(() => {});
106
148
  }
107
149
  }
108
150
 
151
+ // #164 MEDIUM 2: TOML write 후 유효성 검증.
152
+ // write 직전 nextRaw 가 최소 구조 (섹션 헤더 + url= 키) 를 만족하는지 확인해
153
+ // 깨진 TOML 을 filesystem 에 반영하지 않는다.
154
+ function validateCodexTomlPayload(raw, sectionName) {
155
+ if (typeof raw !== "string" || raw.length === 0) {
156
+ return { ok: false, reason: "empty payload" };
157
+ }
158
+ const section = findMcpServerSection(raw, sectionName);
159
+ if (!section) {
160
+ return { ok: false, reason: "missing section header" };
161
+ }
162
+ if (!/^\s*url\s*=\s*.+$/m.test(section.body)) {
163
+ return { ok: false, reason: "missing url key" };
164
+ }
165
+ return { ok: true };
166
+ }
167
+
109
168
  async function writeJsonAtomic(filePath, value) {
110
169
  const payload = `${JSON.stringify(value, null, 2)}\n`;
111
170
  await writeTextAtomic(filePath, payload);
@@ -261,6 +320,16 @@ async function syncCodexConfigFile({ filePath, hubUrl, dryRun, logger }) {
261
320
  );
262
321
 
263
322
  if (!dryRun) {
323
+ const validation = validateCodexTomlPayload(nextRaw, TFX_HUB_SECTION);
324
+ if (!validation.ok) {
325
+ const reason = `invalid toml payload: ${validation.reason}`;
326
+ log(
327
+ logger,
328
+ "error",
329
+ `[codex-mcp-sync] error: ${filePath} (${reason})`,
330
+ );
331
+ return { kind: "error", path: filePath, reason };
332
+ }
264
333
  try {
265
334
  await writeTextAtomic(filePath, nextRaw);
266
335
  } catch (error) {
@@ -311,6 +380,12 @@ async function syncCodexConfigFile({ filePath, hubUrl, dryRun, logger }) {
311
380
  );
312
381
 
313
382
  if (!dryRun) {
383
+ const validation = validateCodexTomlPayload(nextRaw, TFX_HUB_SECTION);
384
+ if (!validation.ok) {
385
+ const reason = `invalid toml payload: ${validation.reason}`;
386
+ log(logger, "error", `[codex-mcp-sync] error: ${filePath} (${reason})`);
387
+ return { kind: "error", path: filePath, reason };
388
+ }
314
389
  try {
315
390
  await writeTextAtomic(filePath, nextRaw);
316
391
  } catch (error) {
@@ -911,38 +911,45 @@ route_agent() {
911
911
  case "$agent" in
912
912
  # ─── 구현 레인 ───
913
913
  executor|codex)
914
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
915
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
914
+ CLI_ARGS="exec --profile gpt55_high ${codex_base}"
915
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
916
916
  build-fixer)
917
- CLI_ARGS="exec --profile codex53_low ${codex_base}"
918
- CLI_EFFORT="codex53_low"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
917
+ # 빌드 수정 — npm/biome/test-lock 등 도메인 지식 필요. base capability 우위로 gpt55_low (fast tier).
918
+ CLI_ARGS="exec --profile gpt55_low ${codex_base}"
919
+ CLI_EFFORT="gpt55_low"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
920
+ cleanup|deslop)
921
+ # 슬롭/정리 — 패턴 매칭 위주. 가성비 mini (gpt-5.4-mini, fast tier).
922
+ CLI_ARGS="exec --profile mini54_med ${codex_base}"
923
+ CLI_EFFORT="mini54_med"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
919
924
  debugger)
920
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
921
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
925
+ # 디버깅 깊은 코드 추적 필요, gpt-5.5 xhigh
926
+ CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
927
+ CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
922
928
 
923
- # ─── 설계/분석 레인 (5.4: 1M 컨텍스트, 에이전틱) ───
929
+ # ─── 설계/분석 레인 (gpt-5.5 xhigh — 5.4 폐기, 5.5 격상) ───
924
930
  deep-executor|architect|critic)
925
- CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
926
- CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
931
+ CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
932
+ CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
927
933
  planner|analyst)
928
- CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
929
- CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
934
+ CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
935
+ CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
930
936
 
931
- # ─── 리뷰 레인 (5.3-codex: SWE-Bench 72%) ───
937
+ # ─── 리뷰 레인 (gpt-5.5 코드 리뷰도 5.5가 강함) ───
932
938
  code-reviewer|quality-reviewer)
933
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
934
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
939
+ CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
940
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
935
941
  security-reviewer)
936
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
937
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
942
+ # 보안 = 깊은 사고 xhigh
943
+ CLI_ARGS="exec --profile gpt55_xhigh ${codex_base} review"
944
+ CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
938
945
 
939
946
  # ─── 리서치 레인 ───
940
947
  scientist|document-specialist)
941
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
942
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
948
+ CLI_ARGS="exec --profile gpt55_high ${codex_base}"
949
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
943
950
  scientist-deep)
944
- CLI_ARGS="exec --profile gpt54_high ${codex_base}"
945
- CLI_EFFORT="gpt54_high"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
951
+ CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
952
+ CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
946
953
 
947
954
  # ─── UI/문서 레인 ───
948
955
  designer|gemini)
@@ -958,14 +965,14 @@ route_agent() {
958
965
 
959
966
  # ─── 검증/테스트 ───
960
967
  verifier)
961
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
962
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
968
+ CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
969
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
963
970
  test-engineer)
964
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
965
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
971
+ CLI_ARGS="exec --profile gpt55_high ${codex_base}"
972
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
966
973
  qa-tester)
967
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
968
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
974
+ CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
975
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
969
976
 
970
977
  # ─── 경량 ───
971
978
  spark)
@@ -975,8 +982,8 @@ route_agent() {
975
982
  *)
976
983
  case "$CLI_TYPE" in
977
984
  codex)
978
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
979
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
985
+ CLI_ARGS="exec --profile gpt55_high ${codex_base}"
986
+ CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
980
987
  gemini)
981
988
  CLI_ARGS="-m $(resolve_gemini_profile pro31) -y --prompt"
982
989
  CLI_EFFORT="pro31"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
@@ -1143,9 +1150,9 @@ apply_plan_guard() {
1143
1150
  if [[ "$CLI_EFFORT" == spark53_* ]]; then
1144
1151
  local codex_base
1145
1152
  codex_base="$(build_codex_base)"
1146
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
1147
- CLI_EFFORT="codex53_high"
1148
- echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: spark → codex53_high로 다운그레이드 (Pro 전용)" >&2
1153
+ CLI_ARGS="exec --profile gpt55_high ${codex_base}"
1154
+ CLI_EFFORT="gpt55_high"
1155
+ echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: spark → gpt55_high로 다운그레이드 (Pro 전용)" >&2
1149
1156
  fi
1150
1157
  }
1151
1158
 
@@ -1168,29 +1175,29 @@ apply_no_claude_native_mode() {
1168
1175
 
1169
1176
  case "$AGENT_TYPE" in
1170
1177
  explore)
1171
- CLI_ARGS="exec --profile codex53_low ${codex_base}"
1172
- CLI_EFFORT="codex53_low"
1178
+ CLI_ARGS="exec --profile gpt55_low ${codex_base}"
1179
+ CLI_EFFORT="gpt55_low"
1173
1180
  DEFAULT_TIMEOUT=600
1174
1181
  RUN_MODE="fg"
1175
1182
  OPUS_OVERSIGHT="false"
1176
1183
  ;;
1177
1184
  verifier)
1178
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
1179
- CLI_EFFORT="codex53_high"
1185
+ CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
1186
+ CLI_EFFORT="gpt55_high"
1180
1187
  DEFAULT_TIMEOUT=1200
1181
1188
  RUN_MODE="fg"
1182
1189
  OPUS_OVERSIGHT="false"
1183
1190
  ;;
1184
1191
  test-engineer)
1185
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
1186
- CLI_EFFORT="codex53_high"
1192
+ CLI_ARGS="exec --profile gpt55_high ${codex_base}"
1193
+ CLI_EFFORT="gpt55_high"
1187
1194
  DEFAULT_TIMEOUT=1200
1188
1195
  RUN_MODE="bg"
1189
1196
  OPUS_OVERSIGHT="false"
1190
1197
  ;;
1191
1198
  qa-tester)
1192
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
1193
- CLI_EFFORT="codex53_high"
1199
+ CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
1200
+ CLI_EFFORT="gpt55_high"
1194
1201
  DEFAULT_TIMEOUT=1200
1195
1202
  RUN_MODE="bg"
1196
1203
  OPUS_OVERSIGHT="false"
@@ -41,10 +41,17 @@ argument-hint: "[setup|spawn|list|attach|send|resume|kill|probe] ..."
41
41
  - `setup --probe-all`
42
42
  - `setup --diagnose`
43
43
 
44
+ `hosts.json` 은 user-state 경로 한 곳만 읽고 쓴다.
45
+ - macOS/Linux: `~/.config/triflux/hosts.json`
46
+ - Windows: `%APPDATA%\triflux\hosts.json`
47
+
48
+ 기존 `references/hosts.json` 및 source/packages/global 3곳 fan-out 단계는 더 이상 사용하지 않는다.
49
+ 첫 실행 시 legacy `references/hosts.json` 이 발견되면 lazy auto-migration으로 user-state 경로에 자동 이동된다.
50
+
44
51
  ### `tfx-remote spawn`
45
52
 
46
53
  기존 `tfx-remote-spawn` 플로우를 사용하되 아래 preflight를 먼저 수행한다.
47
- 1. `hosts.json` 존재 확인
54
+ 1. user-state `hosts.json` 존재 확인
48
55
  2. 호스트명/alias 해석
49
56
  3. probe TTL 확인
50
57
  4. SSH 실패 시 `setup diagnose` 또는 `setup edit` 복귀 경로 제시
@@ -70,6 +77,7 @@ preflight 실패 시 중단만 하지 말고 아래 중 하나로 복귀시킨
70
77
  ## hosts.json contract
71
78
 
72
79
  신규 코드는 가능하면 `hub/lib/hosts-compat.mjs`를 기준으로 해석한다.
80
+ 저장 위치는 macOS/Linux `~/.config/triflux/hosts.json`, Windows `%APPDATA%\triflux\hosts.json` 이다.
73
81
  - v1 legacy 필드 유지: `os`, `ssh_user`, `tailscale.ip`, `tailscale.dns`, `capabilities`
74
82
  - v2 additive 필드 허용: `ssh.user`, `capabilities_v2`, `last_probe`
75
83
 
@@ -341,6 +341,17 @@ options:
341
341
 
342
342
  저장 후 결과 보고.
343
343
 
344
+ **2-7-b. 소스 트리 동기화 (fan-out)**
345
+
346
+ `references/hosts.json` 은 skill reference 폴더에 위치하므로, 글로벌 (`~/.claude/skills/...`) 만 수정하면 triflux 소스 트리와 drift 가 발생한다. 수정 직후 아래 두 경로도 동일 내용으로 덮어쓴다 — 파일이 존재할 때만, 각 위치별로 `hosts.json.bak.<timestamp>` 백업 포함:
347
+
348
+ - `<triflux repo>/skills/tfx-remote-spawn/references/hosts.json`
349
+ - `<triflux repo>/packages/triflux/skills/tfx-remote-spawn/references/hosts.json`
350
+
351
+ triflux 소스 트리가 감지되지 않으면 이 단계를 건너뛴다. Edit 모드에서도 동일한 fan-out 을 적용한다.
352
+
353
+ > 이것은 임시 패치다. 근본 해결은 hosts.json 위치를 user-state 경로로 옮기는 것 (issue #178) 또는 `tfx setup` 의 user-state 파일 skip 로직 (issue #179).
354
+
344
355
  **2-8. MCP 고아 프로세스 정리 훅 배포 (Windows 원격 호스트)**
345
356
 
346
357
  원격 호스트가 Windows인 경우, MCP 고아 프로세스 정리 훅을 자동 배포한다.
@@ -436,6 +447,8 @@ options:
436
447
 
437
448
  선택된 항목에 대해 순차 AskUserQuestion으로 새 값 입력받아 Edit 도구로 hosts.json 수정.
438
449
 
450
+ 수정 후 **2-7-b 소스 트리 동기화 (fan-out)** 단계를 동일하게 적용한다.
451
+
439
452
  #### 전체 프로브 (Probe All)
440
453
 
441
454
  등록된 모든 호스트를 순회하며 프로브:
@@ -12,6 +12,15 @@ argument-hint: "[spawn|list|attach|send|resume|kill|probe] ..."
12
12
 
13
13
  이 스킬은 Phase 4b thin alias다. 새 표면은 `tfx-remote` 명령군이다.
14
14
 
15
+ ## hosts.json location
16
+
17
+ 호스트 별칭/설정은 canonical `tfx-remote` 와 동일하게 user-state 경로 한 곳만 사용한다.
18
+ - macOS/Linux: `~/.config/triflux/hosts.json`
19
+ - Windows: `%APPDATA%\triflux\hosts.json`
20
+
21
+ 기존 `references/hosts.json` 및 source/packages/global fan-out는 더 이상 사용하지 않는다.
22
+ 첫 실행 시 legacy `references/hosts.json` 이 발견되면 lazy auto-migration으로 user-state 경로에 자동 이동된다.
23
+
15
24
  ## Deprecation logging (alias 호출 즉시 실행 필수)
16
25
 
17
26
  canonical 위임 **이전** 에 아래 bash 블록을 한 번 실행한다. Phase 5 (v11) 물리 삭제 게이트는 `.omc/state/alias-usage.log` 의 7일 zero-usage 검증에 의존.
@@ -1,16 +1,41 @@
1
1
  {
2
2
  "hosts": {
3
- "ultra4": {
4
- "description": "Windows 데스크탑 (SSAFY)",
5
- "aliases": ["울트라", "데스크탑"],
6
- "default_dir": "~/Desktop/Projects"
7
- },
8
3
  "m2": {
9
- "description": "MacBook Pro",
4
+ "description": "MacBook Air (tellang M2)",
10
5
  "aliases": ["맥북", "맥"],
11
- "default_dir": "~/projects"
6
+ "default_dir": "~/projects",
7
+ "os": "darwin",
8
+ "ssh_user": "tellang",
9
+ "tailscale": {
10
+ "ip": "100.104.61.126",
11
+ "dns": "m2.sole-hexatonic.ts.net",
12
+ "ssh_mode": "ssh-over-vpn"
13
+ },
14
+ "capabilities": {
15
+ "ssh_active": true,
16
+ "claude": true,
17
+ "node": "v25.9.0"
18
+ },
19
+ "last_probe": "2026-04-24T19:04:48Z"
20
+ },
21
+ "fold7": {
22
+ "description": "Samsung Z Fold 7 (Termux)",
23
+ "aliases": ["폴드", "폰", "fold"],
24
+ "default_dir": "~",
25
+ "os": "android",
26
+ "ssh_user": "",
27
+ "tailscale": {
28
+ "ip": "100.107.139.115",
29
+ "dns": "fold7.sole-hexatonic.ts.net",
30
+ "ssh_mode": "ssh-over-vpn"
31
+ },
32
+ "capabilities": {
33
+ "ssh_active": false,
34
+ "note": "Termux sshd 미실행 — Fold7에서 'pkg install openssh && sshd -p 8022' 후 probe 재실행"
35
+ },
36
+ "last_probe": "2026-04-24T19:04:48Z"
12
37
  }
13
38
  },
14
- "default_host": "ultra4",
39
+ "default_host": "m2",
15
40
  "triggers": ["원격에서", "다른 머신에서", "다른 컴퓨터에서"]
16
41
  }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hosts": {
3
+ "ultra4": {
4
+ "description": "Windows 데스크탑 (SSAFY)",
5
+ "aliases": ["울트라", "데스크탑"],
6
+ "default_dir": "~/Desktop/Projects"
7
+ },
8
+ "m2": {
9
+ "description": "MacBook Pro",
10
+ "aliases": ["맥북", "맥"],
11
+ "default_dir": "~/projects"
12
+ }
13
+ },
14
+ "default_host": "ultra4",
15
+ "triggers": ["원격에서", "다른 머신에서", "다른 컴퓨터에서"]
16
+ }
@@ -44,7 +44,7 @@ options:
44
44
  Bash("triflux setup")
45
45
  ```
46
46
 
47
- 스크립트/HUD/스킬을 `~/.claude/`에 배포. 결과 표시.
47
+ 스크립트/HUD/스킬을 `~/.claude/`에 배포. 결과 표시. `tfx setup` 은 user-state 파일(예: macOS/Linux `~/.config/triflux/hosts.json`, Windows `%APPDATA%\triflux\hosts.json`)을 덮어쓰지 않는다.
48
48
 
49
49
  #### 단계 1.5: 훅 등록 확인
50
50
 
@@ -254,7 +254,11 @@ options:
254
254
 
255
255
  #### 단계 3.8: 원격 기기 프로빙 (Swarm Multi-Machine)
256
256
 
257
- `references/hosts.json` 또는 `~/.triflux/hosts.json` 존재 여부 확인.
257
+ user-state `hosts.json` 존재 여부 확인.
258
+ - macOS/Linux: `~/.config/triflux/hosts.json`
259
+ - Windows: `%APPDATA%\triflux\hosts.json`
260
+
261
+ 기존 source-tree `references/hosts.json` 은 더 이상 사용하지 않는다. legacy 파일이 남아 있으면 첫 원격 실행 시 lazy auto-migration으로 user-state 경로에 자동 이동된다.
258
262
 
259
263
  - 파일 없음 → AskUserQuestion:
260
264
  ```