triflux 10.57.0 → 10.57.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.
@@ -0,0 +1,178 @@
1
+ # 자연어 → 스킬 라우팅
2
+
3
+ 사용자가 스킬명을 모르더라도 자연어로 요청하면 아래 규칙에 따라 적절한 스킬을 호출한다.
4
+
5
+ 이 문서는 **무엇을 어떤 순서로 밟는가**(워크플로 단계 + 자연어 트리거)를 정한다.
6
+ **어느 시스템이 그 기능을 소유하는가**(gstack / superpowers / triflux)는
7
+ `tfx-stack-coexistence.md` 의 책임 매트릭스가 SSOT다. 두 문서가 어긋나 보이면 층위를
8
+ 먼저 구분한다. 실행 계획은 superpowers `writing-plans`, TFX 다중모델 계획·실행은
9
+ `tfx-auto --mode deep`이 맡는다. 같은 층위에서 실제로 갈릴 때만 coexistence를 따른다.
10
+
11
+ ## 기본 워크플로우 ladder
12
+
13
+ 넓은 운영 순서와 스택 간 책임 경계는 이 파일에 둔다. 개별 `SKILL.md`
14
+ description에는 해당 스킬을 고르는 데 필요한 좁은 activation phrase만 넣는다.
15
+
16
+ | 상황 | 기본 순서 | 비고 |
17
+ |------|-----------|------|
18
+ | 메타 라우팅/스킬 선택 | `/tfx-harness` | 어떤 스킬/순서가 맞는지 먼저 판정해야 할 때 쓰는 단일 진입점. |
19
+ | 신규 제품/아이디어 | `/gstack-office-hours` → `/gstack-autoplan` → `/superpowers:writing-plans` → `/tfx-auto` | 먼저 문제/범위를 잡고, 리뷰된 계획을 실행으로 넘긴다. |
20
+ | 기존 작업 계속 | `/gstack-context-restore` → `/tfx-auto` | 저장된 맥락을 복원한 뒤 실행한다. |
21
+ | 직접 구현/수정 | `/tfx-auto` (Codex 우선) | "이거 고쳐줘", "구현해줘". 기존 작업 연장이면 `/gstack-context-restore` 먼저. |
22
+ | 디버그 | Claude 기본 Explore 에이전트 → `/superpowers:systematic-debugging` → `/tfx-auto` | 코드 위치/사용처 확인과 원인 분석을 분리한다. |
23
+ | 리서치 | `/tfx-research` 또는 `/multilingual-parallel-research` | 외부/최신/도구 가능 여부 의문문은 `/tfx-research`; 언어권 병렬 조사는 multilingual 경로. |
24
+ | 검증 | `/superpowers:requesting-code-review` + `/gstack-qa` + `/superpowers:verification-before-completion` | 코드 판정은 review, 브라우저/워크플로우 게이트는 qa, 완료 주장 전 evidence 수집은 verification. |
25
+ | 디자인 검토 | `/gstack-design-review` | 시각/UX 판정. 코드 판정과 분리. |
26
+ | TDD 시작 | `/superpowers:test-driven-development` | 테스트부터 짜는 흐름. 구현 전에 invoke. |
27
+ | 코드 변경 병렬 실행 | 작업별 worktree와 세션을 분리하고 각 세션에서 `/tfx-auto` 실행 | Claude Agent는 `isolation: worktree` 사용 가능. |
28
+ | 병렬 작업 (read-only) | `/tfx-auto --parallel N --mode deep` 또는 `/superpowers:dispatching-parallel-agents` | cwd 공유가 가능한 read-only 병렬. 코드 변경은 작업별 worktree를 분리. |
29
+ | plan 실행 (체크포인트 별도) | `/superpowers:executing-plans` | 다른 세션에서 plan 을 단계별 실행. |
30
+ | 머지 직전 정리 | `/superpowers:finishing-a-development-branch` → `/tfx-ship` 또는 `/gstack-ship` | merge/PR/cleanup 결정 후 ship. |
31
+ | 리뷰 응답 | `/superpowers:receiving-code-review` | 검토 의견에 기술적 rigor 로 응답. blind apply 금지. |
32
+ | triflux 릴리즈 | `/tfx-ship` | triflux 본체는 AI trailer 금지 정책이 있는 전용 ship 경로를 쓴다. |
33
+ | 일반 PR/배포 | `/gstack-ship` | 로컬 `/gstack-ship`도 AI attribution footer/trailer 없이 실행되어야 한다. |
34
+ | 저장/복원 | `/gstack-context-save` / `/gstack-context-restore` | 진행 상태는 gstack checkpoint 계열이 소유한다. |
35
+ | 회고/슬롭 정리 | `/gstack-retro` 또는 명시 `tfx-auto --mode consensus` | 세션 회고는 gstack, 무수식 AI slop/deslop은 host ai-slop-cleaner, TFX 3자 cleanup만 `tfx-auto --mode consensus`. |
36
+
37
+ ## 스킬 선택 결정트리 (SSOT)
38
+
39
+ 우선순위는 **명시 스킬/플래그 > D0-D11 결정트리 > 위 quick reference > 개별 description**이다. 위 표는 파생 참조이며 충돌하면 이 결정트리가 이긴다.
40
+
41
+ - D0: 명시 `/skill`, `$skill`, `tfx-*`, `--mode/--cli`는 안전 위반이 없으면 유지한다.
42
+ - D1: “어떤 스킬/경로?”만 묻는 요청은 `tfx-harness`가 recommendation-only로 owner 하나를 반환한다.
43
+ - D2: 목표·non-goal·acceptance criteria가 불명확하면 host `deep-interview`; 자연어 목표의 `/goal` 변환은 Claude Code 기본 `/goal`을 쓴다.
44
+ - D3: 제품 수요·wedge 단계는 `office-hours`; D4 해법/UX 발산은 `brainstorming`.
45
+ - D5: 승인된 spec의 실행 계획은 superpowers `writing-plans`; 명시 TFX 다중모델 계획·실행은 `tfx-auto --mode deep`.
46
+ - D6: 실패/버그/test failure는 `systematic-debugging` 후 `tfx-auto`; 정상 구조 설명은 `tfx-auto --mode consensus --shape panel`.
47
+ - D7: 로컬 파일·심볼은 Claude 기본 Explore 에이전트(스킬 없음); 외부·최신·공식 문서는 `tfx-research`.
48
+ - D8: 무수식 AI slop/deslop/refactor는 host `ai-slop-cleaner`; 명시 TFX 3자 cleanup만 `tfx-auto --mode consensus`; 회고는 `retro`. “정리” 한 단어로 cleanup을 강제하지 않는다.
49
+ - D9: web/app flow는 `qa`; code verdict는 review backend; 완료 주장은 `verification-before-completion`이 필수다.
50
+ - D10: 명확한 직접 구현은 TDD 필요 여부 뒤 `tfx-auto`; 2+ code-changing lane은 worktree와 세션을 각각 분리.
51
+ - D11: fresh verification 없이 ship 금지. triflux release는 `tfx-ship`, 일반 release는 `ship`, 중단/재개는 context save/restore.
52
+
53
+ owner availability를 실제로 검출한 경우에만 `owner unavailable → tfx-X fallback`을 명시해 fallback할 수 있다. availability가 unknown이면 추측 fallback하지 않는다.
54
+
55
+ ## 행동 유형 → 스킬 매핑
56
+
57
+ | 의도 | 자연어 신호 | 스킬 |
58
+ |------|-----------|------|
59
+ | 리드 운영 | 리드 맡아, 세션들 지휘, 워커 띄워서 조율, 승계·머지·릴리스 조율 | tfx-lead |
60
+ | 구현/수정 | 만들어, 고쳐, 구현해, 짜줘, 수정해, 바꿔 | tfx-auto |
61
+ | 리뷰 | 봐줘, 리뷰해, 검토해, 괜찮아? | `superpowers:requesting-code-review` (owner). 교차모델 판정이 필요할 때만 tfx-review |
62
+ | 분석 | 분석해, 어떻게 돌아가?, 구조가 뭐야 | tfx-auto (`--mode consensus --shape panel`) |
63
+ | 계획 | 계획, 어떻게 하지, 설계해 | superpowers `writing-plans`; TFX 다중모델 계획·실행은 `tfx-auto --mode deep` |
64
+ | 요구사항 명확화 | 요구사항, 인터뷰, 목표 구체화 | host `deep-interview`; 목표 변환은 Claude Code 기본 `/goal` |
65
+ | Codex 프로필 관리 | 프로필, 모델, effort 변경 | `~/.codex/<프로필>.config.toml` 직접 관리 |
66
+ | 검색 | 찾아, 어디있어, 파일 찾아 | Claude 기본 Explore 에이전트 |
67
+ | 리서치 (빠른) | 검색해줘, 찾아봐, 공식문서, 이거 뭐야 | tfx-research |
68
+ | 리서치 (자율) | 자율 리서치, 검색하고 정리해, research and plan | tfx-research (Auto 모드) |
69
+ | 테스트 | 테스트, 검증, 돌려봐, QA | 코드 판정은 tfx-review, 테스트→수정 반복은 tfx-auto, 브라우저 흐름은 gstack `/gstack-qa` |
70
+ | 정리 | 대상 domain을 먼저 판정; 무수식 AI slop/deslop은 host ai-slop-cleaner | D8 참조 (명시 TFX 3자 cleanup만 `tfx-auto --mode consensus`) |
71
+ | 토론/비교 | 뭐가 나을까, 비교해, A vs B | tfx-auto (`--mode consensus --shape debate`) |
72
+ | 합의 | 합의로 분석해, 3자 합의, consensus | tfx-auto (`--mode consensus`) |
73
+ | 패널 | panel, 패널, 전문가 의견, expert panel | tfx-auto (`--mode consensus --shape panel`) |
74
+
75
+ 위 표는 Claude가 요청을 읽고 스킬을 고를 때 쓰는 지도다. 자동 키워드 주입은 [ADR-0023](../../docs/adr/0023-remove-command-hooks-hub-and-adopt-mods.md)에 따라 제거한다. 명시적 스킬 호출은 해당 스킬의 설치 여부와 호출 문법을 확인한다.
76
+
77
+ ## 깊이 수정자
78
+
79
+ | 수정자 | 신호 | 효과 |
80
+ |--------|------|------|
81
+ | 기본 | (없음), 빠르게, 간단히 | Light 스킬 |
82
+ | 깊이 | 제대로, 꼼꼼히, 철저히 | `tfx-auto --mode deep` |
83
+ | 합의 | 3자, 교차, 다각도 | `tfx-auto --mode consensus` |
84
+ | 반복 | 끝까지, 멈추지마, ralph | `--retry ralph` (Phase 3 true state machine, `.claude/rules/tfx-escalation-chain.md` 참조) |
85
+ | 승격 | 알아서 승격, 안 되면 더 강한 모델 | `--retry auto-escalate` (Phase 3 CLI 체인 승격) |
86
+ | 자율 | 알아서, 자동으로, autopilot | autopilot 모드 |
87
+ | 최대 effort | ultracode, 울트라코드 | Claude Code `--effort ultracode`(v2.1.203+). 멀티에이전트 Workflow 오케스트레이션, 토큰 비용 무관 철저성 우선. tfx CLI 플래그는 아니다 |
88
+
89
+ ## CLI 우선순위 정책: default = Codex
90
+
91
+ > 근거(why): [ADR-0004: 기본 구현 CLI = Codex](../../docs/adr/0004-codex-as-default-cli.md). 이 절이 SSOT(어떻게), ADR은 결정 이력(왜).
92
+
93
+ triflux 본체 개발의 실측 운영 패턴 (v10.18.0 ~ v10.20.2, 2주, 25+ PR) 이 거의 전부 Codex 단독 또는 Codex worker spawn 으로 진행되었다. 이 운영 fact 를 default policy 로 명시한다.
94
+
95
+ | 우선순위 | CLI / 모델 | default 적용 lane |
96
+ |---------|-----------|-------------------|
97
+ | 1차 (default) | **Codex** (프로필 설정 기준) | 구현, 수정, 디버그, 리뷰, 분석, 테스트 작성, 회귀 가드, 릴리즈 prepare |
98
+ | 2차 | **Antigravity** | Codex quota exhaust, 가독성 cross-check, 별도 시각 검토 |
99
+ | 한정 (Claude 만) | **Claude** (`opus` 최신 tier 별칭) | 메타 라우팅 (`/tfx-harness`), planning gate (`/gstack-office-hours`, `/gstack-autoplan`), gstack-specific surface (`/gstack-context-*`, `/gstack-qa`), 또는 Codex/Antigravity 미가용 |
100
+
101
+ `tfx-auto`, `tfx-review`, `tfx-research` 등 multi-CLI wrapper 는 이 정책을 따른다. 명시 플래그 (`--cli claude`, `--cli codex`) 가 있으면 override. `--mode consensus` (3-CLI 합의) 도 default head 는 Codex.
102
+
103
+ `--retry auto-escalate` 체인 (`.claude/rules/tfx-escalation-chain.md`) 의 1단계가 Codex 인 것도 이 정책과 정합한다 (2단계 Claude `fable` 최신 tier 별칭은 최종 수단).
104
+
105
+ ### 모델 배치
106
+
107
+ > 근거(why): [ADR-0016](../../docs/adr/0016-codex-astra-top-tier-and-fable-escalation.md), [ADR-0017](../../docs/adr/0017-gpt6-sol-luna-lanes.md), [ADR-0019](../../docs/adr/0019-gpt61-sol-and-sonnet-55.md). 기준 문서는 OpenAI Codex 모델 페이지와 Anthropic 모델 개요(2026-09-30 확인)다.
108
+
109
+ Codex 역할별 프로필의 SSOT는 `scripts/lib/agent-route-policy.mjs`이고, 모델 ID는 `~/.codex/<프로필>.config.toml`이 정한다. `designer`, `writer` 항목은 직접 Codex를 지정한 경우에만 적용하며 일반 실행의 provider는 `hub/team/agent-map.json`이 정한다.
110
+
111
+ | 프로필 | triflux lane |
112
+ |---------|--------------|
113
+ | `gpt6_astra_xhigh` | architect, planner, critic, analyst, debugger, deep-executor, security-reviewer, scientist-deep, designer(직접 지정) |
114
+ | `gpt6_astra_max` / `gpt6_astra_ultra` | `TFX_CODEX_PROFILE=max/ultra`, 재시도 1단계 |
115
+ | `gpt61_sol_high` | executor, codex, code-reviewer, quality-reviewer, verifier, test-engineer, qa-tester, scientist, document-specialist |
116
+ | `gpt61_sol_med` | cleanup, deslop |
117
+ | `gpt6_luna_high` | build-fixer, writer(직접 지정) |
118
+ | `gpt6_luna_low` | spark, explore 재매핑, ChatGPT tier 폴백 |
119
+ | `fable` | `--retry auto-escalate` 최종 단계 |
120
+ | `opus` | 메타 라우팅, planning gate, 스킬의 `model="opus"` lane |
121
+ | `sonnet` | Claude native test-engineer, qa-tester, verifier |
122
+ | `haiku` | Claude native explore, claude |
123
+
124
+ ## CLI 라우팅
125
+
126
+ `codex exec` / `agy -y -p` 직접 호출은 금지한다. tfx 스킬 경유 필수. 자동 차단 훅(headless-guard)은 제거되었으므로 호출자가 지킨다.
127
+
128
+ **Layer 1: Light** (tfx-route.sh → 단일 CLI)
129
+
130
+ | 경로 | CLI | 용도 |
131
+ |------|-----|------|
132
+ | `tfx-auto` | 자동 | 통합 진입점 |
133
+ | `tfx-auto --cli codex` | Codex | Codex 전용 lane |
134
+ | `tfx-auto --cli antigravity` | Antigravity | Antigravity 전용 lane |
135
+ | `tfx-auto --mode quick` | Codex→검증 | 단일 파일, 5분 이내 |
136
+ | `tfx-auto --retry auto-escalate` | 자동 승격 | 실패→더 강한 모델 |
137
+
138
+ **Layer 2: Deep** (headless 3-CLI 합의)
139
+
140
+ `tfx-auto --mode deep`, `tfx-auto --mode consensus --shape consensus|debate|panel`,
141
+ `tfx-auto --retry ralph`
142
+
143
+ **Layer 3: Remote/병렬**
144
+
145
+ | 스킬 | 용도 |
146
+ |------|------|
147
+ | `tfx multi` CLI | 2+개 태스크 headless 병렬 |
148
+ | tfx-remote | Claude Code 원격 세션 (SSH, user-state hosts.json setup 필수) |
149
+
150
+ **Claude 네이티브** (CLI 불필요): tfx-setup, tfx-doctor
151
+
152
+ **Headless UI default** : `tfx-auto`, `tfx multi`의 headless 워커는 default 로 `claude agents` 패널에 노출 (`--native-bridge-ui agents`). opt-out: `--no-native-bridge-ui`. interactive (tmux/wt) 경로는 default-off. 행에서 Enter 를 누르면 워커가 도는 tmux pane 이 열린다. 상세 행동표는 `CLAUDE.md` 의 `<native-bridge>` 섹션. 근거(why): [ADR-0008 : headless 워커 native-bridge 기본 노출](../../docs/adr/0008-native-bridge-ui-default-on.md), [ADR-0026 : agents 행을 워커 tmux 방의 attach client 로](../../docs/adr/0026-agents-row-tmux-attach.md).
153
+
154
+ 자원 우선순위: remote-spawn > multi > Light > 로컬 단독
155
+
156
+ **원격 오프로드 기본 정책** : 로컬 기기의 램이 빠듯해지면 무거운 작업을 `hosts.json` 에 등록한 원격 호스트로 넘긴다. 판단 시점은 병렬 dispatch 직전이다.
157
+
158
+ - 판정 명령: `sysctl -n kern.memorystatus_vm_pressure_level` (1=normal, 2=warn, 4=critical; Stats 앱·활성 상태 보기의 메모리 색과 같은 커널 신호, 노랑=2 빨강=4)
159
+ - 4(critical): 무조건 오프로드
160
+ - 2(warn): 무거운 shard(빌드·전체 테스트 스위트·대규모 리팩터)를 띄우거나 워커 3+개를 새로 만들 때만 오프로드
161
+ - 1(normal): 로컬 유지
162
+ - sysctl 실패 시 대체 신호: `vm_stat` 5초 간격 2회 측정에서 swapouts 증가
163
+ - 원격으로 넘기기 전에 `node scripts/remote-spawn.mjs --probe <host>`로 호스트 상태를 먼저 확인한다.
164
+ - 행동: 원격 코드 변경은 별도 worktree와 세션에서 실행한다. 탐색·대화형은 tfx-remote로 원격 세션을 띄운다
165
+ - 로컬 여유가 충분하면 로컬 유지. 사용자가 로컬/원격을 명시하면 그에 따른다
166
+ - 원격 호스트 부재 시(ssh 불응) 조용히 로컬로 강등하지 말고 한 줄 알린 뒤 로컬 진행
167
+
168
+ 원격 hosts 설정은 user-state 경로만 참조한다: macOS/Linux `~/.config/triflux/hosts.json`, Windows `%APPDATA%\triflux\hosts.json`. 기존 source-tree `references/hosts.json` 은 라우팅 입력으로 사용하지 않으며 첫 실행 lazy auto-migration 대상이다.
169
+
170
+ ## 충돌 해소
171
+
172
+ - ralph = persist alias
173
+ - "auto" 단독 → tfx-auto. "알아서 해" → autopilot 모드 (위 깊이 수정자)
174
+ - "코드에서 찾아" → Claude 기본 Explore 에이전트. "알아봐" → tfx-research
175
+ - 복합 의도: "구현하고 리뷰까지" → tfx-auto 실행 후 리뷰 결과 확인
176
+ - "합의해서 비교해" 류 요청은 기본적으로 `tfx-auto --mode consensus --shape debate` 로 fold 한다
177
+ - `ralph` 표기는 항상 `--retry ralph` mode 를 지칭. persist 스킬도 동일 의미. `--retry auto-escalate` 와 동시 사용 불가 (escalation-chain.md 규약).
178
+ - 코드 변경 병렬 작업은 작업별 worktree와 세션을 분리하고 각 세션에서 `tfx-auto`로 실행한다.
@@ -15,24 +15,26 @@ tree, keyword rules, or owner matrix into this file.
15
15
  ## Locate the routing SSOT
16
16
 
17
17
  This skill is installed globally but the SSOT belongs to the Triflux project.
18
- Try these locations in order and return blocked only when all three fail:
18
+ Try these locations in order and return blocked only when all four fail:
19
19
 
20
20
  1. `$TFX_ROUTING_SSOT`, when set.
21
21
  2. `.claude/rules/tfx-routing.md` below the main Git worktree, derived from
22
22
  the parent of `git rev-parse --git-common-dir`.
23
23
  3. Walk upward from the current working directory looking for
24
24
  `.claude/rules/tfx-routing.md`.
25
+ 4. `references/tfx-routing.md` inside this skill directory, copied from the
26
+ package by `tfx setup`. It serves machines without a clone and may lag the
27
+ clone, so it comes last.
25
28
 
26
29
  Do not use `git rev-parse --show-toplevel` for step 2. In a linked worktree it
27
30
  returns that worktree root, whose SSOT may be a stale branch snapshot.
28
31
  `--git-common-dir` still points to the main repository's `.git` directory.
29
32
 
30
- When blocked, include this one-line remedy:
31
-
32
- `export TFX_ROUTING_SSOT=<triflux clone>/.claude/rules/tfx-routing.md`
33
-
34
- Prefer `~/.zshenv` for this variable because hooks and headless workers may not
35
- load interactive `~/.zshrc`. Do not create a global copy or symlink under
33
+ When blocked, include this one-line remedy: rerun `tfx setup` to install the
34
+ step 4 copy. To follow a clone's latest policy instead, set
35
+ `export TFX_ROUTING_SSOT=<triflux clone>/.claude/rules/tfx-routing.md` in
36
+ `~/.zshenv`, because hooks and headless workers may not load interactive
37
+ `~/.zshrc`. Do not create a global copy or symlink under
36
38
  `~/.claude/rules/`; Claude auto-loads that directory into unrelated sessions.
37
39
 
38
40
  ## Return contract
package/bin/triflux.mjs CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  listInlineProfileNames,
75
75
  MODS_UPDATE_COMMAND,
76
76
  persistSettings,
77
+ planStatusLine,
77
78
  REQUIRED_CODEX_PROFILES,
78
79
  retireOldInstallFiles,
79
80
  runConsentSteps,
@@ -1139,11 +1140,11 @@ function previewStatusLineAction() {
1139
1140
  return {
1140
1141
  type: "statusLine",
1141
1142
  path: settingsPath,
1142
- change: currentCmd.includes("hud-qos-status.mjs")
1143
- ? "noop"
1144
- : currentCmd
1145
- ? "skip"
1146
- : "create",
1143
+ // setup 은 statusLine 을 다루기 전에 HUD 파일(래퍼 포함)을 먼저 복사한다.
1144
+ change: planStatusLine(settings, {
1145
+ hudPath,
1146
+ willSyncWrapper: existsSync(join(PKG_ROOT, "hud", "hud-statusline.sh")),
1147
+ }),
1147
1148
  current: currentCmd || null,
1148
1149
  target: hudPath,
1149
1150
  };
package/hud/constants.mjs CHANGED
@@ -17,6 +17,8 @@ export const CLAUDE_BAND_MARKER_DIR = join(
17
17
  export const BAND_FLAG = "--band";
18
18
  export const BAND_HEADER = "tfx-band 1";
19
19
  export const BAND_ALL_TTL_MS = 3 * 60 * 1000;
20
+ // 양옆 여백 4칸과 첫 행 오른쪽 알림 자리 30칸.
21
+ export const STATUSLINE_RESERVE_COLS = 34;
20
22
  export const CLAUDE_BAND_MARKER_TTL_MS = 12 * 60 * 60 * 1000;
21
23
 
22
24
  export const CONTEXT_MONITOR_CACHE_PATH = join(
@@ -24,6 +24,7 @@ import {
24
24
  CODEX_REFRESH_FLAG,
25
25
  getCodexAuthPath,
26
26
  getCodexHome,
27
+ STATUSLINE_RESERVE_COLS,
27
28
  } from "./constants.mjs";
28
29
  import { buildContextUsageView } from "./context-monitor.mjs";
29
30
  import {
@@ -69,11 +70,15 @@ async function main() {
69
70
 
70
71
  // --band: triflux mods 가 입력창 위에 그릴 줄을 만든다. statusLine 은 band 가 다 그리는 세션에서 비운다.
71
72
  const bandMode = process.argv.includes(BAND_FLAG);
73
+ // statusLine 은 엔진이 양옆에 2칸씩 여백을 두고, 첫 행 오른쪽에 알림을 띄운다(공식 문서). 그만큼 좁게 그린다.
74
+ // 알림 자리를 빼고 40칸이 안 남는 좁은 화면은 그대로 두어 원래 축소 단계를 따른다.
75
+ const reserved = Number(process.env.COLUMNS) - STATUSLINE_RESERVE_COLS;
76
+ if (!bandMode && reserved >= 40) process.env.COLUMNS = String(reserved);
72
77
  const stdin = await readStdinJson();
73
78
  const bandState = bandMode ? "none" : readBandState(stdin?.session_id);
74
79
  if (bandState === "all") return;
75
- // mods 가 넘긴 세션 사용량이 있으면 Claude API 를 따로 조회하지 않는다.
76
- const bandClaudeUsage = claudeUsageFromRateLimits(stdin?.claude_rate_limits);
80
+ // Claude Code 가 넘긴 세션 사용량이 있으면 Claude API 를 따로 조회하지 않는다.
81
+ const stdinClaudeUsage = claudeUsageFromStdin(stdin);
77
82
 
78
83
  const { showCodex, antigravityAllowed } = resolveHudCliVisibility();
79
84
  const claudeUsageSnapshot = readClaudeUsageSnapshot();
@@ -84,7 +89,7 @@ async function main() {
84
89
  if (antigravitySnapshot?.shouldRefresh) scheduleAntigravityQuotaRefresh();
85
90
  // 설정이 없는 홈에서는 갱신 프로세스를 시작하지 않는다.
86
91
  if (
87
- !bandClaudeUsage &&
92
+ !stdinClaudeUsage &&
88
93
  claudeUsageSnapshot.shouldRefresh &&
89
94
  existsSync(join(homedir(), ".claude"))
90
95
  ) {
@@ -96,7 +101,7 @@ async function main() {
96
101
 
97
102
  const contextView = buildContextUsageView(stdin);
98
103
  const claudeUsage =
99
- bandClaudeUsage ??
104
+ stdinClaudeUsage ??
100
105
  (claudeUsageSnapshot.data
101
106
  ? { ...claudeUsageSnapshot.data, stale: claudeUsageSnapshot.isStale }
102
107
  : null);
@@ -123,7 +128,12 @@ async function main() {
123
128
  const rows =
124
129
  bandState === "claude"
125
130
  ? []
126
- : getClaudeRows(currentTier, contextView, claudeUsage);
131
+ : getClaudeRows(
132
+ currentTier,
133
+ contextView,
134
+ claudeUsage,
135
+ Number(stdin?.cost?.total_cost_usd ?? Number.NaN),
136
+ );
127
137
  let codexRowIndex = -1;
128
138
  if (showCodex) {
129
139
  codexRowIndex = rows.length;
@@ -191,16 +201,30 @@ function readBandState(sessionId) {
191
201
  }
192
202
  }
193
203
 
194
- // mods 의 $.session.usage() rateLimits 를 HUD 의 Claude 사용량 형식으로 바꾼다.
195
- function claudeUsageFromRateLimits(rateLimits) {
196
- if (!Array.isArray(rateLimits)) return null;
197
- const find = (kind) => rateLimits.find((w) => w?.kind === kind);
198
- const fiveHour = find("five_hour");
199
- const weekly = find("seven_day");
200
- const spendLimit = find("spend_limit");
204
+ // statusLine 입력의 rate_limits(Claude Code 2.1.251+, Pro/Max 와 gateway)를 HUD 의 Claude 사용량 형식으로 바꾼다.
205
+ // 10.57.0 mods 가 넘기던 claude_rate_limits 배열도 받는다.
206
+ function claudeUsageFromStdin(stdin) {
207
+ const windows = {};
208
+ const limits = stdin?.rate_limits;
209
+ if (limits && typeof limits === "object" && !Array.isArray(limits)) {
210
+ for (const kind of ["five_hour", "seven_day", "spend_limit"]) {
211
+ const w = limits[kind];
212
+ if (w)
213
+ windows[kind] = { percent: w.used_percentage, resetsAt: w.resets_at };
214
+ }
215
+ } else if (Array.isArray(stdin?.claude_rate_limits)) {
216
+ for (const w of stdin.claude_rate_limits)
217
+ if (w?.kind)
218
+ windows[w.kind] = { percent: w.percentUsed, resetsAt: w.resetsAt };
219
+ }
220
+ const {
221
+ five_hour: fiveHour,
222
+ seven_day: weekly,
223
+ spend_limit: spendLimit,
224
+ } = windows;
201
225
  if (!fiveHour && !weekly && !spendLimit) return null;
202
226
  const percent = (w) =>
203
- Number.isFinite(w?.percentUsed) ? Math.round(w.percentUsed) : null;
227
+ Number.isFinite(w?.percent) ? Math.round(w.percent) : null;
204
228
  return {
205
229
  fiveHourPercent: percent(fiveHour),
206
230
  weeklyPercent: percent(weekly),
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # statusLine 진입점. triflux mods band 가 HUD 를 입력창 위에 그리는 세션이면 node 를 띄우지 않고 끝낸다.
3
+ # band 는 1분마다 표식에 "all:<시각>" 을 다시 쓰므로, 3분 넘게 멈췄거나 표식이 없으면 HUD 를 그대로 돌린다.
4
+ # 인자 1: node 실행 파일(없으면 PATH 의 node).
5
+ # Claude Code 는 입력을 닫아 주지만, 닫히지 않아도 1초 뒤에는 읽기를 끝낸다.
6
+ # 백그라운드 명령은 stdin 이 /dev/null 로 바뀌므로 fd 3 으로 넘긴다.
7
+ exec 3<&0
8
+ input=$({ cat <&3 & cat_pid=$!; (sleep 1; kill "$cat_pid" 2>/dev/null) >/dev/null 2>&1 & wait "$cat_pid"; })
9
+ exec 3<&-
10
+ sid=$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p' | head -n 1)
11
+ marker="$HOME/.claude/cache/triflux/claude-band/$sid"
12
+ if [ -n "$sid" ] && [ -f "$marker" ] && [ -n "$(find "$marker" -mmin -3 2>/dev/null)" ]; then
13
+ case "$(head -c 4 "$marker")" in all:) exit 0 ;; esac
14
+ fi
15
+ printf '%s' "$input" | "${1:-node}" "$(dirname "$0")/hud-qos-status.mjs"
@@ -26,8 +26,10 @@ import {
26
26
  SPAWN_LOCK_TTL_MS,
27
27
  } from "../constants.mjs";
28
28
  import {
29
+ acquireSpawnLock,
29
30
  advanceToNextCycle,
30
31
  clampPercent,
32
+ parseRetryAfterMs,
31
33
  readJson,
32
34
  writeJsonSafe,
33
35
  } from "../utils.mjs";
@@ -77,10 +79,27 @@ export function computeClaudeUsagePollState({
77
79
  };
78
80
  }
79
81
 
82
+ // 서버가 Retry-After 로 더 기다리라고 하면 따르되, 잘못된 큰 값에 오래 묶이지 않게 1시간에서 자른다.
83
+ const MAX_RETRY_AFTER_MS = 60 * 60 * 1000;
84
+ function withRetryAfter(pollState, retryAfterMs) {
85
+ if (!retryAfterMs) return pollState;
86
+ return {
87
+ ...pollState,
88
+ delayMs: Math.max(
89
+ pollState.delayMs,
90
+ Math.min(retryAfterMs, MAX_RETRY_AFTER_MS),
91
+ ),
92
+ };
93
+ }
94
+
80
95
  function getSnapshotSchedule(cache) {
81
96
  const timestamp = Number(cache?.timestamp);
82
97
  const nextRefreshAt = Number(cache?.nextRefreshAt);
83
- if (Number.isFinite(nextRefreshAt)) {
98
+ // 다음 갱신이 상한보다 멀면 시계가 뒤로 간 것이다. 그대로 두면 갱신이 오래 멈춘다.
99
+ if (
100
+ Number.isFinite(nextRefreshAt) &&
101
+ nextRefreshAt - Date.now() <= MAX_RETRY_AFTER_MS
102
+ ) {
84
103
  return {
85
104
  nextRefreshAt,
86
105
  shouldRefresh: Date.now() >= nextRefreshAt,
@@ -448,7 +467,11 @@ export function fetchClaudeUsageFromApi(accessToken) {
448
467
  resolve({ ok: false, status: 0 });
449
468
  }
450
469
  } else {
451
- resolve({ ok: false, status: res.statusCode });
470
+ resolve({
471
+ ok: false,
472
+ status: res.statusCode,
473
+ retryAfterMs: parseRetryAfterMs(res.headers?.["retry-after"]),
474
+ });
452
475
  }
453
476
  });
454
477
  },
@@ -674,10 +697,13 @@ export async function fetchClaudeUsage(forceRefresh = false) {
674
697
  : "unknown";
675
698
  const pollState =
676
699
  errorType === "rate_limit"
677
- ? computeClaudeUsagePollState({
678
- consecutive429s,
679
- outcome: "rate_limit",
680
- })
700
+ ? withRetryAfter(
701
+ computeClaudeUsagePollState({
702
+ consecutive429s,
703
+ outcome: "rate_limit",
704
+ }),
705
+ result.retryAfterMs,
706
+ )
681
707
  : null;
682
708
  writeClaudeUsageCache(
683
709
  existingSnapshot.data,
@@ -720,11 +746,7 @@ export function scheduleClaudeUsageRefresh() {
720
746
 
721
747
  // 스폰 락: 30초 내 이미 스폰했으면 중복 방지 (첫 설치 시 429 방지)
722
748
  try {
723
- if (existsSync(CLAUDE_REFRESH_LOCK_PATH)) {
724
- const lockAge = Date.now() - readJson(CLAUDE_REFRESH_LOCK_PATH, {}).t;
725
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
726
- }
727
- writeJsonSafe(CLAUDE_REFRESH_LOCK_PATH, { t: Date.now() });
749
+ if (!acquireSpawnLock(CLAUDE_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS)) return;
728
750
  } catch {
729
751
  /* 락 실패 무시 — 스폰 진행 */
730
752
  }
@@ -26,7 +26,12 @@ import {
26
26
  getCodexHome,
27
27
  SPAWN_LOCK_TTL_MS,
28
28
  } from "../constants.mjs";
29
- import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
29
+ import {
30
+ acquireSpawnLock,
31
+ decodeJwtEmail,
32
+ readJson,
33
+ writeJsonSafe,
34
+ } from "../utils.mjs";
30
35
  import {
31
36
  listProbeTargets,
32
37
  probeRateLimitsViaAppServer,
@@ -552,11 +557,7 @@ export function scheduleCodexRateLimitRefresh() {
552
557
 
553
558
  // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
554
559
  try {
555
- if (existsSync(CODEX_REFRESH_LOCK_PATH)) {
556
- const lockAge = Date.now() - readJson(CODEX_REFRESH_LOCK_PATH, {}).t;
557
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
558
- }
559
- writeJsonSafe(CODEX_REFRESH_LOCK_PATH, { t: Date.now() });
560
+ if (!acquireSpawnLock(CODEX_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS)) return;
560
561
  } catch {
561
562
  /* 락 실패 무시 — 스폰 진행 */
562
563
  }
@@ -10,7 +10,12 @@ import {
10
10
  ANTIGRAVITY_SIGNED_IN_GRACE_MS,
11
11
  SPAWN_LOCK_TTL_MS,
12
12
  } from "../constants.mjs";
13
- import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
13
+ import {
14
+ acquireSpawnLock,
15
+ decodeJwtEmail,
16
+ readJson,
17
+ writeJsonSafe,
18
+ } from "../utils.mjs";
14
19
 
15
20
  function getCredentialEmail(credential) {
16
21
  return (
@@ -140,9 +145,8 @@ export function scheduleAntigravityQuotaRefresh() {
140
145
  isGcpProjectAuth()
141
146
  )
142
147
  return;
143
- const lock = readJson(ANTIGRAVITY_REFRESH_LOCK_PATH, null);
144
- if (Date.now() - Number(lock?.t || 0) < SPAWN_LOCK_TTL_MS) return;
145
- writeJsonSafe(ANTIGRAVITY_REFRESH_LOCK_PATH, { t: Date.now() });
148
+ if (!acquireSpawnLock(ANTIGRAVITY_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS))
149
+ return;
146
150
  try {
147
151
  const child = spawn(
148
152
  process.execPath,
package/hud/renderers.mjs CHANGED
@@ -177,9 +177,20 @@ function spendOnlyPercent(claudeUsage) {
177
177
  // ============================================================================
178
178
  // Claude 행 렌더러
179
179
  // ============================================================================
180
- export function getClaudeRows(currentTier, contextView, claudeUsage) {
180
+ export function getClaudeRows(
181
+ currentTier,
182
+ contextView,
183
+ claudeUsage,
184
+ costUsd = null,
185
+ ) {
181
186
  const ctxView = contextView || buildContextUsageView({});
182
187
  const prefix = `${bold(claudeOrange("c"))}:`;
188
+ // 이번 세션 비용. 폭이 넉넉한 full, compact 에서만 CTX 뒤에 붙인다.
189
+ const costText =
190
+ Number.isFinite(costUsd) &&
191
+ (currentTier === "full" || currentTier === "compact")
192
+ ? ` ${dim(`$${costUsd.toFixed(2)}`)}`
193
+ : "";
183
194
  const spend = spendOnlyPercent(claudeUsage);
184
195
  if (spend != null) {
185
196
  if (currentTier === "nano" || currentTier === "micro") {
@@ -188,7 +199,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
188
199
  }
189
200
  const pct = colorByProvider(spend, formatPercentCell(spend), claudeOrange);
190
201
  const left = `${dim("$:")}${tierBar(currentTier, spend, CLAUDE_ORANGE)}${pct}`;
191
- const right = `${dim("CTX:")}${contextPercentText(ctxView)}`;
202
+ const right = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}`;
192
203
  return [{ prefix, left, right }];
193
204
  }
194
205
  // API 실측 데이터
@@ -256,7 +267,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
256
267
  const warning = ctxView.warningTag
257
268
  ? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
258
269
  : "";
259
- const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${warning}`;
270
+ const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}${warning}`;
260
271
  return [{ prefix, left: quotaSection, right: contextSection }];
261
272
  }
262
273
 
@@ -266,7 +277,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
266
277
  const warning = ctxView.warningTag
267
278
  ? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
268
279
  : "";
269
- const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${warning}`;
280
+ const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}${warning}`;
270
281
  return [{ prefix, left: quotaSection, right: contextSection }];
271
282
  }
272
283
 
package/hud/utils.mjs CHANGED
@@ -7,32 +7,40 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  import { PERCENT_CELL_WIDTH, TIME_CELL_INNER_WIDTH } from "./constants.mjs";
9
9
 
10
+ const STDIN_MAX_BYTES = 1024 * 1024;
11
+
12
+ // 입력이 JSON 으로 읽히는 순간 끝낸다. Claude Code 가 stdin 을 늦게 닫아도 기다리지 않는다.
10
13
  export async function readStdinJson() {
11
14
  if (process.stdin.isTTY) return {};
12
15
  return new Promise((resolve) => {
13
- const timeout = setTimeout(() => {
14
- process.stdin.destroy();
15
- resolve({});
16
- }, 200);
17
- const chunks = [];
18
- process.stdin.on("data", (chunk) => chunks.push(chunk));
19
- process.stdin.on("end", () => {
16
+ let raw = "";
17
+ let done = false;
18
+ const finish = (value) => {
19
+ if (done) return;
20
+ done = true;
20
21
  clearTimeout(timeout);
21
- const raw = chunks.join("").trim();
22
- if (!raw) {
23
- resolve({});
24
- return;
25
- }
22
+ process.stdin.removeAllListeners("data");
23
+ process.stdin.destroy();
24
+ resolve(value);
25
+ };
26
+ const tryParse = () => {
26
27
  try {
27
- resolve(JSON.parse(raw));
28
+ return JSON.parse(raw);
28
29
  } catch {
29
- resolve({});
30
+ return null;
30
31
  }
32
+ };
33
+ // 200ms 안에 다 못 받으면 받은 만큼으로 판정한다.
34
+ const timeout = setTimeout(() => finish(tryParse() ?? {}), 200);
35
+ process.stdin.setEncoding("utf8");
36
+ process.stdin.on("data", (chunk) => {
37
+ raw += chunk;
38
+ if (raw.length > STDIN_MAX_BYTES) return finish({});
39
+ const parsed = tryParse();
40
+ if (parsed) finish(parsed);
31
41
  });
32
- process.stdin.on("error", () => {
33
- clearTimeout(timeout);
34
- resolve({});
35
- });
42
+ process.stdin.on("end", () => finish(tryParse() ?? {}));
43
+ process.stdin.on("error", () => finish({}));
36
44
  process.stdin.resume();
37
45
  });
38
46
  }
@@ -56,6 +64,28 @@ export function writeJsonSafe(filePath, data) {
56
64
  }
57
65
 
58
66
  // .omc/ → .claude/cache/ 마이그레이션: 새 경로 우선, 없으면 레거시 읽고 복사
67
+ // 갱신 프로세스를 겹쳐 띄우지 않는 락. 만료 시각을 적고, 남은 시간이 ttl 보다 길면(시계가 뒤로 감) 깨진 락으로 본다.
68
+ // 예전 { t } 락도 읽는다.
69
+ export function acquireSpawnLock(lockPath, ttlMs, now = Date.now()) {
70
+ const lock = readJson(lockPath, null);
71
+ const until = Number(lock?.until ?? Number(lock?.t) + ttlMs);
72
+ const remaining = until - now;
73
+ if (Number.isFinite(remaining) && remaining > 0 && remaining <= ttlMs)
74
+ return false;
75
+ writeJsonSafe(lockPath, { until: now + ttlMs });
76
+ return true;
77
+ }
78
+
79
+ // Retry-After 헤더(초 또는 HTTP 날짜)를 ms 로 바꾼다. 없거나 지난 값이면 null.
80
+ export function parseRetryAfterMs(value, now = Date.now()) {
81
+ if (value == null || value === "") return null;
82
+ const seconds = Number(value);
83
+ const ms = Number.isFinite(seconds)
84
+ ? seconds * 1000
85
+ : Date.parse(value) - now;
86
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
87
+ }
88
+
59
89
  export function readJsonMigrate(newPath, legacyPath, fallback) {
60
90
  const data = readJson(newPath, null);
61
91
  if (data != null) return data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.57.0",
3
+ "version": "10.57.2",
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": {
@@ -23,6 +23,7 @@
23
23
  "adapters",
24
24
  "bin",
25
25
  "config",
26
+ ".claude/rules/tfx-routing.md",
26
27
  "skills",
27
28
  "!skills/tfx-workspace",
28
29
  "hooks",
@@ -33,6 +33,8 @@ const MIRROR_TOPS = [
33
33
  "scripts",
34
34
  "skills",
35
35
  ];
36
+ // 디렉터리 전체가 아니라 한 파일만 게시하는 경로. tfx-harness 가 clone 없는 기기에서 읽는다.
37
+ const MIRROR_FILES = [".claude/rules/tfx-routing.md"];
36
38
  // core 미러 대상은 pack.mjs 의 CORE_ENTRIES 하나로 정한다. 검사기가 따로 손 목록을 두면
37
39
  // 하위 디렉터리나 새 파일이 빠져도 OK 가 나왔다(#674).
38
40
  const CORE_NON_MIRROR = new Set([
@@ -189,48 +191,55 @@ function compareMirror({
189
191
  const issues = [];
190
192
  const fixed = [];
191
193
 
194
+ const pairs = [];
192
195
  for (const top of MIRROR_TOPS) {
193
- const srcDir = join(repoRoot, top);
194
- const dstDir = join(mirrorRoot, top);
195
196
  const skipRels = SKIP_RELS.get(top) ?? new Set();
196
- const srcFiles = new Set(walkRelFiles(srcDir, skipRels));
197
- const dstFiles = new Set(walkRelFiles(dstDir, skipRels));
198
- const allFiles = new Set([...srcFiles, ...dstFiles]);
197
+ const srcFiles = new Set(walkRelFiles(join(repoRoot, top), skipRels));
198
+ const dstFiles = new Set(walkRelFiles(join(mirrorRoot, top), skipRels));
199
+ for (const rel of new Set([...srcFiles, ...dstFiles]))
200
+ pairs.push({
201
+ rel: `${top}/${rel}`,
202
+ inSrc: srcFiles.has(rel),
203
+ inDst: dstFiles.has(rel),
204
+ });
205
+ }
206
+ for (const rel of MIRROR_FILES) {
207
+ const inSrc = existsSync(join(repoRoot, rel));
208
+ const inDst = existsSync(join(mirrorRoot, rel));
209
+ if (inSrc || inDst) pairs.push({ rel, inSrc, inDst });
210
+ }
199
211
 
200
- for (const rel of allFiles) {
201
- const srcPath = join(srcDir, rel);
202
- const dstPath = join(dstDir, rel);
203
- const inSrc = srcFiles.has(rel);
204
- const inDst = dstFiles.has(rel);
205
- const displayPath = `packages/triflux/${top}/${rel}`;
212
+ for (const { rel, inSrc, inDst } of pairs) {
213
+ const srcPath = join(repoRoot, rel);
214
+ const dstPath = join(mirrorRoot, rel);
215
+ const displayPath = `packages/triflux/${rel}`;
206
216
 
207
- if (inSrc && !inDst) {
208
- if (fix) {
209
- mkdirSync(dirname(dstPath), { recursive: true });
210
- copyFileSync(srcPath, dstPath);
211
- fixed.push({ path: displayPath, kind: "added" });
212
- } else {
213
- issues.push({ path: displayPath, kind: "missing-in-mirror" });
214
- }
215
- continue;
217
+ if (inSrc && !inDst) {
218
+ if (fix) {
219
+ mkdirSync(dirname(dstPath), { recursive: true });
220
+ copyFileSync(srcPath, dstPath);
221
+ fixed.push({ path: displayPath, kind: "added" });
222
+ } else {
223
+ issues.push({ path: displayPath, kind: "missing-in-mirror" });
216
224
  }
225
+ continue;
226
+ }
217
227
 
218
- if (!inSrc && inDst) {
219
- // Orphan in mirror — source of truth is root, mirror must not have
220
- // extra files. Do not auto-delete; require manual decision.
221
- issues.push({ path: displayPath, kind: "orphan-in-mirror" });
222
- continue;
223
- }
228
+ if (!inSrc && inDst) {
229
+ // Orphan in mirror — source of truth is root, mirror must not have
230
+ // extra files. Do not auto-delete; require manual decision.
231
+ issues.push({ path: displayPath, kind: "orphan-in-mirror" });
232
+ continue;
233
+ }
224
234
 
225
- const a = readFileSync(srcPath);
226
- const b = readFileSync(dstPath);
227
- if (!a.equals(b)) {
228
- if (fix) {
229
- copyFileSync(srcPath, dstPath);
230
- fixed.push({ path: displayPath, kind: "updated" });
231
- } else {
232
- issues.push({ path: displayPath, kind: "content-diff" });
233
- }
235
+ const a = readFileSync(srcPath);
236
+ const b = readFileSync(dstPath);
237
+ if (!a.equals(b)) {
238
+ if (fix) {
239
+ copyFileSync(srcPath, dstPath);
240
+ fixed.push({ path: displayPath, kind: "updated" });
241
+ } else {
242
+ issues.push({ path: displayPath, kind: "content-diff" });
234
243
  }
235
244
  }
236
245
  }
package/scripts/setup.mjs CHANGED
@@ -676,7 +676,7 @@ function installEntries(pluginRoot) {
676
676
  "scripts/codex-profile-sanitize.mjs",
677
677
  // tfx-route.sh 가 $sd/lib 에서 찾는다.
678
678
  ...listFiles(pluginRoot, "scripts/lib", [".mjs", ".sh"]),
679
- ...listFiles(pluginRoot, "hud", [".mjs"]),
679
+ ...listFiles(pluginRoot, "hud", [".mjs", ".sh"]),
680
680
  ];
681
681
  }
682
682
 
@@ -1241,6 +1241,19 @@ function syncCodexManagedSkills({
1241
1241
  }));
1242
1242
  }
1243
1243
 
1244
+ // tfx-harness 는 triflux clone 밖에서도 호출된다. clone 이 없는 기기에서도 라우팅 문서를
1245
+ // 읽도록 패키지에 든 사본을 설치된 스킬 옆에 둔다. ~/.claude/rules 는 모든 세션이 자동
1246
+ // 로드하므로 쓰지 않는다.
1247
+ function installRoutingSsot(pluginRoot, skillDir) {
1248
+ const src = join(pluginRoot, ".claude", "rules", "tfx-routing.md");
1249
+ const dst = join(skillDir, "references", "tfx-routing.md");
1250
+ if (!existsSync(src) || !existsSync(join(skillDir, "SKILL.md"))) return false;
1251
+ if (!shouldSyncTextFile(src, dst)) return false;
1252
+ mkdirSync(dirname(dst), { recursive: true });
1253
+ copyFileSync(src, dst);
1254
+ return true;
1255
+ }
1256
+
1244
1257
  export function syncSkills({
1245
1258
  pluginRoot = PLUGIN_ROOT,
1246
1259
  claudeDir = CLAUDE_DIR,
@@ -1293,6 +1306,12 @@ export function syncSkills({
1293
1306
  } else if (skill.action === "skipped")
1294
1307
  result.warnings.push(`Codex ${skill.name}: 사용자 스킬 보존`);
1295
1308
  }
1309
+ const harnessDirs = [join(destination, "tfx-harness")];
1310
+ const codexHarness = join(codexDir, "skills", "tfx-harness");
1311
+ if (existsSync(join(codexHarness, MANAGED_CODEX_SKILL_MARKER)))
1312
+ harnessDirs.push(codexHarness);
1313
+ for (const dir of harnessDirs)
1314
+ if (installRoutingSsot(pluginRoot, dir)) result.changed++;
1296
1315
  return result;
1297
1316
  }
1298
1317
 
@@ -1689,35 +1708,71 @@ export function persistSettings(settings, settingsPath = SETTINGS_PATH) {
1689
1708
  }
1690
1709
  }
1691
1710
 
1711
+ const HUD_STATUSLINE_SCRIPT = "hud-statusline.sh";
1712
+
1713
+ function commandTokens(command) {
1714
+ return [
1715
+ ...String(command || "").matchAll(/"([^"\n]+)"|'([^'\n]+)'|(\S+)/gu),
1716
+ ].map((m) => m[1] ?? m[2] ?? m[3]);
1717
+ }
1718
+
1719
+ // macOS/Linux 는 sh 래퍼로 돌려 mods band 가 그리는 세션에서 node 를 띄우지 않는다(hud/hud-statusline.sh).
1720
+ // willSyncWrapper: 미리보기처럼 래퍼를 아직 복사하기 전이지만 setup 이 곧 복사할 때.
1721
+ function buildStatusLineCommand(hudPath, { willSyncWrapper = false } = {}) {
1722
+ const wrapper = join(dirname(hudPath), HUD_STATUSLINE_SCRIPT);
1723
+ if (process.platform === "win32" || !(willSyncWrapper || existsSync(wrapper)))
1724
+ return buildNodeScriptCommand(hudPath);
1725
+ const node = resolveStableNodeBin(process.execPath, { fallback: "node" });
1726
+ return `sh ${quoteShellCommandArg(wrapper)} ${quoteShellCommandArg(node)}`;
1727
+ }
1728
+
1729
+ // triflux 가 등록한 statusLine 인지: node <hud> 또는 sh <래퍼> <node>.
1730
+ function isTrifluxStatusLine(command, hudPath) {
1731
+ const isNode = (bin) => /(?:^|[/\\])node(?:\.exe)?$/u.test(bin ?? "");
1732
+ const tokens = commandTokens(command);
1733
+ const hud = hudPath.replace(/\\/g, "/");
1734
+ const wrapper = join(dirname(hudPath), HUD_STATUSLINE_SCRIPT).replace(
1735
+ /\\/g,
1736
+ "/",
1737
+ );
1738
+ if (tokens.length === 2) return isNode(tokens[0]) && tokens[1] === hud;
1739
+ if (tokens.length === 3)
1740
+ return tokens[0] === "sh" && tokens[1] === wrapper && isNode(tokens[2]);
1741
+ return false;
1742
+ }
1743
+
1744
+ // 미리보기와 실제 적용이 같은 판정을 쓴다. 사용자가 직접 넣은 statusLine 은 skip 이다.
1745
+ export function planStatusLine(
1746
+ settings,
1747
+ { hudPath = HUD_PATH, willSyncWrapper = false } = {},
1748
+ ) {
1749
+ const current = settings.statusLine;
1750
+ if (current == null) return "create";
1751
+ if (current.command === buildStatusLineCommand(hudPath, { willSyncWrapper }))
1752
+ return "noop";
1753
+ return current.type === "command" &&
1754
+ isTrifluxStatusLine(current.command, hudPath)
1755
+ ? "update"
1756
+ : "skip";
1757
+ }
1758
+
1692
1759
  export function applyStatusLine(
1693
1760
  settings,
1694
1761
  { hudPath = HUD_PATH, warn = console.warn } = {},
1695
1762
  ) {
1696
1763
  if (!existsSync(hudPath)) return false;
1697
- const current = settings.statusLine;
1698
- const desiredCommand = buildNodeScriptCommand(hudPath);
1699
- if (current?.command === desiredCommand) return false;
1700
- if (current != null) {
1701
- const tokens = String(current.command || "").match(
1702
- /^(?:"([^"\n]+)"|'([^'\n]+)'|(\S+))\s+(?:"([^"\n]+)"|'([^'\n]+)'|(\S+))$/u,
1764
+ const change = planStatusLine(settings, { hudPath });
1765
+ if (change === "noop") return false;
1766
+ if (change === "skip") {
1767
+ warn(
1768
+ "기존 statusLine 유지: Triflux HUD를 쓰려면 settings.json에서 직접 선택하세요.",
1703
1769
  );
1704
- const node = tokens?.[1] ?? tokens?.[2] ?? tokens?.[3] ?? "";
1705
- const script = tokens?.[4] ?? tokens?.[5] ?? tokens?.[6];
1706
- if (
1707
- current.type !== "command" ||
1708
- !/(?:^|[/\\])node(?:\.exe)?$/u.test(node) ||
1709
- script !== hudPath.replace(/\\/g, "/")
1710
- ) {
1711
- warn(
1712
- "기존 statusLine 유지: Triflux HUD를 쓰려면 settings.json에서 직접 선택하세요.",
1713
- );
1714
- return false;
1715
- }
1770
+ return false;
1716
1771
  }
1717
1772
  settings.statusLine = {
1718
- ...current,
1773
+ ...settings.statusLine,
1719
1774
  type: "command",
1720
- command: desiredCommand,
1775
+ command: buildStatusLineCommand(hudPath),
1721
1776
  };
1722
1777
  return true;
1723
1778
  }
@@ -21,19 +21,22 @@ host capability evidence를 판정한다. 정책 표, keyword 표, owner 매트
21
21
  2. git 메인 워크트리 — `$(git rev-parse --git-common-dir)`의 부모 아래
22
22
  `.claude/rules/tfx-routing.md`
23
23
  3. cwd에서 위로 올라가며 `.claude/rules/tfx-routing.md`
24
+ 4. 이 스킬 디렉터리의 `references/tfx-routing.md` (`tfx setup`이 패키지에서 복사한 사본)
25
+
26
+ 4순위는 clone이 없는 기기를 위한 것이다. 설치한 버전의 정책이라 clone보다 늦을 수 있으므로
27
+ 1~3순위 뒤에 둔다.
24
28
 
25
29
  2순위에 `--show-toplevel`을 쓰지 않는다. linked worktree 안에서는 worktree 루트를
26
30
  반환하는데 그곳의 SSOT는 브랜치 분기 시점 사본이라, blocked 없이 stale 정책으로
27
31
  조용히 오판정한다. `--git-common-dir`은 worktree 안에서도 메인 repo의 `.git`을 가리킨다.
28
32
 
29
- blocked를 반환할 때는 해소 방법을 한 줄 덧붙인다:
30
- `export TFX_ROUTING_SSOT=<triflux clone>/.claude/rules/tfx-routing.md`
31
- (`~/.zshrc`가 아니라 `~/.zshenv`에 둔다. zshrc는 interactive 전용이라 훅·headless
32
- 워커·툴 셸에서는 1순위가 통째로 죽는다.)
33
+ blocked를 반환할 때는 해소 방법을 한 줄 덧붙인다: `tfx setup`을 다시 실행해 4순위 사본을 설치한다.
34
+ clone의 최신 정책을 쓰려면 `export TFX_ROUTING_SSOT=<triflux clone>/.claude/rules/tfx-routing.md`
35
+ 를 `~/.zshenv`에 둔다. `~/.zshrc`는 interactive 전용이라 훅·headless 워커·툴 셸에서는 읽히지 않는다.
33
36
 
34
37
  `~/.claude/rules/`에 사본이나 symlink를 두지 않는다. 그 디렉터리는 Claude가 모든 세션에
35
38
  자동 로드하므로 tfx와 무관한 세션까지 SSOT 전문을 매번 컨텍스트에 싣게 된다.
36
- npm 패키지에도 `.claude/`는 없다(`tfx-mirror-policy.md` mirror 제외 대상).
39
+ npm 패키지에는 `.claude/` 중 `rules/tfx-routing.md` 한 파일만 들어 있다.
37
40
 
38
41
  반환 형식:
39
42