triflux 10.9.31 → 10.10.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.
Files changed (124) hide show
  1. package/CLAUDE.md +159 -0
  2. package/hooks/keyword-rules.json +39 -0
  3. package/hooks/safety-guard.mjs +71 -0
  4. package/hub/cli-adapter-base.mjs +0 -1
  5. package/hub/lib/process-utils.mjs +5 -1
  6. package/hub/middleware/quota-middleware.mjs +70 -0
  7. package/hub/server.mjs +5 -0
  8. package/hub/team/conductor.mjs +15 -3
  9. package/hub/team/execution-mode.mjs +98 -0
  10. package/hub/team/process-cleanup.mjs +22 -9
  11. package/hub/team/swarm-cli.mjs +11 -5
  12. package/hub/team/swarm-hypervisor.mjs +57 -4
  13. package/hub/team/swarm-planner.mjs +12 -0
  14. package/hub/workers/codex-app-server-worker.mjs +0 -1
  15. package/hud/context-monitor.mjs +50 -4
  16. package/hud/hud-qos-status.mjs +5 -2
  17. package/package.json +21 -62
  18. package/references/cli-parameter-reference.md +240 -0
  19. package/references/codex-plugin-cc-analysis.md +706 -0
  20. package/references/codex-plugin-cc-code-patterns.md +468 -0
  21. package/references/hosts.json +46 -0
  22. package/scripts/__tests__/release-governance.test.mjs +56 -0
  23. package/scripts/hub-ensure.mjs +21 -0
  24. package/scripts/mcp-cleanup.ps1 +2 -2
  25. package/scripts/release/lib.mjs +39 -4
  26. package/scripts/release/prepare.mjs +68 -7
  27. package/scripts/setup.mjs +96 -66
  28. package/scripts/sync-hub-mcp-settings.mjs +157 -2
  29. package/skills/tfx-analysis/SKILL.md +153 -60
  30. package/skills/tfx-auto/SKILL.md +82 -4
  31. package/skills/tfx-auto-codex/SKILL.md +25 -90
  32. package/skills/tfx-autopilot/SKILL.md +26 -97
  33. package/skills/tfx-autoresearch/SKILL.md +5 -117
  34. package/skills/tfx-autoroute/SKILL.md +23 -170
  35. package/skills/tfx-codex/SKILL.md +21 -66
  36. package/skills/tfx-deep-analysis/SKILL.md +12 -216
  37. package/skills/tfx-deep-interview/SKILL.md +7 -187
  38. package/skills/tfx-deep-plan/SKILL.md +6 -271
  39. package/skills/tfx-deep-qa/SKILL.md +8 -149
  40. package/skills/tfx-deep-research/SKILL.md +8 -199
  41. package/skills/tfx-deep-review/SKILL.md +16 -162
  42. package/skills/tfx-fullcycle/SKILL.md +23 -268
  43. package/skills/tfx-gemini/SKILL.md +21 -74
  44. package/skills/tfx-multi/SKILL.md +23 -167
  45. package/skills/tfx-persist/SKILL.md +25 -257
  46. package/skills/tfx-plan/SKILL.md +156 -42
  47. package/skills/tfx-qa/SKILL.md +118 -83
  48. package/skills/tfx-research/SKILL.md +154 -93
  49. package/skills/tfx-review/SKILL.md +181 -13
  50. package/skills/tfx-ship/SKILL.md +324 -0
  51. package/skills/tfx-swarm/SKILL.md +25 -210
  52. package/skills/tfx-workspace/async-tests/run-tests.sh +203 -0
  53. package/skills/tfx-workspace/evals/evals.json +79 -0
  54. package/skills/tfx-workspace/iteration-1/benchmark.json +524 -0
  55. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/eval_metadata.json +11 -0
  56. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/grading.json +25 -0
  57. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/outputs/analysis.md +154 -0
  58. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/timing.json +5 -0
  59. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/grading.json +25 -0
  60. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/outputs/analysis.md +126 -0
  61. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/timing.json +5 -0
  62. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/eval_metadata.json +11 -0
  63. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/grading.json +25 -0
  64. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/outputs/analysis.md +119 -0
  65. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/timing.json +5 -0
  66. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/grading.json +25 -0
  67. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/outputs/analysis.md +115 -0
  68. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/timing.json +5 -0
  69. package/skills/tfx-workspace/iteration-1/hub-start-sequence/eval_metadata.json +10 -0
  70. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/grading.json +20 -0
  71. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/outputs/analysis.md +86 -0
  72. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/timing.json +5 -0
  73. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/grading.json +20 -0
  74. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/outputs/analysis.md +81 -0
  75. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/timing.json +5 -0
  76. package/skills/tfx-workspace/iteration-1/multi-team-creation/eval_metadata.json +12 -0
  77. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/grading.json +30 -0
  78. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/outputs/analysis.md +316 -0
  79. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/timing.json +5 -0
  80. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/grading.json +30 -0
  81. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/outputs/analysis.md +352 -0
  82. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/timing.json +5 -0
  83. package/skills/tfx-workspace/iteration-1/review.html +1325 -0
  84. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/eval_metadata.json +12 -0
  85. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/grading.json +30 -0
  86. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/outputs/analysis.md +97 -0
  87. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/timing.json +5 -0
  88. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/grading.json +30 -0
  89. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/outputs/analysis.md +94 -0
  90. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/timing.json +5 -0
  91. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/eval_metadata.json +12 -0
  92. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/grading.json +30 -0
  93. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/outputs/analysis.md +209 -0
  94. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/timing.json +5 -0
  95. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/grading.json +30 -0
  96. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/outputs/analysis.md +193 -0
  97. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/timing.json +5 -0
  98. package/skills/tfx-workspace/iteration-2/benchmark.json +144 -0
  99. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/eval_metadata.json +13 -0
  100. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/grading.json +35 -0
  101. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/outputs/analysis.md +382 -0
  102. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/timing.json +5 -0
  103. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/grading.json +35 -0
  104. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/outputs/analysis.md +333 -0
  105. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/timing.json +5 -0
  106. package/skills/tfx-workspace/iteration-2/review.html +1325 -0
  107. package/skills/tfx-workspace/skill-snapshot/tfx-auto/SKILL.md +217 -0
  108. package/skills/tfx-workspace/skill-snapshot/tfx-auto-codex/SKILL.md +77 -0
  109. package/skills/tfx-workspace/skill-snapshot/tfx-codex/SKILL.md +65 -0
  110. package/skills/tfx-workspace/skill-snapshot/tfx-doctor/SKILL.md +94 -0
  111. package/skills/tfx-workspace/skill-snapshot/tfx-gemini/SKILL.md +82 -0
  112. package/skills/tfx-workspace/skill-snapshot/tfx-hub/SKILL.md +133 -0
  113. package/skills/tfx-workspace/skill-snapshot/tfx-multi/SKILL.md +426 -0
  114. package/skills/tfx-workspace/skill-snapshot/tfx-setup/SKILL.md +101 -0
  115. package/.claude-plugin/marketplace.json +0 -34
  116. package/.claude-plugin/plugin.json +0 -22
  117. package/config/mcp-registry.json +0 -29
  118. package/tui/codex-profile.mjs +0 -457
  119. package/tui/core.mjs +0 -266
  120. package/tui/doctor.mjs +0 -375
  121. package/tui/gemini-profile.mjs +0 -299
  122. package/tui/monitor-data.mjs +0 -152
  123. package/tui/monitor.mjs +0 -330
  124. package/tui/setup.mjs +0 -598
package/CLAUDE.md ADDED
@@ -0,0 +1,159 @@
1
+ # triflux — Claude Code 운영 가이드
2
+
3
+ <core-systems>
4
+ ## 핵심 스킬 시스템 (항상 인지)
5
+
6
+ 이 프로젝트는 3개의 스킬 시스템을 동시에 사용한다. 어떤 작업이든 해당 시스템의 스킬이 있는지 먼저 확인한다.
7
+
8
+ | 시스템 | 접두사 | 용도 | 스킬 수 |
9
+ |--------|--------|------|---------|
10
+ | **triflux** | `/tfx-*` | CLI 라우팅, 멀티모델 오케스트레이션, 스웜, 원격 실행 | ~40개 |
11
+ | **gstack** | `/` (접두사 없음) | QA, ship, investigate, design, review, checkpoint | ~35개 |
12
+ | **omc** | `/oh-my-claudecode:*` | autopilot, ralph, team, ultrawork, ccg | ~25개 |
13
+
14
+ 스킬을 모르면 자연어 라우팅(`.claude/rules/tfx-routing.md`)으로 자동 매핑된다.
15
+ 세션 종료 전 메모리 파일이 3개+ 변경됐으면 `/memory-hygiene` 제안을 검토한다.
16
+ </core-systems>
17
+
18
+ <psmux-wt>
19
+ ## psmux/WT 규칙
20
+
21
+ psmux 세션·WT 패인을 생성/조작/정리할 때 `tfx-psmux-rules` 스킬을 참조한다.
22
+ WT 프리징 방지: exit → sleep 2 → kill 순서. 바로 kill하지 않는다.
23
+
24
+ ### wt.exe → wt-manager 경유
25
+
26
+ safety-guard가 `wt.exe`, `wt new-tab`, `wt split-pane`, `Start-Process wt`를 차단한다.
27
+ `hub/team/wt-manager.mjs`의 API를 사용한다.
28
+
29
+ | 용도 | API |
30
+ |------|-----|
31
+ | 새 탭 | `createTab({ title, command, profile, cwd })` |
32
+ | 패인 분할 | `splitPane({ direction: 'H'\|'V', title, command })` |
33
+ | 다중 배치 | `applySplitLayout([{ title, command, direction }])` |
34
+ | 탭 정리 | `closeTab(title)` / `closeStale({ olderThanMs, titlePattern })` |
35
+
36
+ 차단과 대안은 항상 쌍으로 존재해야 한다. 차단만 추가하고 대안을 안 만들면 데드락.
37
+
38
+ ### raw `psmux kill-session` → psmux wrapper 경유
39
+
40
+ safety-guard가 raw `psmux kill-session`을 차단한다.
41
+ 세션 정리는 `hub/team/psmux.mjs` 공개 API 또는 internal wrapper로 우회한다.
42
+
43
+ | 용도 | API / 래퍼 |
44
+ |------|------------|
45
+ | 세션 조회 | `listSessions({ filterTitle?, olderThanMs? })` |
46
+ | title prefix / regex kill | `killSessionByTitle(titlePattern)` |
47
+ | stale idle 세션 정리 | `pruneStale({ olderThanMs, dryRun })` |
48
+ | Bash 훅 우회용 래퍼 | `node hub/team/psmux.mjs --internal kill-by-title <prefix\|/regex/>` |
49
+
50
+ ### psmux에서 Codex 실행
51
+
52
+ | 방식 | 동작 | 이유 |
53
+ |------|------|------|
54
+ | `codex` (interactive) | 불가 | psmux에서 TTY를 못 잡음 |
55
+ | `codex < prompt.md` | 불가 | "stdin is not a terminal" |
56
+ | `codex exec "$(cat prompt.md)" -s danger-full-access --dangerously-bypass-approvals-and-sandbox` | 사용 | 유일한 안전 경로 |
57
+
58
+ `codex exec`는 config.toml `approval_mode`를 무시하므로 `--dangerously-bypass-approvals-and-sandbox` 필수.
59
+ `-s` 유효값: read-only, workspace-write, danger-full-access.
60
+ </psmux-wt>
61
+
62
+ <codex-config>
63
+ ## Codex config.toml
64
+
65
+ config.toml에 이미 설정된 값은 CLI 플래그로 중복 지정하지 않는다.
66
+
67
+ | config.toml에 있으면 | CLI에서 생략 |
68
+ |---------------------|-------------|
69
+ | `approval_mode = "auto"` | `-a`, `--full-auto` |
70
+ | `sandbox = "workspace-write"` | `-s`, `--full-auto` |
71
+
72
+ 안전 패턴: config.toml에 기본값을 두고, CLI에서는 `--profile` 선택만 한다.
73
+ </codex-config>
74
+
75
+ <account-broker>
76
+ ## AccountBroker (계정 브로커)
77
+
78
+ conductor, headless, swarm-hypervisor가 하나의 AccountBroker 싱글턴을 공유한다.
79
+
80
+ | 항목 | 설명 |
81
+ |------|------|
82
+ | 계정별 CircuitBreaker | 장애 격리 — 한 계정 오류가 다른 계정에 전파되지 않음 |
83
+ | busy 플래그 | 동일 계정 이중 임대(double-lease) 방지 |
84
+ | `/broker/reload` | 장시간 세션 중 accounts.json 핫리로드 |
85
+ | EventEmitter 이벤트 | `lease`, `release`, `cooldown`, `tierFallback`, `circuitOpen`, `circuitClose`, `noAvailableAccounts` — HUD 연동용 |
86
+ </account-broker>
87
+
88
+ <remote>
89
+ ## 원격 실행
90
+
91
+ ### 스킬 구분
92
+
93
+ | 스킬 | 대상 | 방식 |
94
+ |------|------|------|
95
+ | tfx-codex-swarm | 로컬 전용 | 로컬 worktree + psmux |
96
+ | tfx-remote-spawn | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
97
+
98
+ codex를 SSH 너머로 직접 실행하지 않는다. config.toml 충돌 + TTY 문제.
99
+ 원격에서 codex가 필요하면: remote-spawn → Claude Code → Claude가 내부에서 codex 호출.
100
+
101
+ ### SSH 패턴
102
+
103
+ hosts.json `os` 필드로 대상 셸을 판단한다. safety-guard도 이 필드를 참조.
104
+
105
+ | 대상 OS | 셸 | 패턴 |
106
+ |---------|-----|------|
107
+ | windows | PowerShell | scp + `pwsh -File` 필수. `$var` → `$env:VAR`, `2>/dev/null` → `2>$null` |
108
+ | darwin | zsh | 인라인 가능. brew PATH 주의 (`/opt/homebrew/bin`) |
109
+ | linux | bash | 인라인 가능. 표준 POSIX |
110
+
111
+ - `~` → `$HOME` 변환은 모든 OS 공통
112
+ </remote>
113
+
114
+ <headless-retrieval>
115
+ ## Headless 결과 회수
116
+
117
+ background로 실행한 headless 결과는 **반드시 task-notification 완료 후** 읽는다.
118
+
119
+ | 패턴 | 올바름 | 이유 |
120
+ |------|--------|------|
121
+ | task-notification 후 output 파일 읽기 | YES | 프로세스 종료 = 워커 전부 완료 |
122
+ | task-notification 전 output 파일 tail | NO | 시작 메시지만 보이고 "실패"로 오진 |
123
+ | psmux capture-pane으로 중간 체크 | NO | 워커 진행 중이면 빈 화면일 수 있음 |
124
+
125
+ 완료 마커: `=== HEADLESS_COMPLETE succeeded=N failed=N total=N ===`
126
+ 워커 상세: `$TMPDIR/tfx-headless/{sessionName}-worker-N.txt`
127
+ </headless-retrieval>
128
+
129
+ <cross-review>
130
+ ## 교차 검증
131
+
132
+ - Claude 작성 코드 → Codex 리뷰
133
+ - Codex 작성 코드 → Claude 리뷰
134
+ - 동일 모델 self-approve 하지 않는다
135
+ - git commit 전 미검증 파일 감지 시 nudge
136
+ </cross-review>
137
+
138
+ <session-context>
139
+ ## 맥락 이탈 판단
140
+
141
+ 현재 세션 맥락과 무관한 요청이 감지되면 psmux 격리를 제안한다.
142
+
143
+ | 확신도 | 신호 | 행동 |
144
+ |--------|------|------|
145
+ | 확실 | "새 탭", "별도로", "새 세션" | 바로 psmux spawn |
146
+ | 높음 | 다른 프로젝트/스택 언급 | 분리 제안 |
147
+ | 중간 | 작업 유형 전환 | 분리 제안 + 현재 세션 옵션 |
148
+ | 낮음 | 현재 작업 연장 | 세션 유지 |
149
+ </session-context>
150
+
151
+ ## 세부 규칙은 `.claude/rules/` 참조
152
+
153
+ | 파일 | 내용 |
154
+ |------|------|
155
+ | `.claude/rules/tfx-routing.md` | 자연어 → 스킬 라우팅, CLI 라우팅 Layer 1~3, 충돌 해소 |
156
+ | `.claude/rules/tfx-execution-skill-map.md` | tfx-auto / multi / swarm 실행 엔진 매핑, 격리 기준, 안티패턴 |
157
+ | `.claude/rules/tfx-update-logic.md` | triflux / OMC / gstack / Codex / Gemini 업데이트 로직 |
158
+
159
+ Claude Code는 `.claude/rules/*.md` 를 자동 로드한다. Codex CLI는 `@import` 미지원이므로 필요 시 `AGENTS.md` 를 독립 유지한다.
@@ -664,6 +664,45 @@
664
664
  "exclusive": false,
665
665
  "state": null,
666
666
  "mcp_route": null
667
+ },
668
+ {
669
+ "id": "tfx-ship",
670
+ "patterns": [
671
+ {
672
+ "source": "\\btfx[\\s-]?ship\\b",
673
+ "flags": "i"
674
+ },
675
+ {
676
+ "source": "/ship\\b",
677
+ "flags": "i"
678
+ },
679
+ {
680
+ "source": "\\brelease\\b",
681
+ "flags": "i"
682
+ },
683
+ {
684
+ "source": "\\bpublish\\b",
685
+ "flags": "i"
686
+ },
687
+ {
688
+ "source": "배포(?!자|사|장|처)",
689
+ "flags": ""
690
+ },
691
+ {
692
+ "source": "릴리[즈스]",
693
+ "flags": ""
694
+ },
695
+ {
696
+ "source": "쉽\\s*(?:해|하자|가자|쳐)",
697
+ "flags": ""
698
+ }
699
+ ],
700
+ "skill": "tfx-ship",
701
+ "priority": 3,
702
+ "supersedes": ["gstack-ship"],
703
+ "exclusive": false,
704
+ "state": null,
705
+ "mcp_route": null
667
706
  }
668
707
  ]
669
708
  }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+
2
3
  // hooks/safety-guard.mjs — PreToolUse:Bash 훅
3
4
  //
4
5
  // 위험한 Bash 명령을 사전 차단(exit 2)하거나 경고(additionalContext)한다.
@@ -8,6 +9,7 @@
8
9
  // BLOCK (exit 2) — 복구 불가능한 파괴적 명령
9
10
  // WARN (allow + context) — 주의가 필요한 명령
10
11
 
12
+ import { spawnSync } from "node:child_process";
11
13
  import { existsSync, readFileSync } from "node:fs";
12
14
  import { join } from "node:path";
13
15
 
@@ -251,6 +253,60 @@ function hasSegmentInvocation(cmd, patterns) {
251
253
  });
252
254
  }
253
255
 
256
+ function isGitCommitInvocation(command) {
257
+ const lines = command.split(/\n/);
258
+ let heredocDelimiter = null;
259
+ return lines.some((line) => {
260
+ if (heredocDelimiter !== null) {
261
+ if (line.trim() === heredocDelimiter) heredocDelimiter = null;
262
+ return false;
263
+ }
264
+
265
+ const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
266
+ if (heredocMatch) {
267
+ heredocDelimiter = heredocMatch[1];
268
+ }
269
+
270
+ return line
271
+ .split(/\s*(?:&&|;|\|\|)\s*/)
272
+ .some((segment) => /^\s*git\s+commit\b/i.test(segment.trim()));
273
+ });
274
+ }
275
+
276
+ function resolveHookCwd(input) {
277
+ return String(
278
+ input?.cwd ||
279
+ input?.tool_input?.cwd ||
280
+ process.env.CLAUDE_CWD ||
281
+ process.cwd() ||
282
+ process.env.PWD,
283
+ );
284
+ }
285
+
286
+ function getCurrentGitBranch(cwd) {
287
+ try {
288
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
289
+ cwd,
290
+ encoding: "utf8",
291
+ stdio: ["ignore", "pipe", "ignore"],
292
+ });
293
+ if (result.status !== 0) return "";
294
+ return String(result.stdout || "").trim();
295
+ } catch {
296
+ return "";
297
+ }
298
+ }
299
+
300
+ function isProtectedMainBranch(branch) {
301
+ return /^(main|master)$/i.test(String(branch || "").trim());
302
+ }
303
+
304
+ function isSwarmWorktreeCwd(cwd) {
305
+ return /(?:^|\/)\.codex-swarm\/wt-[^/]+(?:\/|$)/.test(
306
+ String(cwd || "").replace(/\\/g, "/"),
307
+ );
308
+ }
309
+
254
310
  function blockCommand(message, command) {
255
311
  process.stderr.write(
256
312
  `${message}\n` +
@@ -275,6 +331,7 @@ function main() {
275
331
 
276
332
  const command = (input.tool_input?.command || "").trim();
277
333
  if (!command) process.exit(0);
334
+ const hookCwd = resolveHookCwd(input);
278
335
 
279
336
  // psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
280
337
  // git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
@@ -298,6 +355,20 @@ function main() {
298
355
  process.exit(0);
299
356
  }
300
357
 
358
+ const codexPrdActive = process.env.CODEX_PRD_ACTIVE === "1";
359
+ if (codexPrdActive && isGitCommitInvocation(command)) {
360
+ const branch = getCurrentGitBranch(hookCwd);
361
+ if (isProtectedMainBranch(branch)) {
362
+ const locationHint = isSwarmWorktreeCwd(hookCwd)
363
+ ? "swarm worktree 내부에서도 main/master 직접 commit은 금지됩니다."
364
+ : "현재 PWD가 swarm worktree가 아닙니다.";
365
+ blockCommand(
366
+ `[safety-guard] Codex PRD 실행 중 ${branch} 직접 commit 차단됨. ${locationHint}`,
367
+ command,
368
+ );
369
+ }
370
+ }
371
+
301
372
  // 0.1. reflexion 적응형 패널티 — 이전 세션에서 차단된 패턴 사전 경고
302
373
  const penalties = loadReflexionPenalties();
303
374
  if (penalties.length > 0) {
@@ -147,7 +147,6 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
147
147
  profile,
148
148
  skipGitRepoCheck = true,
149
149
  sandboxBypass = true,
150
- cwd,
151
150
  mcpServers,
152
151
  } = opts;
153
152
 
@@ -102,7 +102,11 @@ function killWithEscalation(orphanPids, procMap) {
102
102
  }
103
103
  }
104
104
  }
105
- killProcess(pid, { signal: "SIGKILL", force: true });
105
+ killProcess(pid, {
106
+ signal: "SIGKILL",
107
+ force: true,
108
+ tree: IS_WINDOWS,
109
+ });
106
110
  }
107
111
  if (!isPidAlive(pid)) killed++;
108
112
  }
@@ -0,0 +1,70 @@
1
+ const FAILURE_STATUSES = new Set(["quota_hit", "error"]);
2
+
3
+ function buildFailureRecord(result = {}) {
4
+ const failure = {
5
+ id: result.id ?? "unknown",
6
+ status: result.status ?? "unknown",
7
+ };
8
+
9
+ if (Number.isFinite(result.http)) {
10
+ failure.http = result.http;
11
+ }
12
+
13
+ if (typeof result.message === "string" && result.message.length > 0) {
14
+ failure.message = result.message;
15
+ }
16
+
17
+ if (result.headers && Object.keys(result.headers).length > 0) {
18
+ failure.headers = result.headers;
19
+ }
20
+
21
+ return failure;
22
+ }
23
+
24
+ export function summarizeQuotaResults(results = []) {
25
+ const metrics = {
26
+ checked: results.length,
27
+ ok: 0,
28
+ quotaHit: 0,
29
+ error: 0,
30
+ failed: 0,
31
+ };
32
+ const failures = [];
33
+
34
+ for (const result of results) {
35
+ const status = result?.status ?? "unknown";
36
+
37
+ if (status === "ok") {
38
+ metrics.ok += 1;
39
+ } else if (status === "quota_hit") {
40
+ metrics.quotaHit += 1;
41
+ } else if (status === "error") {
42
+ metrics.error += 1;
43
+ }
44
+
45
+ if (FAILURE_STATUSES.has(status)) {
46
+ failures.push(buildFailureRecord(result));
47
+ }
48
+ }
49
+
50
+ metrics.failed = failures.length;
51
+ return { metrics, failures };
52
+ }
53
+
54
+ export function logQuotaRefreshFailures(logger, results = []) {
55
+ const { metrics, failures } = summarizeQuotaResults(results);
56
+ if (failures.length === 0) {
57
+ return { logged: false, metrics, failures };
58
+ }
59
+
60
+ logger.warn(
61
+ {
62
+ tag: "hub-quota",
63
+ metrics,
64
+ failures,
65
+ },
66
+ "broker.quota_refresh_degraded",
67
+ );
68
+
69
+ return { logged: true, metrics, failures };
70
+ }
package/hub/server.mjs CHANGED
@@ -29,6 +29,7 @@ import { DelegatorService } from "./delegator/index.mjs";
29
29
  import { createHitlManager } from "./hitl.mjs";
30
30
  import { cleanupOrphanNodeProcesses } from "./lib/process-utils.mjs";
31
31
  import * as spawnTrace from "./lib/spawn-trace.mjs";
32
+ import { logQuotaRefreshFailures } from "./middleware/quota-middleware.mjs";
32
33
  import { wrapRequestHandler } from "./middleware/request-logger.mjs";
33
34
  import { createPipeServer } from "./pipe.mjs";
34
35
  import { createRouter } from "./router.mjs";
@@ -299,6 +300,9 @@ async function syncHubMcpSettingsIfAvailable({ hubUrl }) {
299
300
  return;
300
301
  }
301
302
  await mod.syncHubMcpSettings({ hubUrl });
303
+ if (typeof mod?.syncCodexHubUrl === "function") {
304
+ await mod.syncCodexHubUrl({ hubUrl });
305
+ }
302
306
  } catch (error) {
303
307
  const message = error?.message || String(error);
304
308
  if (error?.code === "ERR_MODULE_NOT_FOUND") {
@@ -988,6 +992,7 @@ export async function startHub({
988
992
  ) {
989
993
  try {
990
994
  const results = await refreshAllAccountQuotas();
995
+ logQuotaRefreshFailures(hubLog, results);
991
996
  return writeJson(res, 200, { ok: true, results, ts: Date.now() });
992
997
  } catch (err) {
993
998
  hubLog.error(
@@ -28,6 +28,7 @@ import {
28
28
  getConductorRegistry,
29
29
  } from "./conductor-registry.mjs";
30
30
  import { createEventLog } from "./event-log.mjs";
31
+ import { buildSpawnSpecForMode, MODES } from "./execution-mode.mjs";
31
32
  import { createHealthProbe } from "./health-probe.mjs";
32
33
  import { buildLauncher } from "./launcher-template.mjs";
33
34
  import {
@@ -490,6 +491,14 @@ export function createConductor(opts = {}) {
490
491
  let recentOutput = "";
491
492
 
492
493
  const spawnCwd = session.config.workdir || launcher.cwd || undefined;
494
+ const spawnSpec = buildSpawnSpecForMode(MODES.HEADLESS, {
495
+ cli: session.config.agent,
496
+ prompt: session.config.prompt,
497
+ profile: session.config.profile,
498
+ model: session.config.model,
499
+ mcpServers: session.config.mcpServers,
500
+ resolveCommand: opts.deps?.resolveCliExecutable,
501
+ });
493
502
 
494
503
  // #90 branch guard: shard spawn cwd가 main 브랜치면 즉시 abort.
495
504
  // swarm shard는 반드시 shard 전용 worktree 브랜치에서 실행되어야 한다.
@@ -524,8 +533,8 @@ export function createConductor(opts = {}) {
524
533
 
525
534
  let child;
526
535
  try {
527
- child = spawnFn(launcher.command, {
528
- shell: true,
536
+ child = spawnFn(spawnSpec.command, spawnSpec.args, {
537
+ shell: false,
529
538
  cwd: spawnCwd,
530
539
  env: {
531
540
  ...process.env,
@@ -544,6 +553,7 @@ export function createConductor(opts = {}) {
544
553
  session: session.id,
545
554
  error: err.message,
546
555
  cwd: spawnCwd || null,
556
+ command: spawnSpec.command,
547
557
  });
548
558
  handleFailure(session, `spawn_error:${err.message}`);
549
559
  return;
@@ -561,7 +571,9 @@ export function createConductor(opts = {}) {
561
571
  session: session.id,
562
572
  agent: session.config.agent,
563
573
  pid: child.pid,
564
- command: launcher.command,
574
+ command: spawnSpec.command,
575
+ args: spawnSpec.args,
576
+ legacyCommand: launcher.command,
565
577
  restart: session.restarts,
566
578
  });
567
579
 
@@ -1,5 +1,11 @@
1
1
  // hub/team/execution-mode.mjs — headless vs interactive execution mode selection
2
2
 
3
+ import { existsSync } from "node:fs";
4
+
5
+ import { whichCommand } from "../platform.mjs";
6
+
7
+ const WIN32_EXT_PRECEDENCE = [".cmd", ".exe", ".bat", ".ps1"];
8
+
3
9
  export const MODES = Object.freeze({
4
10
  HEADLESS: "headless",
5
11
  INTERACTIVE: "interactive",
@@ -10,6 +16,98 @@ function quotePrompt(prompt) {
10
16
  return JSON.stringify(typeof prompt === "string" ? prompt : "");
11
17
  }
12
18
 
19
+ function asPrompt(prompt) {
20
+ return typeof prompt === "string" ? prompt : "";
21
+ }
22
+
23
+ function pushFlag(args, flag, value) {
24
+ if (typeof value === "string" && value.length > 0) {
25
+ args.push(flag, value);
26
+ }
27
+ }
28
+
29
+ export function resolveCliExecutable(cli, opts = {}) {
30
+ const name = String(cli || "codex");
31
+ const resolveCommand = opts.resolveCommand || whichCommand;
32
+ const resolved = resolveCommand(name) || name;
33
+
34
+ // Windows: Node spawn({ shell: false }) calls CreateProcess directly and does NOT
35
+ // search PATHEXT. npm-installed CLIs (codex, gemini) live at `<npm>/codex` (Git Bash
36
+ // shell script) alongside `<npm>/codex.cmd` (Windows batch wrapper). whichCommand
37
+ // returns the extensionless path, which Windows cannot execute → ENOENT "The system
38
+ // cannot find the file specified." Append the correct extension when the resolved
39
+ // path has none.
40
+ const platform = opts.platform || process.platform;
41
+ if (platform === "win32" && resolved) {
42
+ const hasExt = /\.[^\\/.]+$/.test(resolved);
43
+ if (!hasExt) {
44
+ const existsFn = opts.existsSyncFn || existsSync;
45
+ for (const ext of WIN32_EXT_PRECEDENCE) {
46
+ const candidate = `${resolved}${ext}`;
47
+ try {
48
+ if (existsFn(candidate)) return candidate;
49
+ } catch {
50
+ // ignore stat failures, try next extension
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ return resolved;
57
+ }
58
+
59
+ export function buildSpawnSpecForMode(mode, opts = {}) {
60
+ const cli = opts.cli || "codex";
61
+ const prompt = asPrompt(opts.prompt);
62
+ const resolvedCommand = resolveCliExecutable(cli, opts);
63
+ const platform = opts.platform || process.platform;
64
+
65
+ // Node v20.12+ (CVE-2024-27980) rejects spawn of .cmd/.bat files with shell:false
66
+ // (EINVAL). npm-installed Windows wrappers (e.g. codex.cmd) hit this. Wrap via
67
+ // cmd.exe /c to keep shell:false while still launching the batch wrapper.
68
+ const needsCmdWrap =
69
+ platform === "win32" && /\.(cmd|bat)$/i.test(resolvedCommand);
70
+ const wrap = (args) =>
71
+ needsCmdWrap
72
+ ? { command: "cmd", args: ["/c", resolvedCommand, ...args] }
73
+ : { command: resolvedCommand, args };
74
+
75
+ if (cli === "gemini") {
76
+ const args = [];
77
+ pushFlag(args, "--model", opts.model);
78
+ args.push("--yolo", "--prompt", prompt, "--output-format", "text");
79
+ return { ...wrap(args), useExec: true, shell: false };
80
+ }
81
+
82
+ if (mode === MODES.INTERACTIVE || mode === MODES.AUTO) {
83
+ return { ...wrap([]), useExec: false, shell: false };
84
+ }
85
+
86
+ if (cli === "claude") {
87
+ const args = [];
88
+ pushFlag(args, "--model", opts.model);
89
+ args.push("-p", prompt);
90
+ return { ...wrap(args), useExec: true, shell: false };
91
+ }
92
+
93
+ const args = [];
94
+ pushFlag(args, "--profile", opts.profile);
95
+ args.push(
96
+ "exec",
97
+ "--dangerously-bypass-approvals-and-sandbox",
98
+ "--skip-git-repo-check",
99
+ "--color",
100
+ "never",
101
+ );
102
+ if (Array.isArray(opts.mcpServers)) {
103
+ for (const server of opts.mcpServers) {
104
+ args.push("-c", `mcp_servers.${server}.enabled=true`);
105
+ }
106
+ }
107
+ args.push(prompt);
108
+ return { ...wrap(args), useExec: true, shell: false };
109
+ }
110
+
13
111
  /**
14
112
  * @param {{
15
113
  * cli: "codex"|"gemini"|"claude",
@@ -12,10 +12,17 @@ const execFileAsync = promisify(nodeExecFile);
12
12
  const TARGET_PROCESS_NAMES = ["node", "python", "python3"];
13
13
  const SIGTERM_GRACE_MS = 5000;
14
14
 
15
- function forceKillPid(pid) {
16
- if (IS_WINDOWS) {
15
+ export function forceKillPid(
16
+ pid,
17
+ {
18
+ isWindows = IS_WINDOWS,
19
+ execFileSyncFn = execFileSync,
20
+ killFn = process.kill,
21
+ } = {},
22
+ ) {
23
+ if (isWindows) {
17
24
  try {
18
- execFileSync("taskkill", ["/F", "/PID", String(pid)], {
25
+ execFileSyncFn("taskkill", ["/F", "/T", "/PID", String(pid)], {
19
26
  stdio: "ignore",
20
27
  timeout: 5000,
21
28
  windowsHide: true,
@@ -23,7 +30,7 @@ function forceKillPid(pid) {
23
30
  return;
24
31
  } catch (taskkillError) {
25
32
  try {
26
- process.kill(pid);
33
+ killFn(pid);
27
34
  return;
28
35
  } catch {
29
36
  throw taskkillError;
@@ -31,7 +38,7 @@ function forceKillPid(pid) {
31
38
  }
32
39
  }
33
40
 
34
- process.kill(pid, "SIGKILL");
41
+ killFn(pid, "SIGKILL");
35
42
  }
36
43
 
37
44
  // cmdLine 패턴 기반 화이트리스트 (고아 후보에서 제외)
@@ -296,6 +303,12 @@ export async function findOrphanProcesses(opts = {}) {
296
303
  export function createProcessCleanup(opts = {}) {
297
304
  const execFileFn = opts.execFileFn ?? execFileAsync;
298
305
  const dryRun = opts.dryRun ?? false;
306
+ const isWindows = opts.isWindows ?? IS_WINDOWS;
307
+ const execFileSyncFn = opts.execFileSyncFn ?? execFileSync;
308
+ const killFn = opts.killFn ?? process.kill;
309
+ const sleepFn =
310
+ opts.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
311
+ const sigtermGraceMs = opts.sigtermGraceMs ?? SIGTERM_GRACE_MS;
299
312
 
300
313
  let lastOrphans = [];
301
314
 
@@ -332,16 +345,16 @@ export function createProcessCleanup(opts = {}) {
332
345
  lastOrphans.map(async (p) => {
333
346
  try {
334
347
  // SIGTERM
335
- process.kill(p.pid, "SIGTERM");
348
+ killFn(p.pid, "SIGTERM");
336
349
 
337
350
  // 5초 대기 후 살아있으면 강제 종료
338
- await new Promise((resolve) => setTimeout(resolve, SIGTERM_GRACE_MS));
351
+ await sleepFn(sigtermGraceMs);
339
352
 
340
353
  try {
341
354
  // 프로세스가 아직 살아있는지 확인 (signal 0)
342
- process.kill(p.pid, 0);
355
+ killFn(p.pid, 0);
343
356
  // 여전히 살아있음 → Windows는 taskkill/process.kill, 그 외는 SIGKILL
344
- forceKillPid(p.pid);
357
+ forceKillPid(p.pid, { isWindows, execFileSyncFn, killFn });
345
358
  } catch {
346
359
  // ESRCH: 이미 종료됨 — 정상
347
360
  }
@@ -131,14 +131,20 @@ export async function cmdSwarmRun(args, { json = false } = {}) {
131
131
  baseBranch: flags.baseBranch,
132
132
  });
133
133
 
134
- hyper.on("shardLaunched", ({ shard, sessionId, remote }) => {
134
+ hyper.on("shardLaunched", ({ shardName, sessionId, remote }) => {
135
135
  const tag = remote ? ` ${GRAY}(remote)${RESET}` : "";
136
- console.log(` ${GREEN}▸${RESET} launched: ${shard}${tag} [${sessionId}]`);
136
+ console.log(
137
+ ` ${GREEN}▸${RESET} launched: ${shardName}${tag} [${sessionId}]`,
138
+ );
139
+ });
140
+ hyper.on("shardCompleted", ({ shardName, sessionId, isRedundant }) => {
141
+ const tag = isRedundant ? ` ${GRAY}(redundant)${RESET}` : "";
142
+ console.log(` ${GREEN}✓${RESET} ${shardName}${tag} [${sessionId}]`);
137
143
  });
138
- hyper.on("shardCompleted", ({ shard, success, reason }) => {
139
- const mark = success ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
144
+ hyper.on("shardFailed", ({ shardName, failureMode, reason }) => {
140
145
  const reasonStr = reason ? ` ${GRAY}(${reason})${RESET}` : "";
141
- console.log(` ${mark} ${shard}${reasonStr}`);
146
+ const modeStr = failureMode ? ` ${GRAY}[${failureMode}]${RESET}` : "";
147
+ console.log(` ${RED}✗${RESET} ${shardName}${modeStr}${reasonStr}`);
142
148
  });
143
149
  hyper.on("warning", ({ type, ...rest }) => {
144
150
  console.error(