triflux 10.9.18 → 10.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CLAUDE.md +212 -0
  2. package/hooks/hook-orchestrator.mjs +155 -24
  3. package/hub/lib/bash-path.mjs +73 -0
  4. package/hub/team/dashboard-open.mjs +1 -68
  5. package/hub/team/native-supervisor.mjs +9 -2
  6. package/hub/team/process-cleanup.mjs +27 -5
  7. package/hub/team/psmux.mjs +5 -13
  8. package/hub/team/session.mjs +6 -26
  9. package/hub/team/swarm-hypervisor.mjs +413 -102
  10. package/hub/team/synapse-http.mjs +1 -0
  11. package/hub/team/tui-core.mjs +292 -0
  12. package/hub/team/tui-lite.mjs +20 -154
  13. package/hub/team/tui-synapse.mjs +213 -0
  14. package/hub/team/tui-viewer.mjs +3 -0
  15. package/hub/team/tui-widgets.mjs +262 -0
  16. package/hub/team/tui.mjs +159 -255
  17. package/hub/workers/delegator-mcp.mjs +2 -2
  18. package/package.json +21 -62
  19. package/references/hosts.json +46 -0
  20. package/scripts/cross-review-gate.mjs +13 -0
  21. package/scripts/remote-spawn.mjs +11 -46
  22. package/scripts/session-spawn-helper.mjs +8 -21
  23. package/scripts/test-tfx-route-no-claude-native.mjs +4 -2
  24. package/scripts/tfx-route.sh +13 -0
  25. package/skills/tfx-workspace/async-tests/run-tests.sh +203 -0
  26. package/skills/tfx-workspace/evals/evals.json +79 -0
  27. package/skills/tfx-workspace/iteration-1/benchmark.json +524 -0
  28. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/eval_metadata.json +11 -0
  29. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/grading.json +25 -0
  30. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/outputs/analysis.md +154 -0
  31. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/timing.json +5 -0
  32. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/grading.json +25 -0
  33. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/outputs/analysis.md +126 -0
  34. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/timing.json +5 -0
  35. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/eval_metadata.json +11 -0
  36. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/grading.json +25 -0
  37. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/outputs/analysis.md +119 -0
  38. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/timing.json +5 -0
  39. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/grading.json +25 -0
  40. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/outputs/analysis.md +115 -0
  41. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/timing.json +5 -0
  42. package/skills/tfx-workspace/iteration-1/hub-start-sequence/eval_metadata.json +10 -0
  43. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/grading.json +20 -0
  44. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/outputs/analysis.md +86 -0
  45. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/timing.json +5 -0
  46. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/grading.json +20 -0
  47. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/outputs/analysis.md +81 -0
  48. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/timing.json +5 -0
  49. package/skills/tfx-workspace/iteration-1/multi-team-creation/eval_metadata.json +12 -0
  50. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/grading.json +30 -0
  51. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/outputs/analysis.md +316 -0
  52. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/timing.json +5 -0
  53. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/grading.json +30 -0
  54. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/outputs/analysis.md +352 -0
  55. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/timing.json +5 -0
  56. package/skills/tfx-workspace/iteration-1/review.html +1325 -0
  57. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/eval_metadata.json +12 -0
  58. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/grading.json +30 -0
  59. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/outputs/analysis.md +97 -0
  60. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/timing.json +5 -0
  61. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/grading.json +30 -0
  62. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/outputs/analysis.md +94 -0
  63. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/timing.json +5 -0
  64. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/eval_metadata.json +12 -0
  65. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/grading.json +30 -0
  66. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/outputs/analysis.md +209 -0
  67. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/timing.json +5 -0
  68. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/grading.json +30 -0
  69. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/outputs/analysis.md +193 -0
  70. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/timing.json +5 -0
  71. package/skills/tfx-workspace/iteration-2/benchmark.json +144 -0
  72. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/eval_metadata.json +13 -0
  73. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/grading.json +35 -0
  74. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/outputs/analysis.md +382 -0
  75. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/timing.json +5 -0
  76. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/grading.json +35 -0
  77. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/outputs/analysis.md +333 -0
  78. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/timing.json +5 -0
  79. package/skills/tfx-workspace/iteration-2/review.html +1325 -0
  80. package/skills/tfx-workspace/skill-snapshot/tfx-auto/SKILL.md +217 -0
  81. package/skills/tfx-workspace/skill-snapshot/tfx-auto-codex/SKILL.md +77 -0
  82. package/skills/tfx-workspace/skill-snapshot/tfx-codex/SKILL.md +65 -0
  83. package/skills/tfx-workspace/skill-snapshot/tfx-doctor/SKILL.md +94 -0
  84. package/skills/tfx-workspace/skill-snapshot/tfx-gemini/SKILL.md +82 -0
  85. package/skills/tfx-workspace/skill-snapshot/tfx-hub/SKILL.md +133 -0
  86. package/skills/tfx-workspace/skill-snapshot/tfx-multi/SKILL.md +426 -0
  87. package/skills/tfx-workspace/skill-snapshot/tfx-setup/SKILL.md +101 -0
  88. package/.claude-plugin/marketplace.json +0 -34
  89. package/.claude-plugin/plugin.json +0 -22
  90. package/config/mcp-registry.json +0 -29
  91. package/scripts/__tests__/release-governance.test.mjs +0 -148
  92. package/scripts/release/bump-version.mjs +0 -77
  93. package/scripts/release/check-sync.mjs +0 -51
  94. package/scripts/release/lib.mjs +0 -303
  95. package/scripts/release/prepare.mjs +0 -85
  96. package/scripts/release/publish.mjs +0 -87
  97. package/scripts/release/verify.mjs +0 -81
  98. package/scripts/release/version-manifest.json +0 -26
  99. package/tui/codex-profile.mjs +0 -457
  100. package/tui/core.mjs +0 -266
  101. package/tui/doctor.mjs +0 -375
  102. package/tui/gemini-profile.mjs +0 -299
  103. package/tui/monitor-data.mjs +0 -152
  104. package/tui/monitor.mjs +0 -339
  105. package/tui/setup.mjs +0 -598
package/CLAUDE.md ADDED
@@ -0,0 +1,212 @@
1
+ <!-- prompt-hygiene:ignore line_count_warning -->
2
+ # triflux — Claude Code 운영 가이드
3
+
4
+ <core-systems>
5
+ ## 핵심 스킬 시스템 (항상 인지)
6
+
7
+ 이 프로젝트는 3개의 스킬 시스템을 동시에 사용한다. 어떤 작업이든 해당 시스템의 스킬이 있는지 먼저 확인한다.
8
+
9
+ | 시스템 | 접두사 | 용도 | 스킬 수 |
10
+ |--------|--------|------|---------|
11
+ | **triflux** | `/tfx-*` | CLI 라우팅, 멀티모델 오케스트레이션, 스웜, 원격 실행 | ~40개 |
12
+ | **gstack** | `/` (접두사 없음) | QA, ship, investigate, design, review, checkpoint | ~35개 |
13
+ | **omc** | `/oh-my-claudecode:*` | autopilot, ralph, team, ultrawork, ccg | ~25개 |
14
+
15
+ 스킬을 모르면 자연어 라우팅(아래)으로 자동 매핑된다.
16
+ 세션 종료 전 메모리 파일이 3개+ 변경됐으면 `/memory-hygiene` 제안을 검토한다.
17
+ </core-systems>
18
+
19
+ <psmux-wt>
20
+ ## psmux/WT 규칙
21
+
22
+ psmux 세션·WT 패인을 생성/조작/정리할 때 `tfx-psmux-rules` 스킬을 참조한다.
23
+ WT 프리징 방지: exit → sleep 2 → kill 순서. 바로 kill하지 않는다.
24
+
25
+ ### wt.exe → wt-manager 경유
26
+
27
+ safety-guard가 `wt.exe`, `wt new-tab`, `wt split-pane`, `Start-Process wt`를 차단한다.
28
+ `hub/team/wt-manager.mjs`의 API를 사용한다.
29
+
30
+ | 용도 | API |
31
+ |------|-----|
32
+ | 새 탭 | `createTab({ title, command, profile, cwd })` |
33
+ | 패인 분할 | `splitPane({ direction: 'H'\|'V', title, command })` |
34
+ | 다중 배치 | `applySplitLayout([{ title, command, direction }])` |
35
+ | 탭 정리 | `closeTab(title)` / `closeStale({ olderThanMs, titlePattern })` |
36
+
37
+ 차단과 대안은 항상 쌍으로 존재해야 한다. 차단만 추가하고 대안을 안 만들면 데드락.
38
+
39
+ ### psmux에서 Codex 실행
40
+
41
+ | 방식 | 동작 | 이유 |
42
+ |------|------|------|
43
+ | `codex` (interactive) | 불가 | psmux에서 TTY를 못 잡음 |
44
+ | `codex < prompt.md` | 불가 | "stdin is not a terminal" |
45
+ | `codex exec "$(cat prompt.md)" -s danger-full-access --dangerously-bypass-approvals-and-sandbox` | 사용 | 유일한 안전 경로 |
46
+
47
+ `codex exec`는 config.toml `approval_mode`를 무시하므로 `--dangerously-bypass-approvals-and-sandbox` 필수.
48
+ `-s` 유효값: read-only, workspace-write, danger-full-access.
49
+ </psmux-wt>
50
+
51
+ <codex-config>
52
+ ## Codex config.toml
53
+
54
+ config.toml에 이미 설정된 값은 CLI 플래그로 중복 지정하지 않는다.
55
+
56
+ | config.toml에 있으면 | CLI에서 생략 |
57
+ |---------------------|-------------|
58
+ | `approval_mode = "auto"` | `-a`, `--full-auto` |
59
+ | `sandbox = "workspace-write"` | `-s`, `--full-auto` |
60
+
61
+ 안전 패턴: config.toml에 기본값을 두고, CLI에서는 `--profile` 선택만 한다.
62
+ </codex-config>
63
+
64
+ <account-broker>
65
+ ## AccountBroker (계정 브로커)
66
+
67
+ conductor, headless, swarm-hypervisor가 하나의 AccountBroker 싱글턴을 공유한다.
68
+
69
+ | 항목 | 설명 |
70
+ |------|------|
71
+ | 계정별 CircuitBreaker | 장애 격리 — 한 계정 오류가 다른 계정에 전파되지 않음 |
72
+ | busy 플래그 | 동일 계정 이중 임대(double-lease) 방지 |
73
+ | `/broker/reload` | 장시간 세션 중 accounts.json 핫리로드 |
74
+ | EventEmitter 이벤트 | `lease`, `release`, `cooldown`, `tierFallback`, `circuitOpen`, `circuitClose`, `noAvailableAccounts` — HUD 연동용 |
75
+ </account-broker>
76
+
77
+ <remote>
78
+ ## 원격 실행
79
+
80
+ ### 스킬 구분
81
+
82
+ | 스킬 | 대상 | 방식 |
83
+ |------|------|------|
84
+ | tfx-codex-swarm | 로컬 전용 | 로컬 worktree + psmux |
85
+ | tfx-remote-spawn | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
86
+
87
+ codex를 SSH 너머로 직접 실행하지 않는다. config.toml 충돌 + TTY 문제.
88
+ 원격에서 codex가 필요하면: remote-spawn → Claude Code → Claude가 내부에서 codex 호출.
89
+
90
+ ### SSH 패턴
91
+
92
+ hosts.json `os` 필드로 대상 셸을 판단한다. safety-guard도 이 필드를 참조.
93
+
94
+ | 대상 OS | 셸 | 패턴 |
95
+ |---------|-----|------|
96
+ | windows | PowerShell | scp + `pwsh -File` 필수. `$var` → `$env:VAR`, `2>/dev/null` → `2>$null` |
97
+ | darwin | zsh | 인라인 가능. brew PATH 주의 (`/opt/homebrew/bin`) |
98
+ | linux | bash | 인라인 가능. 표준 POSIX |
99
+
100
+ - `~` → `$HOME` 변환은 모든 OS 공통
101
+ </remote>
102
+
103
+ <headless-retrieval>
104
+ ## Headless 결과 회수
105
+
106
+ background로 실행한 headless 결과는 **반드시 task-notification 완료 후** 읽는다.
107
+
108
+ | 패턴 | 올바름 | 이유 |
109
+ |------|--------|------|
110
+ | task-notification 후 output 파일 읽기 | YES | 프로세스 종료 = 워커 전부 완료 |
111
+ | task-notification 전 output 파일 tail | NO | 시작 메시지만 보이고 "실패"로 오진 |
112
+ | psmux capture-pane으로 중간 체크 | NO | 워커 진행 중이면 빈 화면일 수 있음 |
113
+
114
+ 완료 마커: `=== HEADLESS_COMPLETE succeeded=N failed=N total=N ===`
115
+ 워커 상세: `$TMPDIR/tfx-headless/{sessionName}-worker-N.txt`
116
+ </headless-retrieval>
117
+
118
+ <cross-review>
119
+ ## 교차 검증
120
+
121
+ - Claude 작성 코드 → Codex 리뷰
122
+ - Codex 작성 코드 → Claude 리뷰
123
+ - 동일 모델 self-approve 하지 않는다
124
+ - git commit 전 미검증 파일 감지 시 nudge
125
+ </cross-review>
126
+
127
+ <session-context>
128
+ ## 맥락 이탈 판단
129
+
130
+ 현재 세션 맥락과 무관한 요청이 감지되면 psmux 격리를 제안한다.
131
+
132
+ | 확신도 | 신호 | 행동 |
133
+ |--------|------|------|
134
+ | 확실 | "새 탭", "별도로", "새 세션" | 바로 psmux spawn |
135
+ | 높음 | 다른 프로젝트/스택 언급 | 분리 제안 |
136
+ | 중간 | 작업 유형 전환 | 분리 제안 + 현재 세션 옵션 |
137
+ | 낮음 | 현재 작업 연장 | 세션 유지 |
138
+ </session-context>
139
+
140
+ <routing>
141
+ ## 자연어 → 스킬 라우팅
142
+
143
+ 사용자가 스킬명을 모르더라도 자연어로 요청하면 아래 규칙에 따라 적절한 스킬을 호출한다.
144
+
145
+ ### 행동 유형 → 스킬 매핑
146
+
147
+ | 의도 | 자연어 신호 | 스킬 |
148
+ |------|-----------|------|
149
+ | 구현/수정 | 만들어, 고쳐, 구현해, 짜줘, 수정해, 바꿔 | tfx-auto |
150
+ | 리뷰 | 봐줘, 리뷰해, 검토해, 괜찮아? | tfx-review |
151
+ | 분석 | 분석해, 어떻게 돌아가?, 구조가 뭐야 | tfx-analysis |
152
+ | 계획 | 계획, 어떻게 하지, 설계해 | tfx-plan |
153
+ | 검색 | 찾아, 어디있어, 파일 찾아 | tfx-find |
154
+ | 리서치 (빠른) | 검색해줘, 찾아봐, 공식문서, 이거 뭐야 | tfx-research |
155
+ | 리서치 (자율) | 자율 리서치, 검색하고 정리해, research and plan | tfx-autoresearch |
156
+ | 테스트 | 테스트, 검증, 돌려봐, QA | tfx-qa |
157
+ | 정리 | 정리해, 슬롭 제거, 클린업 | tfx-prune |
158
+ | 토론 | 뭐가 나을까, 비교해, A vs B | tfx-debate |
159
+
160
+ ### 깊이 수정자
161
+
162
+ | 수정자 | 신호 | 효과 |
163
+ |--------|------|------|
164
+ | 기본 | (없음), 빠르게, 간단히 | Light 스킬 |
165
+ | 깊이 | 제대로, 꼼꼼히, 철저히 | Deep 스킬 (tfx-deep-*). 예외: tfx-deep-interview는 Gemini 단독 |
166
+ | 합의 | 3자, 교차, 다각도 | consensus 프로토콜 |
167
+ | 반복 | 끝까지, 멈추지마, ralph | persist 모드 |
168
+ | 자율 | 알아서, 자동으로, autopilot | autopilot 모드 |
169
+
170
+ ### CLI 라우팅
171
+
172
+ headless-guard가 `codex exec` / `gemini -y -p` 직접 호출을 차단한다. tfx 스킬 경유 필수.
173
+
174
+ **Layer 1 — Light** (tfx-route.sh → 단일 CLI)
175
+
176
+ | 스킬 | CLI | 용도 |
177
+ |------|-----|------|
178
+ | tfx-auto | 자동 | 통합 진입점 |
179
+ | tfx-codex | Codex | Codex 전용 |
180
+ | tfx-gemini | Gemini | Gemini 전용 |
181
+ | tfx-autopilot | Codex→검증 | 단일 파일, 5분 이내 |
182
+ | tfx-autoroute | 자동 승격 | 실패→더 강한 모델 |
183
+
184
+ **Layer 2 — Deep** (headless 3-CLI 합의)
185
+
186
+ tfx-deep-review, tfx-deep-qa, tfx-deep-plan, tfx-deep-research, tfx-consensus, tfx-debate, tfx-panel, tfx-fullcycle, tfx-persist
187
+
188
+ **Layer 3 — Remote/병렬**
189
+
190
+ | 스킬 | 용도 |
191
+ |------|------|
192
+ | tfx-multi | 2+개 태스크 headless 병렬 |
193
+ | tfx-swarm | PRD별 worktree + 다중 모델(Codex/Gemini/Claude) + 다중 기기(로컬+원격) |
194
+ | tfx-remote-spawn | Claude Code 원격 세션 (SSH, setup 필수) |
195
+
196
+ **Claude 네이티브** (CLI 불필요): tfx-find, tfx-forge, tfx-prune, tfx-index, tfx-setup, tfx-doctor, tfx-hooks, tfx-hub
197
+
198
+ 자원 우선순위: remote-spawn > swarm > multi > Light > 로컬 단독
199
+
200
+ ### 충돌 해소
201
+
202
+ - ralph = persist alias
203
+ - "auto" 단독 → tfx-auto. "알아서 해" → tfx-autopilot
204
+ - "코드에서 찾아" → tfx-find. "알아봐" → tfx-research
205
+ - 복합 의도: "구현하고 리뷰까지" → tfx-auto → cross-review hook
206
+
207
+ ### Q-Learning 동적 라우팅 (실험적)
208
+
209
+ - `TRIFLUX_DYNAMIC_ROUTING=true` 또는 `1` 설정 시 Q-Learning 기반 동적 스킬 라우팅 활성화
210
+ - `routing-weights.json` + Q-table로 스킬 선택 최적화
211
+ - 기본 비활성
212
+ </routing>
@@ -21,7 +21,14 @@
21
21
  // HOME / USERPROFILE — ${HOME} 치환용
22
22
 
23
23
  import { execFile, execFileSync } from "node:child_process";
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { createHash } from "node:crypto";
25
+ import {
26
+ existsSync,
27
+ mkdirSync,
28
+ readFileSync,
29
+ unlinkSync,
30
+ writeFileSync,
31
+ } from "node:fs";
25
32
  import { tmpdir } from "node:os";
26
33
  import { dirname, join } from "node:path";
27
34
  import { fileURLToPath } from "node:url";
@@ -30,6 +37,13 @@ import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
30
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
31
38
  const REGISTRY_PATH =
32
39
  process.env.TRIFLUX_HOOK_REGISTRY || join(__dirname, "hook-registry.json");
40
+ const HOOK_CACHE_DIR =
41
+ process.env.TRIFLUX_HOOK_CACHE_DIR ||
42
+ join(tmpdir(), "triflux-hook-orchestrator-cache");
43
+ const HOOK_CACHE_TTL_MS = Math.max(
44
+ 0,
45
+ Number.parseInt(process.env.TRIFLUX_HOOK_CACHE_TTL_MS || "1500", 10) || 1500,
46
+ );
33
47
 
34
48
  // ── stdin 읽기 ──────────────────────────────────────────────
35
49
  function readStdin() {
@@ -77,6 +91,57 @@ function matchesMatcher(hookMatcher, toolName, eventInput) {
77
91
  });
78
92
  }
79
93
 
94
+ function shouldDedupePreToolUseBash(eventName, toolName) {
95
+ return eventName === "PreToolUse" && toolName === "Bash";
96
+ }
97
+
98
+ function buildHookCachePath(stdinData) {
99
+ const digest = createHash("sha1").update(stdinData).digest("hex");
100
+ return join(HOOK_CACHE_DIR, `pretool-bash-${digest}.json`);
101
+ }
102
+
103
+ function readHookCache(stdinData) {
104
+ if (HOOK_CACHE_TTL_MS <= 0) return null;
105
+
106
+ const cachePath = buildHookCachePath(stdinData);
107
+ if (!existsSync(cachePath)) return null;
108
+
109
+ try {
110
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
111
+ const ageMs = Date.now() - Number(cached?.ts || 0);
112
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > HOOK_CACHE_TTL_MS) {
113
+ unlinkSync(cachePath);
114
+ return null;
115
+ }
116
+ return cached;
117
+ } catch {
118
+ try {
119
+ unlinkSync(cachePath);
120
+ } catch {}
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function writeHookCache(stdinData, payload) {
126
+ if (HOOK_CACHE_TTL_MS <= 0) return;
127
+
128
+ try {
129
+ mkdirSync(HOOK_CACHE_DIR, { recursive: true });
130
+ writeFileSync(
131
+ buildHookCachePath(stdinData),
132
+ JSON.stringify({ ts: Date.now(), ...payload }),
133
+ "utf8",
134
+ );
135
+ } catch {
136
+ // 캐시 실패는 훅 실행에 영향 주지 않음
137
+ }
138
+ }
139
+
140
+ function shouldShortCircuitPreToolUseBash(result) {
141
+ if (!result || result.code === 2) return true;
142
+ return result.code === 0 && Boolean(result.stdout?.trim());
143
+ }
144
+
80
145
  // ── 단일 훅 실행 ────────────────────────────────────────────
81
146
  function executeHook(hook, stdinData) {
82
147
  const cmd = resolveCommand(hook.command);
@@ -315,9 +380,23 @@ async function main() {
315
380
 
316
381
  if (!eventName) process.exit(0);
317
382
 
383
+ const dedupePreToolUseBash = shouldDedupePreToolUseBash(eventName, toolName);
384
+ if (dedupePreToolUseBash) {
385
+ const cached = readHookCache(stdinRaw);
386
+ if (cached?.stderr) process.stderr.write(cached.stderr);
387
+ if (cached?.blocked) process.exit(2);
388
+ if (cached?.mergedOutput) {
389
+ process.stdout.write(JSON.stringify(cached.mergedOutput));
390
+ }
391
+ if (cached) process.exit(0);
392
+ }
393
+
318
394
  // ── SessionStart fast-path ──
319
395
  // TRIFLUX_HOOK_FAST_PATH=false로 비활성화 가능 (rollback)
320
- if (eventName === "SessionStart" && process.env.TRIFLUX_HOOK_FAST_PATH !== "false") {
396
+ if (
397
+ eventName === "SessionStart" &&
398
+ process.env.TRIFLUX_HOOK_FAST_PATH !== "false"
399
+ ) {
321
400
  try {
322
401
  const { execute } = await import("./session-start-fast.mjs");
323
402
  const result = await execute(stdinRaw);
@@ -328,7 +407,9 @@ async function main() {
328
407
 
329
408
  // external source 훅 (session-vault 등)은 기존 방식으로 실행
330
409
  const allHooks = registry.events.SessionStart || [];
331
- const externalHooks = allHooks.filter((h) => h.enabled !== false && h.source !== "triflux");
410
+ const externalHooks = allHooks.filter(
411
+ (h) => h.enabled !== false && h.source !== "triflux",
412
+ );
332
413
  for (const hook of externalHooks) {
333
414
  const hookResult = executeHookAsync(hook, stdinRaw);
334
415
  hookResult.catch(() => {}); // fire-and-forget for external hooks
@@ -337,7 +418,9 @@ async function main() {
337
418
  process.exit(0);
338
419
  } catch (err) {
339
420
  // fast-path 실패 시 기존 방식으로 폴백
340
- process.stderr.write(`[orchestrator] fast-path failed, falling back: ${err.message}\n`);
421
+ process.stderr.write(
422
+ `[orchestrator] fast-path failed, falling back: ${err.message}\n`,
423
+ );
341
424
  }
342
425
  }
343
426
 
@@ -366,35 +449,56 @@ async function main() {
366
449
 
367
450
  let mergedOutput = null;
368
451
  let blocked = false;
452
+ let blockingStderr = "";
369
453
 
370
- for (const group of groups) {
371
- if (blocked) break;
372
-
373
- if (group.hooks.length === 1) {
374
- // 단일 훅 — 기존 동기 실행
375
- const result = executeHook(group.hooks[0], stdinRaw);
454
+ if (dedupePreToolUseBash) {
455
+ for (const hook of matched) {
456
+ const result = executeHook(hook, stdinRaw);
376
457
  if (result.code === 2) {
377
- if (result.stderr) process.stderr.write(result.stderr);
458
+ blockingStderr = result.stderr || "";
459
+ if (blockingStderr) process.stderr.write(blockingStderr);
378
460
  blocked = true;
379
461
  break;
380
462
  }
381
463
  if (result.code === 0 && result.stdout.trim()) {
382
464
  mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
383
465
  }
384
- } else {
385
- // 같은 priority 다중 훅 — 비동기 병렬 실행
386
- const results = await Promise.all(
387
- group.hooks.map((h) => executeHookAsync(h, stdinRaw)),
388
- );
389
- for (const result of results) {
466
+ if (shouldShortCircuitPreToolUseBash(result)) {
467
+ break;
468
+ }
469
+ }
470
+ } else {
471
+ for (const group of groups) {
472
+ if (blocked) break;
473
+
474
+ if (group.hooks.length === 1) {
475
+ // 단일 훅 — 기존 동기 실행
476
+ const result = executeHook(group.hooks[0], stdinRaw);
390
477
  if (result.code === 2) {
391
- if (result.stderr) process.stderr.write(result.stderr);
478
+ blockingStderr = result.stderr || "";
479
+ if (blockingStderr) process.stderr.write(blockingStderr);
392
480
  blocked = true;
393
481
  break;
394
482
  }
395
483
  if (result.code === 0 && result.stdout.trim()) {
396
484
  mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
397
485
  }
486
+ } else {
487
+ // 같은 priority 다중 훅 — 비동기 병렬 실행
488
+ const results = await Promise.all(
489
+ group.hooks.map((h) => executeHookAsync(h, stdinRaw)),
490
+ );
491
+ for (const result of results) {
492
+ if (result.code === 2) {
493
+ blockingStderr = result.stderr || "";
494
+ if (blockingStderr) process.stderr.write(blockingStderr);
495
+ blocked = true;
496
+ break;
497
+ }
498
+ if (result.code === 0 && result.stdout.trim()) {
499
+ mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
500
+ }
501
+ }
398
502
  }
399
503
  }
400
504
  }
@@ -403,23 +507,35 @@ async function main() {
403
507
  if (eventName === "PostToolUse" && !blocked) {
404
508
  try {
405
509
  const home = process.env.HOME || process.env.USERPROFILE || "";
406
- const snapshotPath = join(home, ".claude", "cache", "tfx-hub", "context-monitor.json");
510
+ const snapshotPath = join(
511
+ home,
512
+ ".claude",
513
+ "cache",
514
+ "tfx-hub",
515
+ "context-monitor.json",
516
+ );
407
517
  const nudgeMarker = join(tmpdir(), "tfx-compact-nudge-sent");
408
518
  if (existsSync(snapshotPath) && !existsSync(nudgeMarker)) {
409
519
  const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
410
520
  const percent = Number(snap.percent || 0);
411
521
  if (percent >= 80) {
412
522
  const level = percent >= 90 ? "critical" : "warn";
413
- const msg = level === "critical"
414
- ? `[context ${percent}%] 컨텍스트 ${percent}% 사용. /compact 또는 에이전트 분할을 강력 권장합니다.`
415
- : `[context ${percent}%] 컨텍스트 ${percent}% 사용. 마일스톤이면 /compact 권장합니다.`;
416
- mergedOutput = mergeOutputs(mergedOutput, JSON.stringify({ systemMessage: msg }));
523
+ const msg =
524
+ level === "critical"
525
+ ? `[context ${percent}%] 컨텍스트 ${percent}% 사용. /compact 또는 에이전트 분할을 강력 권장합니다.`
526
+ : `[context ${percent}%] 컨텍스트 ${percent}% 사용. 마일스톤이면 /compact를 권장합니다.`;
527
+ mergedOutput = mergeOutputs(
528
+ mergedOutput,
529
+ JSON.stringify({ systemMessage: msg }),
530
+ );
417
531
  if (level === "warn") {
418
532
  writeFileSync(nudgeMarker, new Date().toISOString());
419
533
  }
420
534
  }
421
535
  }
422
- } catch { /* 컨텍스트 모니터 읽기 실패 무시 */ }
536
+ } catch {
537
+ /* 컨텍스트 모니터 읽기 실패 무시 */
538
+ }
423
539
  }
424
540
 
425
541
  // ── PostToolUse:Skill 완료 시 라우팅 가중치 기록 ──
@@ -440,9 +556,24 @@ async function main() {
440
556
 
441
557
  // 결과 출력
442
558
  if (blocked) {
559
+ if (dedupePreToolUseBash) {
560
+ writeHookCache(stdinRaw, {
561
+ blocked: true,
562
+ mergedOutput: null,
563
+ stderr: blockingStderr,
564
+ });
565
+ }
443
566
  process.exit(2);
444
567
  }
445
568
 
569
+ if (dedupePreToolUseBash) {
570
+ writeHookCache(stdinRaw, {
571
+ blocked: false,
572
+ mergedOutput,
573
+ stderr: "",
574
+ });
575
+ }
576
+
446
577
  if (mergedOutput) {
447
578
  process.stdout.write(JSON.stringify(mergedOutput));
448
579
  }
@@ -0,0 +1,73 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ const GIT_BASH_PATHS = Object.freeze([
4
+ "C:/Program Files/Git/bin/bash.exe",
5
+ "C:/Program Files/Git/usr/bin/bash.exe",
6
+ "C:/Program Files (x86)/Git/bin/bash.exe",
7
+ "C:/Program Files (x86)/Git/usr/bin/bash.exe",
8
+ ]);
9
+
10
+ /**
11
+ * Resolve a concrete Git Bash executable on Windows.
12
+ *
13
+ * @param {object} [opts]
14
+ * @param {string} [opts.platform=process.platform]
15
+ * @param {(path: string) => boolean} [opts.exists=existsSync]
16
+ * @returns {string | null}
17
+ */
18
+ export function resolveGitBashExecutable(opts = {}) {
19
+ const { platform = process.platform, exists = existsSync } = opts;
20
+
21
+ if (platform !== "win32") {
22
+ return null;
23
+ }
24
+
25
+ return GIT_BASH_PATHS.find((candidate) => exists(candidate)) || null;
26
+ }
27
+
28
+ /**
29
+ * Resolve a Windows-safe bash executable.
30
+ * On Windows we prefer Git Bash over bare `bash`, which may resolve to WSL.
31
+ *
32
+ * @param {object} [opts]
33
+ * @param {string} [opts.platform=process.platform]
34
+ * @param {(path: string) => boolean} [opts.exists=existsSync]
35
+ * @returns {string}
36
+ */
37
+ export function resolveBashExecutable(opts = {}) {
38
+ return resolveGitBashExecutable(opts) || "bash";
39
+ }
40
+
41
+ function shellQuote(command) {
42
+ return `'${String(command).replace(/'/g, `'"'"'`)}'`;
43
+ }
44
+
45
+ /**
46
+ * Wrap .sh script command strings so Windows always runs them through Bash.
47
+ * This prevents shell:true child launches from treating a script path like an
48
+ * openable file association instead of an executable script.
49
+ *
50
+ * @param {string} command
51
+ * @param {object} [opts]
52
+ * @param {string} [opts.bashCommand]
53
+ * @returns {string}
54
+ */
55
+ export function ensureBashScriptExecution(command, opts = {}) {
56
+ const text = String(command || "").trim();
57
+ if (!text) return text;
58
+
59
+ if (!/\.sh(?:\s|$)/iu.test(text)) {
60
+ return text;
61
+ }
62
+
63
+ if (
64
+ /^(?:\s*[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*(?:bash(?:\.exe)?|sh)\b/iu.test(
65
+ text,
66
+ )
67
+ ) {
68
+ return text;
69
+ }
70
+
71
+ const bashCommand = opts.bashCommand || resolveBashExecutable();
72
+ return `${bashCommand} -lc ${shellQuote(text)}`;
73
+ }
@@ -1,11 +1,5 @@
1
1
  import { psmuxExec } from "./psmux.mjs";
2
- import {
3
- detectMultiplexer,
4
- focusWtPane,
5
- hasWindowsTerminal,
6
- resolveAttachCommand,
7
- tmuxExec,
8
- } from "./session.mjs";
2
+ import { hasWindowsTerminal } from "./session.mjs";
9
3
  import { createWtManager } from "./wt-manager.mjs";
10
4
 
11
5
  function sanitizeWindowTitle(value, fallback = "triflux") {
@@ -81,26 +75,6 @@ async function spawnWindowsTerminal(spec, opts = {}) {
81
75
  }
82
76
  }
83
77
 
84
- export function focusManagedPane(target, opts = {}) {
85
- const { teammateMode = "", layout = "1xN" } = opts;
86
- const paneRef = String(target || "");
87
-
88
- if (teammateMode === "wt" || paneRef.startsWith("wt:")) {
89
- const paneIndex = parseWorkerNumber(paneRef);
90
- return paneIndex != null && focusWtPane(paneIndex, { layout });
91
- }
92
-
93
- if (!paneRef) return false;
94
- try {
95
- if (detectMultiplexer() === "psmux")
96
- psmuxExec(["select-pane", "-t", paneRef]);
97
- else tmuxExec(`select-pane -t ${paneRef}`);
98
- return true;
99
- } catch {
100
- return false;
101
- }
102
- }
103
-
104
78
  export function openHeadlessDashboardTarget(sessionName, opts = {}) {
105
79
  const { worker = null, openAll = false, cwd = process.cwd(), title } = opts;
106
80
 
@@ -126,44 +100,3 @@ export function openHeadlessDashboardTarget(sessionName, opts = {}) {
126
100
  );
127
101
  return true;
128
102
  }
129
-
130
- export function openDashboardRuntimeTarget(runtime, opts = {}) {
131
- const {
132
- teammateMode = "",
133
- sessionName = "",
134
- targetPane = "",
135
- layout = "1xN",
136
- openAll = false,
137
- cwd = process.cwd(),
138
- title = "",
139
- } = { ...runtime, ...opts };
140
-
141
- if (teammateMode === "headless") {
142
- return openHeadlessDashboardTarget(sessionName, {
143
- worker: openAll ? null : targetPane,
144
- openAll,
145
- cwd,
146
- title,
147
- });
148
- }
149
-
150
- if (
151
- (teammateMode === "wt" || String(targetPane).startsWith("wt:")) &&
152
- !openAll
153
- ) {
154
- return focusManagedPane(targetPane, { teammateMode: "wt", layout });
155
- }
156
-
157
- try {
158
- if (!openAll && targetPane)
159
- focusManagedPane(targetPane, { teammateMode, layout });
160
- void spawnWindowsTerminal(resolveAttachCommand(sessionName), {
161
- mode: decideDashboardOpenMode({ openAll }),
162
- title: title || `▲ ${sanitizeSessionName(sessionName)}`,
163
- cwd,
164
- });
165
- return true;
166
- } catch {
167
- return false;
168
- }
169
- }
@@ -9,6 +9,10 @@ import {
9
9
  } from "node:fs";
10
10
  import { createServer } from "node:http";
11
11
  import { dirname, join } from "node:path";
12
+ import {
13
+ ensureBashScriptExecution,
14
+ resolveBashExecutable,
15
+ } from "../lib/bash-path.mjs";
12
16
  import { verifySlimWrapperRouteExecution } from "./native.mjs";
13
17
  import { forceCleanupTeam } from "./nativeProxy.mjs";
14
18
 
@@ -149,7 +153,10 @@ function validateMemberCommand(command, memberName) {
149
153
  }
150
154
 
151
155
  function spawnMember(member) {
152
- validateMemberCommand(member.command, member.name);
156
+ const command = ensureBashScriptExecution(member.command, {
157
+ bashCommand: resolveBashExecutable(),
158
+ });
159
+ validateMemberCommand(command, member.name);
153
160
 
154
161
  const outPath = join(logsDir, `${member.name}.out.log`);
155
162
  const errPath = join(logsDir, `${member.name}.err.log`);
@@ -157,7 +164,7 @@ function spawnMember(member) {
157
164
  const outWs = createWriteStream(outPath, { flags: "a" });
158
165
  const errWs = createWriteStream(errPath, { flags: "a" });
159
166
 
160
- const child = spawn(member.command, {
167
+ const child = spawn(command, {
161
168
  shell: true,
162
169
  env: {
163
170
  ...process.env,