triflux 10.30.0 → 10.31.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/hub/server.mjs CHANGED
@@ -2054,6 +2054,16 @@ export async function startHub({
2054
2054
  }, 60000);
2055
2055
  sessionTimer.unref();
2056
2056
 
2057
+ const synapsePruneTimer = setInterval(() => {
2058
+ try {
2059
+ const { count } = synapseRegistry.pruneExpired();
2060
+ if (count > 0) hubLog.info({ count }, "synapse.prune");
2061
+ } catch (err) {
2062
+ hubLog.warn({ err }, "synapse.prune_failed");
2063
+ }
2064
+ }, 60000);
2065
+ synapsePruneTimer.unref();
2066
+
2057
2067
  // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
2058
2068
  // TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
2059
2069
  const orphanCleanupTimer = setInterval(
@@ -2288,6 +2298,7 @@ export async function startHub({
2288
2298
  router.stopSweeper();
2289
2299
  clearInterval(hitlTimer);
2290
2300
  clearInterval(sessionTimer);
2301
+ clearInterval(synapsePruneTimer);
2291
2302
  clearInterval(rateLimitTimer);
2292
2303
  clearInterval(orphanCleanupTimer);
2293
2304
  if (idleTimer) {
@@ -5,6 +5,7 @@ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
5
5
  const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
6
6
  const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
7
7
  const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
8
+ const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
8
9
  // Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
9
10
  // timeout produces stale false-positives. 5-minute TTL + an `idle` state
10
11
  // distinguishes "alive but inactive" from "presumed dead".
@@ -133,6 +134,7 @@ export function createSynapseRegistry(opts = {}) {
133
134
  remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
134
135
  interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
135
136
  interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
137
+ expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
136
138
  } = opts;
137
139
 
138
140
  const sessions = new Map();
@@ -355,10 +357,89 @@ export function createSynapseRegistry(opts = {}) {
355
357
  return true;
356
358
  }
357
359
 
360
+ // stale/expired 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
361
+ // live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
362
+ function pruneExpired(opts2 = {}) {
363
+ if (destroyed) return { removed: [], count: 0 };
364
+
365
+ const cutoff =
366
+ typeof opts2.olderThanMs === "number"
367
+ ? opts2.olderThanMs
368
+ : expireTimeoutMs;
369
+ const removed = [];
370
+ const currentTime = now();
371
+
372
+ for (const [sessionId, session] of sessions) {
373
+ if (
374
+ isLiveStatus(session.status) ||
375
+ currentTime - session.lastHeartbeat <= cutoff
376
+ ) {
377
+ continue;
378
+ }
379
+
380
+ stopMonitor(sessionId);
381
+ sessions.delete(sessionId);
382
+ removed.push(sessionId);
383
+ notifyRemoved(session);
384
+ }
385
+
386
+ if (removed.length > 0) persist();
387
+ return { removed, count: removed.length };
388
+ }
389
+
358
390
  function heartbeat(sessionId, partialMeta = null) {
359
391
  const normalized = normalizeSessionId(sessionId);
360
392
  const session = sessions.get(normalized);
361
- if (!session) return false;
393
+ if (!session) {
394
+ // self-heal은 위치 정보(worktreePath 또는 cwd)가 있는 heartbeat에서만
395
+ // 발동한다. UserPromptSubmit heartbeat는 항상 worktreePath를 보내므로
396
+ // SessionStart register POST drop 복구 경로는 커버되고, taskSummary만 담은
397
+ // 빈약한 heartbeat로 위치 없는 가비지 세션이 register 되는 것은 막는다
398
+ // (그 경우는 기존대로 false → 400/heartbeat failed).
399
+ const hasLocator =
400
+ partialMeta &&
401
+ typeof partialMeta === "object" &&
402
+ !Array.isArray(partialMeta) &&
403
+ ((typeof partialMeta.worktreePath === "string" &&
404
+ partialMeta.worktreePath.length > 0) ||
405
+ (typeof partialMeta.cwd === "string" && partialMeta.cwd.length > 0));
406
+ const canSelfHeal = Boolean(normalized) && hasLocator;
407
+ if (!canSelfHeal) return false;
408
+
409
+ // 미등록 세션 heartbeat = SessionStart register POST가 drop된 경우.
410
+ // self-heal로 register 복구(영구 미등록 방지).
411
+ //
412
+ // sessionKind는 명시값이 없으면 "interactive"로 본다. 실제 복구 경로인
413
+ // heartbeatInteractiveSession은 worktreePath/host/taskSummary만 보내고
414
+ // sessionKind는 생략하는데, sanitizeSession 기본값("headless")으로 떨어지면
415
+ // 복구된 Claude 세션이 30초 local timeout을 받아 idle/5분 TTL을 잃고 곧장
416
+ // stale로 전이돼 getActive()/peer discovery에서 사라진다(이 PRD가 고치려던
417
+ // 원래 증상의 재현). heartbeat 기반 liveness로 살아나는 세션은 interactive다.
418
+ const sanitizedKind =
419
+ typeof partialMeta.sessionKind === "string"
420
+ ? partialMeta.sessionKind
421
+ : "interactive";
422
+ const healed = sanitizeSession(
423
+ {
424
+ ...partialMeta,
425
+ sessionId: normalized,
426
+ sessionKind: sanitizedKind,
427
+ status: "active",
428
+ lastHeartbeat: now(),
429
+ },
430
+ normalized,
431
+ );
432
+ if (!healed) return false;
433
+
434
+ sessions.set(normalized, healed);
435
+ startMonitor(normalized);
436
+ persist();
437
+ emitter?.emit("synapse.session.started", {
438
+ sessionId: normalized,
439
+ session: cloneSession(healed),
440
+ });
441
+ return true;
442
+ }
362
443
 
363
444
  const wasRemote = session.isRemote;
364
445
  const updated = { ...session, lastHeartbeat: now(), status: "active" };
@@ -492,6 +573,7 @@ export function createSynapseRegistry(opts = {}) {
492
573
  return Object.freeze({
493
574
  register,
494
575
  unregister,
576
+ pruneExpired,
495
577
  heartbeat,
496
578
  getActive,
497
579
  getAll,
@@ -1,4 +1,7 @@
1
1
  // hub/workers/codex-mcp.mjs — Codex MCP 서버 래퍼
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import process from "node:process";
3
6
  import { fileURLToPath } from "node:url";
4
7
 
@@ -94,13 +97,78 @@ function normalizeStructuredContent(structuredContent, fallbackText = "") {
94
97
  return { threadId, content };
95
98
  }
96
99
 
97
- function buildCodexArguments(prompt, opts = {}) {
100
+ /**
101
+ * Read a top-level TOML scalar (`key = "value"` / `key = 'value'` /
102
+ * `key = value`), tolerating an inline `# comment` and surrounding whitespace.
103
+ * Returns the trimmed value, or null when the key is absent/empty. Intentionally
104
+ * tiny — the per-profile files only carry `model` + `model_reasoning_effort`.
105
+ *
106
+ * @param {string} raw
107
+ * @param {string} key
108
+ * @returns {string|null}
109
+ */
110
+ function readTomlScalar(raw, key) {
111
+ const re = new RegExp(
112
+ `^\\s*${key}\\s*=\\s*(?:"([^"\\n]*)"|'([^'\\n]*)'|([^#\\n]*?))\\s*(?:#.*)?$`,
113
+ "m",
114
+ );
115
+ const match = raw.match(re);
116
+ if (!match) return null;
117
+ const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
118
+ return value || null;
119
+ }
120
+
121
+ /**
122
+ * Resolve a Codex effort profile name (e.g. "gpt55_high") to its concrete
123
+ * `model` + reasoning effort. SSOT = the per-profile file written by
124
+ * `tfx setup` at `~/.codex/<profile>.config.toml`. Falls back to deriving the
125
+ * effort from the `<...>_<effort>` naming convention when the file is absent.
126
+ *
127
+ * codex 0.137 removed `profile` from the Codex MCP tool schema, so we translate
128
+ * the profile into the still-accepted `model` + `config` fields instead of
129
+ * forwarding `profile` (which now hard-fails with "unknown field `profile`").
130
+ *
131
+ * @param {string} profileName
132
+ * @returns {{ model: string|null, reasoningEffort: string|null }}
133
+ */
134
+ export function resolveCodexProfileConfig(profileName) {
135
+ const result = { model: null, reasoningEffort: null };
136
+ if (typeof profileName !== "string" || !profileName) return result;
137
+
138
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
139
+ try {
140
+ const raw = readFileSync(
141
+ join(codexHome, `${profileName}.config.toml`),
142
+ "utf8",
143
+ );
144
+ result.model = readTomlScalar(raw, "model");
145
+ result.reasoningEffort = readTomlScalar(raw, "model_reasoning_effort");
146
+ } catch {
147
+ // Profile file absent (non-file alias). Derive effort from the naming
148
+ // convention; leave model unset so codex uses its config.toml default.
149
+ const suffix = /_(xhigh|high|med|medium|low)$/.exec(profileName);
150
+ if (suffix) {
151
+ const map = {
152
+ xhigh: "xhigh",
153
+ high: "high",
154
+ med: "medium",
155
+ medium: "medium",
156
+ low: "low",
157
+ };
158
+ result.reasoningEffort = map[suffix[1]] || null;
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+
164
+ export function buildCodexArguments(prompt, opts = {}) {
98
165
  const args = { prompt };
99
166
 
100
167
  if (typeof opts.cwd === "string" && opts.cwd) args.cwd = opts.cwd;
101
168
  if (typeof opts.model === "string" && opts.model) args.model = opts.model;
102
- if (typeof opts.profile === "string" && opts.profile)
103
- args.profile = opts.profile;
169
+ // NOTE: `profile` is intentionally NOT forwarded as a Codex tool argument.
170
+ // codex 0.137 dropped it from the tool schema; it is resolved into
171
+ // `model` + `config.model_reasoning_effort` just before return (see below).
104
172
  if (typeof opts.approvalPolicy === "string" && opts.approvalPolicy) {
105
173
  args["approval-policy"] = opts.approvalPolicy;
106
174
  }
@@ -120,6 +188,22 @@ function buildCodexArguments(prompt, opts = {}) {
120
188
  args["compact-prompt"] = opts.compactPrompt;
121
189
  }
122
190
 
191
+ // Map the effort profile to model + reasoning effort instead of forwarding the
192
+ // (0.137-rejected) `profile` field. Explicit opts.model wins over the profile;
193
+ // profile effort merges on top of any opts.config.
194
+ if (typeof opts.profile === "string" && opts.profile) {
195
+ const resolved = resolveCodexProfileConfig(opts.profile);
196
+ if (resolved.model && typeof args.model !== "string") {
197
+ args.model = resolved.model;
198
+ }
199
+ if (resolved.reasoningEffort) {
200
+ args.config = {
201
+ ...(args.config || {}),
202
+ model_reasoning_effort: resolved.reasoningEffort,
203
+ };
204
+ }
205
+ }
206
+
123
207
  return args;
124
208
  }
125
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.30.0",
3
+ "version": "10.31.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -123,7 +123,11 @@ _preflight_check_gh_auth
123
123
  # top-level sandbox/approval_mode와 의미가 다르다. 이 값이 "approve"이면
124
124
  # codex exec이 non-TTY subprocess에서 승인 대기로 stall하므로 감지 대상에서 제외.
125
125
  # (refs: tellang/triflux#66, Yeachan-Heo/oh-my-codex#1478)
126
- _CODEX_CONFIG="${HOME}/.codex/config.toml"
126
+ # TFX_CODEX_CONFIG override: integration tests that run the full route script
127
+ # point this at an isolated tmpdir config so the MCP config-swap never mutates
128
+ # the real ~/.codex/config.toml (non-hermetic corruption guard). Defaults to the
129
+ # real path for normal runs.
130
+ _CODEX_CONFIG="${TFX_CODEX_CONFIG:-${HOME}/.codex/config.toml}"
127
131
  _CODEX_HAS_SANDBOX=""
128
132
  if [[ -f "$_CODEX_CONFIG" ]] && awk '
129
133
  /^\[{1,2}mcp_servers\..*\.tools\./ { in_mcp_tool=1; next }
@@ -460,6 +464,85 @@ prepend_codex_north_star() {
460
464
  rm -f "$tmp_file" 2>/dev/null || true
461
465
  }
462
466
 
467
+ # prepend_skill: opt-in 으로 등록된 스킬의 SKILL.md 본문을 프롬프트 앞에 주입한다.
468
+ # 활성 조건은 TFX_INJECT_SKILL env (tfx-auto 가 --skill <name> 으로 set). 미설정이면
469
+ # 현행 동작 그대로 (no-op). CLI-agnostic — codex/agy 레인에서 동일하게 재사용한다.
470
+ # 전달은 prepend_codex_north_star 와 동일하게 printf/cat → temp file 만 사용하므로
471
+ # $ / 백슬래시(₩) 같은 특수문자를 셸 재확장 없이 리터럴로 보존한다 (parse-safe).
472
+ # 스킬 경로: ${TFX_SKILLS_DIR:-<repo>/skills}/<name>/SKILL.md.
473
+ prepend_skill() {
474
+ local prompt="${1:-}"
475
+ local skill_name="${TFX_INJECT_SKILL:-}"
476
+ if [[ -z "$skill_name" ]]; then
477
+ printf '%s' "$prompt"
478
+ return 0
479
+ fi
480
+ # 경로 탈출 방지: skill_name 은 skills/ 하위 단일 디렉토리 이름만 허용한다.
481
+ if [[ "$skill_name" == *"/"* || "$skill_name" == *".."* ]]; then
482
+ echo "[tfx-route] WARNING: TFX_INJECT_SKILL='${skill_name}' 에 경로 구분자/.. 포함 — 주입 거부" >&2
483
+ printf '%s' "$prompt"
484
+ return 0
485
+ fi
486
+
487
+ local skills_dir="${TFX_SKILLS_DIR:-}"
488
+ if [[ -z "$skills_dir" ]]; then
489
+ skills_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)/skills"
490
+ fi
491
+ local skill_file="${skills_dir}/${skill_name}/SKILL.md"
492
+ if [[ ! -r "$skill_file" ]]; then
493
+ echo "[tfx-route] WARNING: TFX_INJECT_SKILL='${skill_name}' 스킬을 찾지 못함 ($skill_file) — 주입 생략" >&2
494
+ printf '%s' "$prompt"
495
+ return 0
496
+ fi
497
+
498
+ local tmp_file
499
+ tmp_file="$(mktemp "${TFX_TMP:-${TMPDIR:-/tmp}}/tfx-skill-prompt.XXXXXX" 2>/dev/null)" || {
500
+ printf '%s' "$prompt"
501
+ return 0
502
+ }
503
+
504
+ if ! {
505
+ printf '%s\n' "--- SKILL: ${skill_name} (apply this methodology to the task below) ---"
506
+ cat "$skill_file"
507
+ if [[ -s "$skill_file" ]]; then
508
+ local last_byte
509
+ last_byte="$(tail -c 1 "$skill_file" 2>/dev/null || true)"
510
+ [[ -n "$last_byte" ]] && printf '\n'
511
+ fi
512
+ printf '%s\n' "--- END SKILL ---"
513
+ printf '%s' "$prompt"
514
+ } > "$tmp_file"; then
515
+ rm -f "$tmp_file" 2>/dev/null || true
516
+ printf '%s' "$prompt"
517
+ return 0
518
+ fi
519
+
520
+ cat "$tmp_file" 2>/dev/null || printf '%s' "$prompt"
521
+ rm -f "$tmp_file" 2>/dev/null || true
522
+ }
523
+
524
+ # append_agy_anti_overclaim: agy(Gemini 3.x) 레인 전용 완료/grounding 규율 블록을
525
+ # 프롬프트 *뒤*에 붙인다. Gemini 3.x 는 과신(overconfidence) outlier 라 거짓 완료
526
+ # 주장·환각이 잦다 (AA-Omniscience 실증). 부정 제약은 Gemini 3 공식 가이드 권고대로
527
+ # 프롬프트 END 에 배치하고, blanket "do not guess" 대신 "주어진 context 로 추론 +
528
+ # 정확한 abstention 우선" 형태로 적는다 (open-ended negative 는 역효과). TFX_AGY_ANTI_OVERCLAIM=0 으로 opt-out.
529
+ append_agy_anti_overclaim() {
530
+ local prompt="${1:-}"
531
+ case "${TFX_AGY_ANTI_OVERCLAIM:-1}" in
532
+ 0|false|FALSE|off|OFF|no|NO)
533
+ printf '%s' "$prompt"
534
+ return 0
535
+ ;;
536
+ esac
537
+
538
+ printf '%s' "$prompt"
539
+ printf '\n\n%s\n' "--- COMPLETION & GROUNDING DISCIPLINE (apply strictly) ---"
540
+ printf '%s\n' "- Claim done/fixed/passing ONLY after running the check in this turn and seeing its output. With no fresh evidence, report what is still unverified instead of asserting success."
541
+ printf '%s\n' "- Ground every answer in the provided context and the actual repository. If something is not verifiable from what you can see, say 'No Info / 확인 불가' and stop rather than inventing plausible-but-unconfirmed details."
542
+ printf '%s\n' "- Use the provided context for deductions and prefer accurate abstention over confident guessing."
543
+ printf '%s' "--- END DISCIPLINE ---"
544
+ }
545
+
463
546
  read_probe_state() {
464
547
  local pid="$1"
465
548
  local state_file="${TFX_PROBE_STATE_FILE:-${TFX_PROBE_DIR}/${pid}.json}"
@@ -2674,6 +2757,14 @@ FALLBACK_EOF
2674
2757
  _codex_full_prompt_with_sentinel="$(prepend_codex_north_star "$FULL_PROMPT"; printf '%s' "$_codex_prompt_sentinel")"
2675
2758
  FULL_PROMPT="${_codex_full_prompt_with_sentinel%"$_codex_prompt_sentinel"}"
2676
2759
  fi
2760
+ # Opt-in 스킬 주입 (TFX_INJECT_SKILL). north-star 와 동일한 sentinel 패턴으로
2761
+ # trailing newline 을 보존한다. 미설정이면 prepend_skill 이 no-op.
2762
+ if [[ -n "${TFX_INJECT_SKILL:-}" ]]; then
2763
+ local _codex_skill_sentinel="__TFX_CODEX_SKILL_END_${$}_${RANDOM}__"
2764
+ local _codex_skill_prompt
2765
+ _codex_skill_prompt="$(prepend_skill "$FULL_PROMPT"; printf '%s' "$_codex_skill_sentinel")"
2766
+ FULL_PROMPT="${_codex_skill_prompt%"$_codex_skill_sentinel"}"
2767
+ fi
2677
2768
  codex_transport_effective="exec"
2678
2769
  if [[ "$TFX_CODEX_TRANSPORT" != "exec" ]]; then
2679
2770
  run_codex_mcp "$FULL_PROMPT" "$use_tee" || exit_code=$?
@@ -2778,6 +2869,18 @@ EOF
2778
2869
  _agy_full_prompt_with_sentinel="$(prepend_codex_north_star "$FULL_PROMPT"; printf '%s' "$_agy_prompt_sentinel")"
2779
2870
  FULL_PROMPT="${_agy_full_prompt_with_sentinel%"$_agy_prompt_sentinel"}"
2780
2871
  fi
2872
+ # Opt-in 스킬 주입 (TFX_INJECT_SKILL) — codex 레인과 동일.
2873
+ if [[ -n "${TFX_INJECT_SKILL:-}" ]]; then
2874
+ local _agy_skill_sentinel="__TFX_AGY_SKILL_END_${$}_${RANDOM}__"
2875
+ local _agy_skill_prompt
2876
+ _agy_skill_prompt="$(prepend_skill "$FULL_PROMPT"; printf '%s' "$_agy_skill_sentinel")"
2877
+ FULL_PROMPT="${_agy_skill_prompt%"$_agy_skill_sentinel"}"
2878
+ fi
2879
+ # agy(Gemini 3.x) anti-overclaim 규율 블록을 프롬프트 END 에 append. opt-out: TFX_AGY_ANTI_OVERCLAIM=0.
2880
+ local _agy_aoc_sentinel="__TFX_AGY_AOC_END_${$}_${RANDOM}__"
2881
+ local _agy_aoc_prompt
2882
+ _agy_aoc_prompt="$(append_agy_anti_overclaim "$FULL_PROMPT"; printf '%s' "$_agy_aoc_sentinel")"
2883
+ FULL_PROMPT="${_agy_aoc_prompt%"$_agy_aoc_sentinel"}"
2781
2884
  run_antigravity_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
2782
2885
  if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
2783
2886
  local agy_stderr_bytes=0
@@ -28,7 +28,7 @@ triggers:
28
28
  - spec-panel
29
29
  - business-panel
30
30
  - index-repo
31
- argument-hint: "<command|task> [args...] [--cli auto|codex|antigravity|claude] [--mode quick|deep|consensus] [--risk-tier auto|low|medium|high] [--shape consensus|debate|panel] [--cli-set triad|no-antigravity|custom] [--parallel 1|N|swarm] [--retry 0|1|ralph] [--isolation none|worktree] [--remote <host>|none]"
31
+ argument-hint: "<command|task> [args...] [--cli auto|codex|antigravity|claude] [--mode quick|deep|consensus] [--risk-tier auto|low|medium|high] [--shape consensus|debate|panel] [--cli-set triad|no-antigravity|custom] [--parallel 1|N|swarm] [--retry 0|1|ralph] [--isolation none|worktree] [--remote <host>|none] [--skill <name>]"
32
32
  ---
33
33
 
34
34
  # tfx-auto — 통합 CLI 오케스트레이터
@@ -169,6 +169,7 @@ ARGUMENTS 에 아래 플래그가 있으면 Step 0 스마트 라우팅의 내부
169
169
  | `--no-claude-native` | false (기본) | Claude native sub-agent 경로 유지 | — |
170
170
  | `--no-claude-native` | true | Claude native 경로 disable, CLI 기반 worker 강제 | tfx-route.sh |
171
171
  | `--max-iterations` | `0` (기본, unlimited) | `--retry ralph`/`auto-escalate` 상한 | retry-state-machine.mjs |
172
+ | `--skill` | `<name>` | `skills/<name>/SKILL.md` 본문을 codex/agy 프롬프트 앞에 주입 (`TFX_INJECT_SKILL`). 미지정 시 no-op | tfx-route.sh |
172
173
 
173
174
  ### `--risk-tier` 계약
174
175
 
@@ -204,6 +205,37 @@ ARGUMENTS 에 아래 플래그가 있으면 Step 0 스마트 라우팅의 내부
204
205
  - `--retry ralph` → true ralph state machine (Phase 3, retry-state-machine.mjs)
205
206
  - `--retry auto-escalate` → CLI 승격 체인 (Phase 3)
206
207
  - `--max-iterations N` (N>0) → ralph/auto-escalate 에 상한 부여
208
+ - `--skill <name>` → `TFX_INJECT_SKILL=<name>` 로 tfx-route.sh 에 전달. 스킬 파일 부재 시 warning 후 주입 생략 (fail-open, 작업은 계속)
209
+
210
+ ### 스킬 주입 (`--skill <name>`)
211
+
212
+ codex/agy 워커 프롬프트 앞에 등록된 스킬의 방법론을 주입하는 **opt-in** 프레임워크. 설계 근거는 `decision-skill-passing` (omc 레퍼런스 + codex/agy 네이티브 스킬 조사):
213
+
214
+ - **메커니즘**: `--skill <name>` → tfx-route.sh `TFX_INJECT_SKILL=<name>`. `skills/<name>/SKILL.md` 본문을 `--- SKILL: <name> (apply this methodology...) ---` delimited block 으로 프롬프트 앞에 prepend. codex·agy 레인 공통 (CLI-agnostic, `prepend_skill`).
215
+ - **기본 off**: 미지정이면 no-op → 현행 동작 그대로. 회귀 위험 없음.
216
+ - **파싱 안전**: prepend 는 `printf`/`cat` → temp file 만 사용. codex 는 argv `--` 뒤, agy 는 stdin `printf %s` 로 전달되므로 `$`(codex)·`₩`/백슬래시(agy) 같은 특수문자를 셸 재확장 없이 **리터럴 보존**. 스킬 본문을 그대로 넘겨도 깨지지 않음.
217
+ - **네이티브 스킬을 안 쓰는 이유**: codex `$skill-name`/`/name` 슬래시와 agy `/name` 은 둘 다 **인터랙티브 TUI 전용** → headless one-shot 에서 named-skill 강제 호출 불가 (미문서). 그래서 prose 주입을 채택 (레퍼런스 omc 도 role .md 를 raw prepend, 네이티브 회피).
218
+
219
+ #### 커맨드→스킬 매핑 (tfx-auto convention, opt-in)
220
+
221
+ tfx-auto 가 커맨드/agent 별로 주입할 스킬을 고를 때 쓰는 **권고 테이블**. 기본은 매핑 없음 (명시 `--skill` 만 작동) — 부적절한 스킬이 모든 프롬프트에 새는 것을 막는다. 프로젝트가 명시적으로 활성화할 때만 아래를 적용해 `--skill` 을 자동 부여한다.
222
+
223
+ | 커맨드/agent | 권고 스킬 | 비고 |
224
+ |--------------|-----------|------|
225
+ | (기본) | 없음 | explicit `--skill` 우선 |
226
+ | 프로젝트 정의 | 프로젝트가 지정 | 활성화 시 tfx-auto 가 해당 `--skill` set |
227
+
228
+ > 매핑된 스킬 파일이 없으면 `prepend_skill` 이 warning 후 주입을 생략한다 (fail-open). 존재하지 않는 매핑을 "주입됨"으로 가정하지 않는다.
229
+
230
+ #### agy anti-overclaim (자동, agy 레인 전용)
231
+
232
+ agy(Gemini 3.x) 레인은 `TFX_AGY_ANTI_OVERCLAIM`(기본 on) 으로 완료/grounding 규율 블록을 프롬프트 **END** 에 자동 append (`append_agy_anti_overclaim`). Gemini 3.x 과신(AA-Omniscience 실측: 정확도 53-56% 대비 환각률 88-91%) 대응:
233
+
234
+ - fresh 증거 없이 done/fixed/passing 주장 금지.
235
+ - 검증 불가 시 `No Info / 확인 불가` 후 중단 (날조 금지).
236
+ - 추론은 주어진 context 로, confident guessing 보다 accurate abstention 우선.
237
+
238
+ 부정 제약은 Gemini 3 공식 가이드대로 **END 배치**하고 blanket "do not guess" 는 역효과라 피한다. opt-out: `TFX_AGY_ANTI_OVERCLAIM=0`.
207
239
 
208
240
  ### Retry state machine 계약 (legacy autoroute / persist 이관)
209
241
 
@@ -0,0 +1,382 @@
1
+ ---
2
+ name: tfx-goal-clarify
3
+ description: "자연어 아이디어를 Claude Code `/goal` 명령어 한 블록으로 변환하는 clarifier. 모호한 작업 설명을 받아 공식 3축(End state / Stated check / Constraints) + stop bound로 압축해 복붙 가능한 /goal 문자열을 출력. '/goal로 만들어', 'goal 프롬프트', 'goal 변환', 'clarify goal', 'goal 양식', '자율 작업 시작' 같은 요청에 사용."
4
+ triggers:
5
+ - goal 프롬프트
6
+ - goal 변환
7
+ - clarify goal
8
+ - goal 양식
9
+ - /goal 만들어
10
+ - goal-clarify
11
+ - tfx-goal-clarify
12
+ - 자율 작업 만들어
13
+ argument-hint: "<목표 자연어> [--tier 1|2|3]"
14
+ allowed-tools:
15
+ - Bash
16
+ - Read
17
+ - Glob
18
+ - Grep
19
+ - AskUserQuestion
20
+ - Write
21
+ ---
22
+
23
+ # tfx-goal-clarify — 자연어 → `/goal` 블록 변환기
24
+
25
+ > tfx-interview의 슬림 fork. **인터뷰 산출물을 Action Plan 대신 `/goal` 블록 한 덩어리로만** 출력한다.
26
+ > 공식 `/goal` 3축(End state / Stated check / Constraints) + `or stop after N turns`를 강제한다.
27
+
28
+ ## 용도
29
+
30
+ Claude Code 2.1.139+의 `/goal` 명령어에 넣을 프롬프트를 자연어 아이디어로부터 구조화한다. `/goal`은 평가자(Haiku)가 transcript만 보고 yes/no 판정하므로, **측정 불가능한 조건은 무한 루프 또는 환각 yes**를 유발한다. 이 스킬은 그 함정을 회피한다.
31
+
32
+ **비교**:
33
+ - `tfx-interview`: 일반 요구사항 인터뷰 → 전체 Action Plan 산출
34
+ - `tfx-goal-clarify`: **/goal 블록 한 덩어리만** 산출 (5분 내 완료 목표)
35
+ - `gstack-office-hours`: 제품 아이디어 단계 (코드 이전)
36
+
37
+ ## 입력 처리
38
+
39
+ `ARGUMENTS: <자연어 목표>`가 들어오면 첫 입력으로 사용. 없으면 사용자에게 요청.
40
+
41
+ `--tier N` 플래그:
42
+ - `1` = one-liner (3턴 이내 작업)
43
+ - `2` = standard 3-part (기본값, 10턴 이내)
44
+ - `3` = 9-section (다중 모듈/다일 작업)
45
+
46
+ ## 워크플로우
47
+
48
+ ### Step 1: 분류 + 초기 ambiguity 측정
49
+
50
+ 코드베이스 컨텍스트 살펴보기 (선택적):
51
+ ```bash
52
+ # 사용자가 어떤 repo에서 호출했는지 확인
53
+ git rev-parse --show-toplevel 2>/dev/null
54
+ ```
55
+
56
+ 자연어 입력을 3축으로 분해:
57
+ | 축 | 질문 | 점수 (0~1) |
58
+ |----|------|-----------|
59
+ | End state | 완료를 무엇으로 증명? | goal |
60
+ | Check | Claude가 어떤 명령/출력으로 보여줄 수 있나? | constraints |
61
+ | Constraints | 절대 건드리면 안 될 것은? | criteria |
62
+
63
+ `ambiguity = 1 - (end_state×0.4 + check×0.3 + constraints×0.3)`
64
+
65
+ Gemini 위임 (tfx-interview와 동일):
66
+ ```bash
67
+ Bash("bash ~/.claude/scripts/tfx-route.sh gemini '<analysis prompt>'")
68
+ ```
69
+
70
+ Gemini 미사용 환경(m5 등 SSH 원격)에서는 Claude가 직접 채점.
71
+
72
+ ### Step 2: 적응형 인터뷰 (AskUserQuestion 사용, Tier별 질문 수 가변)
73
+
74
+ **반드시 Claude Code 네이티브 `AskUserQuestion` 도구를 사용**한다. 자유 텍스트 질의 금지 — 사용자가 옵션 중에서 선택하도록 구조화한다.
75
+
76
+ **Tier별 필수 축**:
77
+ | Tier | 핵심 축 (필수) | 보조 축 (조건부) | 질문 수 |
78
+ |------|---------------|----------------|---------|
79
+ | 1 | End state, Bound | — | 1~2 |
80
+ | 2 | End state, Check, Constraints, Bound | Scope | 3~5 |
81
+ | 3 | End state, Check, Constraints, Scope, Priority, Plan, Bound | Rollback, Output artifact | 5~8 |
82
+
83
+ **ambiguity < 20% 도달 시 즉시 종료** (남은 질문 스킵).
84
+
85
+ #### AskUserQuestion 호출 패턴 (예시)
86
+
87
+ 각 축마다 다음 형태로 호출. **options는 사용자 입력 기반으로 동적 생성** + 사용자가 "Other"로 직접 입력 가능.
88
+
89
+ **Q1. End state (모든 Tier 필수)**
90
+ ```json
91
+ {
92
+ "questions": [{
93
+ "question": "이 작업이 끝났다는 걸 어떤 단일 측정 가능한 결과로 증명하나요?",
94
+ "header": "End state",
95
+ "multiSelect": false,
96
+ "options": [
97
+ {"label": "테스트 exit code", "description": "예: pnpm test <path> exits 0"},
98
+ {"label": "패턴 카운트 0", "description": "예: rg \"legacy\" src returns 0 hits"},
99
+ {"label": "타입체크 통과", "description": "예: pnpm typecheck exits 0"},
100
+ {"label": "빌드 성공", "description": "예: pnpm build exits 0"}
101
+ ]
102
+ }]
103
+ }
104
+ ```
105
+
106
+ **Q2. Check 명령 (Tier 2/3)**
107
+ 사용자 End state 선택 후 그 결과를 어떻게 transcript에 노출시킬지 — 보통 자동 추론(End state가 이미 명령형이면 그대로 사용). 모호하면 명령 후보 3-4개 옵션.
108
+
109
+ **Q3. Scope (Tier 2/3, 코드베이스 컨텍스트 있을 때만)**
110
+ ```json
111
+ {
112
+ "questions": [{
113
+ "question": "변경 허용 범위는 어디까지인가요?",
114
+ "header": "Scope",
115
+ "multiSelect": true,
116
+ "options": [
117
+ {"label": "src/<module>/만", "description": "단일 모듈 한정"},
118
+ {"label": "src/ 전체", "description": "소스 전체"},
119
+ {"label": "tests/ 포함", "description": "테스트도 수정 가능"},
120
+ {"label": "package.json 가능", "description": "의존성 추가 허용"}
121
+ ]
122
+ }]
123
+ }
124
+ ```
125
+
126
+ **Q4. Constraints (Tier 2/3 필수)**
127
+ ```json
128
+ {
129
+ "questions": [{
130
+ "question": "절대 변경하면 안 되는 것은 무엇인가요?",
131
+ "header": "Constraints",
132
+ "multiSelect": true,
133
+ "options": [
134
+ {"label": "공개 API 시그니처", "description": "props/exports 유지"},
135
+ {"label": "기존 테스트", "description": "tests/legacy/ 등 보호"},
136
+ {"label": "DB 스키마", "description": "migration 금지"},
137
+ {"label": "없음 (None)", "description": "제약 없이 진행"}
138
+ ]
139
+ }]
140
+ }
141
+ ```
142
+
143
+ **Q5. Priority (Tier 3만)**
144
+ ```json
145
+ {
146
+ "questions": [{
147
+ "question": "충돌 시 어느 쪽을 우선하나요?",
148
+ "header": "Priority",
149
+ "multiSelect": false,
150
+ "options": [
151
+ {"label": "타입 안전 > 테스트 > 시각", "description": "타입체크 우선"},
152
+ {"label": "테스트 > 타입 > 시각", "description": "기존 동작 보존"},
153
+ {"label": "사용자 정의", "description": "직접 입력"}
154
+ ]
155
+ }]
156
+ }
157
+ ```
158
+
159
+ **Q6. Bound (모든 Tier 필수, 마지막)**
160
+ ```json
161
+ {
162
+ "questions": [{
163
+ "question": "최대 얼마나 돌릴까요? (Stop 조건)",
164
+ "header": "Stop bound",
165
+ "multiSelect": false,
166
+ "options": [
167
+ {"label": "10 turns", "description": "단순 수정 작업"},
168
+ {"label": "20 turns", "description": "중간 규모 (기본 권장)"},
169
+ {"label": "30 turns", "description": "마이그레이션"},
170
+ {"label": "60 turns", "description": "다중 모듈 (Tier 3)"}
171
+ ]
172
+ }]
173
+ }
174
+ ```
175
+
176
+ **Q7. Rollback (Tier 3 선택)**
177
+ 실패 시 어떻게 복구할지 — git revert, checkpoint commit 빈도 등.
178
+
179
+ **Q8. Output artifact (Tier 3 선택)**
180
+ 산출물 형태 — PR description, 보고서 파일, 단순 코드 변경 등.
181
+
182
+ #### 적응형 종료 조건
183
+
184
+ 각 응답 후 ambiguity 재계산:
185
+ ```
186
+ if ambiguity < 0.20:
187
+ → 즉시 Step 3 (남은 질문 스킵)
188
+ elif 모든 필수 축 답변 완료:
189
+ → Step 3
190
+ elif 질문 수 >= Tier별 상한:
191
+ → Step 3 (남은 ambiguity는 사용자가 직접 편집할 영역으로 표시)
192
+ else:
193
+ → 다음 질문
194
+ ```
195
+
196
+ #### Fallback: AskUserQuestion 미사용 환경 (Codex/Gemini CLI 등)
197
+
198
+ 자유 텍스트로 한 질문씩 던지되, 위 옵션 리스트를 보기로 제시.
199
+
200
+ ### Step 3: `/goal` 블록 조립
201
+
202
+ **Tier 1 (one-liner)** — 단순 명령:
203
+ ```
204
+ /goal <end_state> [or stop after N turns]
205
+ ```
206
+
207
+ **Tier 2 (standard, 기본)**:
208
+ ```
209
+ /goal
210
+ End state: {end_state}
211
+ Check: {check_command}
212
+ Constraints: {constraints}
213
+ Bound: or stop after {N} turns
214
+ ```
215
+
216
+ **Tier 3 (9-section)** — 다중 모듈/긴 작업:
217
+ ```
218
+ /goal
219
+ GOAL: {goal}
220
+ CONTEXT: {context}
221
+ CONSTRAINTS: {constraints}
222
+ PRIORITY: {priority}
223
+ PLAN: {plan_steps}
224
+ DONE WHEN: {done_when}
225
+ VERIFY: {verify_commands}
226
+ OUTPUT: {output_artifacts}
227
+ STOP RULES: or stop after {N} turns
228
+ ```
229
+
230
+ ### Step 4: 사용자 표시 + 검증
231
+
232
+ 출력 형식 (반드시 이 순서):
233
+
234
+ ```
235
+ 📊 Ambiguity: {initial}% → {final}% (목표 < 20%)
236
+ - End state: {pct}%
237
+ - Check: {pct}%
238
+ - Constraints: {pct}%
239
+
240
+ 📋 4000자 한도: {used}/{4000} chars
241
+
242
+ 📦 /goal 블록 (Tier {N}):
243
+
244
+ ────────── 복붙 영역 ──────────
245
+ {the assembled /goal block}
246
+ ─────────────────────────────────
247
+
248
+ ✅ 검증:
249
+ - 종료 상태가 측정 가능한가? {Y/N}
250
+ - Check 명령이 transcript에 결과를 남기는가? {Y/N}
251
+ - Stop bound가 있는가? {Y/N}
252
+ - 4000자 이내인가? {Y/N}
253
+ ```
254
+
255
+ ### Step 5: 저장 (선택)
256
+
257
+ 기본은 출력만. 사용자가 원하면 저장:
258
+ - 위치: `.tfx/goals/goal-{timestamp}.txt`
259
+ - 형식: 원본 자연어 + 인터뷰 응답 + 최종 /goal 블록
260
+
261
+ ## 출력 후 옵션 (AskUserQuestion)
262
+
263
+ ```
264
+ 질문: 이 /goal 블록을 어떻게 처리할까요?
265
+ 옵션:
266
+ - 클립보드 복사: 블록을 클립보드로 복사 (입력창에 붙여넣기만 하면 됨)
267
+ - 출력만: 화면 출력만 하고 종료
268
+ - 수정: 특정 축(End state/Check/Constraints) 재질문
269
+ - Tier 변경: 1↔2↔3 재조립
270
+ ```
271
+
272
+ > `/goal`은 Claude Code 슬래시 명령어라 **에이전트가 세션 입력창에 대신 전송할 수 없다.**
273
+ > 그래서 "바로 실행" 옵션은 두지 않는다. 사용자가 클립보드에서 입력창에 직접 붙여넣어 실행한다.
274
+
275
+ ### 클립보드 복사 실행 방법
276
+
277
+ "클립보드 복사" 선택 시 `Bash`로 OS별 클립보드 명령에 블록을 파이프한다.
278
+ 블록에 백틱·따옴표가 섞이므로 **임시 파일을 거쳐** 복사한다 (인용 깨짐 방지).
279
+
280
+ ```bash
281
+ # 1) 조립된 /goal 블록을 임시 파일로 기록 (Write 도구 또는 heredoc)
282
+ cat > "${TMPDIR:-/tmp}/tfx-goal-block.txt" <<'GOAL_EOF'
283
+ <the assembled /goal block>
284
+ GOAL_EOF
285
+
286
+ # 2) OS별 클립보드로 복사 (darwin 우선, fallback chain)
287
+ f="${TMPDIR:-/tmp}/tfx-goal-block.txt"
288
+ if command -v pbcopy >/dev/null 2>&1; then pbcopy < "$f" # macOS
289
+ elif command -v wl-copy >/dev/null 2>&1; then wl-copy < "$f" # Wayland
290
+ elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard < "$f" # X11
291
+ elif command -v clip.exe >/dev/null 2>&1; then clip.exe < "$f" # Windows/WSL
292
+ else echo "클립보드 명령 없음 — 위 출력에서 수동 복사"; fi
293
+ ```
294
+
295
+ 복사 성공 시 `✅ 클립보드에 복사됨 — 입력창에 붙여넣어 실행하세요.` 를 출력한다.
296
+
297
+ ## 동작 규칙
298
+
299
+ 1. **출력 우선**: 최종 산출물은 항상 `/goal` 블록 한 덩어리. 부수 설명은 위/아래에만.
300
+ 2. **4000자 강제**: 한도 초과 시 자동으로 PLAN/CONTEXT를 압축.
301
+ 3. **Stop bound 강제**: 사용자가 명시 안 해도 `or stop after 20 turns`를 기본 삽입 (Tier 2/3).
302
+ 4. **Check 강제**: 평가자가 transcript만 보므로 Check 없는 블록은 거부.
303
+ 5. **Constraints 비어있어도 OK**: 'none' 명시.
304
+ 6. **언어**: 사용자가 한국어로 입력하면 인터뷰는 한국어, /goal 블록은 영어 (Claude Code가 영어 명령에 더 안정적).
305
+
306
+ ## 안티패턴 (자동 거부)
307
+
308
+ 다음 End state는 거부:
309
+ - "production-ready", "better", "modern", "clean" — 주관적
310
+ - "all tests pass" without 명시된 테스트 위치 — 범위 폭발
311
+ - "refactor everything" — 범위 무제한
312
+
313
+ 거부 시 사용자에게:
314
+ ```
315
+ ⚠️ End state "{...}"는 평가자가 측정할 수 없습니다.
316
+ 다음 중 어느 형태로 바꿀까요?
317
+ 1) 명령어 exit code (예: pnpm test exits 0)
318
+ 2) 파일/문자열 카운트 (예: rg X returns 0 hits)
319
+ 3) 파일 변경 여부 (예: git diff --stat shows ≤3 files changed)
320
+ ```
321
+
322
+ ## Quick Examples
323
+
324
+ ### Tier 1 입력/출력
325
+ ```
326
+ 입력: "auth 테스트 다 통과시켜"
327
+ 출력: /goal pnpm test test/auth exits 0, or stop after 15 turns
328
+ ```
329
+
330
+ ### Tier 2 입력/출력
331
+ ```
332
+ 입력: "레거시 auth API 호출들을 v2로 마이그레이션"
333
+ 출력:
334
+ /goal
335
+ End state: src/legacy/auth/*.ts 의 모든 호출이 src/auth/v2 로 마이그레이션됨
336
+ Check: `pnpm test src/auth` exits 0 AND `rg "legacy/auth" src` returns 0 hits
337
+ Constraints: tests/legacy/는 수정 금지, package.json 의존성 추가 금지
338
+ Bound: or stop after 30 turns
339
+ ```
340
+
341
+ ### Tier 3 입력/출력
342
+ ```
343
+ 입력: "users 모듈 전체를 새 design system으로 갈아엎고 PR 만들어"
344
+ 출력:
345
+ /goal
346
+ GOAL: src/users/** 의 모든 React 컴포넌트를 @company/ds-v3로 마이그레이션
347
+ CONTEXT: 기존은 @company/ds-v2, 디자인 토큰은 docs/design/v3.md, 18개 컴포넌트
348
+ CONSTRAINTS:
349
+ - tests/users/ snapshot 갱신 외 변경 금지
350
+ - props 시그니처 유지 (caller 영향 없음)
351
+ - i18n key 변경 금지
352
+ PRIORITY: 시각적 회귀 < 타입 안전성 < 테스트 통과
353
+ PLAN:
354
+ 1. 컴포넌트 인벤토리 추출
355
+ 2. v2→v3 mapping 표 생성
356
+ 3. 각 컴포넌트 변환 + snapshot 갱신
357
+ 4. PR description 작성
358
+ DONE WHEN: 18/18 컴포넌트 마이그레이션 + 모든 snapshot 통과 + PR 본문 생성
359
+ VERIFY: `pnpm typecheck` exits 0 AND `pnpm test src/users` exits 0 AND `rg "ds-v2" src/users` returns 0 hits
360
+ OUTPUT: PR description in .claude/scratch/pr-users-ds-v3.md
361
+ STOP RULES: or stop after 60 turns, or if token spend exceeds 500K
362
+ ```
363
+
364
+ ## 토큰 예산
365
+
366
+ | 단계 | Claude | Gemini |
367
+ |------|--------|--------|
368
+ | 초기 분류 | ~0.5K | ~1K |
369
+ | 3축 인터뷰 (Q1~Q3) | ~1K | ~3K |
370
+ | /goal 블록 조립 | ~0.5K | ~1K |
371
+ | **총합** | **~2K** | **~5K** |
372
+
373
+ Gemini 없으면 Claude 단독 ~5K.
374
+
375
+ ## 사용 예
376
+
377
+ ```
378
+ /tfx-goal-clarify "auth 마이그레이션"
379
+ /tfx-goal-clarify "테스트 다 고쳐" --tier 1
380
+ /tfx-goal-clarify "users 모듈 design system 갈아엎기" --tier 3
381
+ /goal 변환 "스냅샷 갱신해서 CI 그린"
382
+ ```