@triflux/core 10.50.2 → 10.51.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 (46) hide show
  1. package/hooks/agy-session-hook.mjs +11 -56
  2. package/hooks/codex-session-hook.mjs +11 -101
  3. package/hub/bridge.mjs +1 -79
  4. package/hub/cli-adapter-base.mjs +12 -90
  5. package/hub/codex-adapter.mjs +0 -1
  6. package/hub/codex-preflight.mjs +5 -28
  7. package/hub/index.mjs +1 -1
  8. package/hub/lib/known-errors.json +2 -2
  9. package/hub/lib/process-utils.mjs +0 -123
  10. package/hub/lib/spawn-trace.mjs +3 -2
  11. package/hub/lib/timeout-defaults.mjs +0 -1
  12. package/hub/role-contract.mjs +0 -2
  13. package/hud/cli-policy.mjs +0 -22
  14. package/hud/constants.mjs +11 -26
  15. package/hud/hud-qos-status.mjs +23 -51
  16. package/hud/providers/gemini.mjs +105 -20
  17. package/hud/renderers.mjs +50 -104
  18. package/hud/utils.mjs +2 -2
  19. package/package.json +1 -3
  20. package/scripts/lib/agent-route-policy.mjs +162 -14
  21. package/scripts/lib/claudemd-scanner.mjs +2 -199
  22. package/scripts/lib/cli-claude.mjs +0 -2
  23. package/scripts/lib/context.mjs +3 -19
  24. package/scripts/lib/env-probe.mjs +5 -64
  25. package/scripts/lib/gemini-profiles.mjs +2 -69
  26. package/scripts/lib/machine-profile.mjs +3 -17
  27. package/scripts/lib/mcp-filter.mjs +109 -53
  28. package/scripts/lib/mcp-health.mjs +2 -2
  29. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  30. package/scripts/lib/psmux-info.mjs +11 -59
  31. package/scripts/lib/session-presence.mjs +1 -1
  32. package/scripts/lib/skill-template.mjs +0 -245
  33. package/hub/dynamic-routing-engine.mjs +0 -511
  34. package/hub/lib/risk-tier.mjs +0 -53
  35. package/hub/lib/ssh-command.mjs +0 -231
  36. package/hub/lib/staged-file-detect.mjs +0 -43
  37. package/hub/lib/tfx-route-args.mjs +0 -353
  38. package/hub/routing-snapshot.mjs +0 -261
  39. package/mesh/index.mjs +0 -69
  40. package/mesh/mesh-budget.mjs +0 -137
  41. package/mesh/mesh-heartbeat.mjs +0 -100
  42. package/mesh/mesh-protocol.mjs +0 -96
  43. package/mesh/mesh-queue.mjs +0 -163
  44. package/mesh/mesh-registry.mjs +0 -78
  45. package/mesh/mesh-router.mjs +0 -77
  46. package/scripts/lib/dynamic-route-cli.mjs +0 -107
@@ -1,231 +0,0 @@
1
- // hub/lib/ssh-command.mjs — OS-aware SSH command builder
2
- // PowerShell 호스트에 bash 문법(2>/dev/null, &&, $())을 보내는 사고를 방지한다.
3
- // 모든 SSH 명령 생성 코드에서 이 유틸리티를 사용할 것.
4
-
5
- import { readHost, readHosts, resolveHost } from "./hosts-compat.mjs";
6
-
7
- /** hosts.json 캐시 (프로세스 수명 동안 유지) */
8
- let hostsCache = null;
9
- let hostsCacheRoot = null;
10
-
11
- /**
12
- * hosts.json을 로드하여 캐시한다.
13
- * @param {string} [repoRoot] — 프로젝트 루트 경로
14
- */
15
- function loadHostsCache(repoRoot) {
16
- const cacheKey = repoRoot || process.cwd();
17
- if (hostsCache && hostsCacheRoot === cacheKey) return;
18
- hostsCache = readHosts(repoRoot);
19
- hostsCacheRoot = cacheKey;
20
- }
21
-
22
- /**
23
- * hosts.json에서 호스트 OS를 조회한다.
24
- * @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
25
- * @param {string} [repoRoot] — 프로젝트 루트 경로
26
- * @returns {"windows"|"posix"} 호스트 OS 유형
27
- */
28
- export function detectHostOs(hostAlias, repoRoot) {
29
- loadHostsCache(repoRoot);
30
-
31
- const hostCfg = resolveHost(hostAlias, repoRoot)?.host;
32
- if (hostCfg?.os === "windows") return "windows";
33
- if (hostCfg?.os) return "posix";
34
-
35
- // IP 주소나 unknown alias → posix 기본값
36
- return "posix";
37
- }
38
-
39
- /**
40
- * OS에 맞는 셸 쿼팅을 적용한다.
41
- * - posix: 싱글쿼트 래핑 + 내부 싱글쿼트 이스케이프
42
- * - windows: PowerShell 싱글쿼트 래핑 + 내부 싱글쿼트 더블링
43
- * @param {string} value
44
- * @param {"windows"|"posix"} os
45
- * @returns {string}
46
- */
47
- export function shellQuoteForHost(value, os) {
48
- const s = String(value);
49
- if (os === "windows") {
50
- // PowerShell: 싱글쿼트 내부에서 '' 로 이스케이프
51
- return `'${s.replace(/'/g, "''")}'`;
52
- }
53
- // POSIX: 싱글쿼트 내부에서 '\'' 로 이스케이프
54
- return `'${s.replace(/'/g, "'\\''")}'`;
55
- }
56
-
57
- /**
58
- * OS에 맞는 stderr 억제 구문을 반환한다.
59
- * @param {"windows"|"posix"} os
60
- * @returns {string} "2>$null" 또는 "2>/dev/null"
61
- */
62
- export function suppressStderr(os) {
63
- return os === "windows" ? "2>$null" : "2>/dev/null";
64
- }
65
-
66
- /**
67
- * OS에 맞는 명령 연결자를 반환한다.
68
- * @param {"windows"|"posix"} os
69
- * @returns {string} "; " (PowerShell) 또는 " && " (bash)
70
- */
71
- export function commandJoin(os) {
72
- return os === "windows" ? "; " : " && ";
73
- }
74
-
75
- /**
76
- * OS에 맞는 null 디바이스 경로를 반환한다.
77
- * @param {"windows"|"posix"} os
78
- * @returns {string}
79
- */
80
- export function nullDevice(os) {
81
- return os === "windows" ? "$null" : "/dev/null";
82
- }
83
-
84
- /**
85
- * SSH execFileSync 인자 배열을 안전하게 구성한다.
86
- * psmux 명령처럼 원격 셸에서 실행할 명령을 배열로 받아
87
- * 적절한 쿼팅을 적용한 SSH 인자 배열을 반환한다.
88
- *
89
- * @param {string} host — SSH 호스트 (user@ip 또는 alias)
90
- * @param {string[]} remoteCmd — 원격에서 실행할 명령 + 인자
91
- * @param {object} [opts]
92
- * @param {number} [opts.connectTimeout=5] — SSH 연결 타임아웃(초)
93
- * @param {boolean} [opts.keepalive=true] — ServerAliveInterval/CountMax 활성화
94
- * @param {"windows"|"posix"} [opts.os] — 호스트 OS (미지정 시 자동 감지)
95
- * @param {string} [opts.repoRoot] — hosts.json 탐색 경로
96
- * @returns {string[]} execFileSync('ssh', returnValue) 형태로 사용
97
- */
98
- export function buildSshArgs(host, remoteCmd, opts = {}) {
99
- const connectTimeout = opts.connectTimeout ?? 5;
100
- const keepalive = opts.keepalive !== false;
101
- const hostAlias = host.includes("@") ? host.split("@").pop() : host;
102
- const os = opts.os ?? detectHostOs(hostAlias, opts.repoRoot);
103
-
104
- // 명령어(첫 요소)는 쿼팅하지 않고, 인자만 쿼팅
105
- const [cmd, ...args] = remoteCmd;
106
- const quotedArgs = args.map((a) => shellQuoteForHost(a, os));
107
- const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
108
-
109
- const sshArgs = [
110
- "-o",
111
- `ConnectTimeout=${connectTimeout}`,
112
- "-o",
113
- "BatchMode=yes",
114
- ];
115
-
116
- if (keepalive) {
117
- sshArgs.push("-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3");
118
- }
119
-
120
- sshArgs.push(host, remoteCmdStr);
121
- return sshArgs;
122
- }
123
-
124
- /**
125
- * 검증: 문자열에 호스트 OS와 맞지 않는 셸 문법이 포함되었는지 검사한다.
126
- * SSH 명령 생성 시 안전 게이트로 사용.
127
- *
128
- * @param {string} command — 원격 실행할 명령 문자열
129
- * @param {"windows"|"posix"} os — 대상 호스트 OS
130
- * @returns {{ safe: boolean, violations: string[] }}
131
- */
132
- export function validateCommandForOs(command, os) {
133
- const violations = [];
134
-
135
- if (os === "windows") {
136
- // PowerShell에서 해석 불가능한 bash 문법 검출
137
- if (/2>\/dev\/null/.test(command)) {
138
- violations.push("2>/dev/null → PowerShell에서는 2>$null 사용");
139
- }
140
- if (/\$\(/.test(command) && !/\$\(/.test(command) === false) {
141
- // $() bash substitution vs PowerShell $() — 문맥 의존
142
- // PowerShell도 $()를 사용하므로 여기선 경고만
143
- }
144
- if (/\s&&\s/.test(command)) {
145
- violations.push("&& → PowerShell에서는 ; 또는 -and 사용");
146
- }
147
- if (/\s\|\|\s/.test(command)) {
148
- violations.push("|| → PowerShell에서는 ; 또는 -or 사용");
149
- }
150
- if (/>\s*\/dev\/null/.test(command)) {
151
- violations.push(">/dev/null → PowerShell에서는 >$null 사용");
152
- }
153
- }
154
-
155
- return { safe: violations.length === 0, violations };
156
- }
157
-
158
- /**
159
- * hosts.json에서 호스트 전체 설정을 조회한다.
160
- * @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
161
- * @param {string} [repoRoot]
162
- * @returns {object|null}
163
- */
164
- export function getHostConfig(hostAlias, repoRoot) {
165
- loadHostsCache(repoRoot);
166
- return readHost(hostAlias, repoRoot);
167
- }
168
-
169
- /**
170
- * 별칭(alias)으로 호스트 키를 찾는다.
171
- * @param {string} alias — 호스트 키 또는 aliases 배열 내 값
172
- * @param {string} [repoRoot]
173
- * @returns {string|null} 호스트 키 (e.g. "ultra4") 또는 null
174
- */
175
- export function resolveHostAlias(alias, repoRoot) {
176
- loadHostsCache(repoRoot);
177
-
178
- return resolveHost(alias, repoRoot)?.name ?? null;
179
- }
180
-
181
- /**
182
- * 특정 capability를 보유한 호스트 목록을 반환한다.
183
- * 명시적 선택 UI에서 바로 리소스 정보를 보여줄 수 있도록 specs를 평탄화한다.
184
- * @param {string} capability — e.g. "codex", "claude", "gpu"
185
- * @param {string} [repoRoot]
186
- * @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
187
- */
188
- export function selectHostForCapability(capability, repoRoot) {
189
- loadHostsCache(repoRoot);
190
-
191
- const matches = [];
192
- for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
193
- if (cfg.capabilities?.includes(capability)) {
194
- matches.push({
195
- name,
196
- config: cfg,
197
- specs: {
198
- cores: cfg.specs?.cores,
199
- ram_gb: cfg.specs?.ram_gb,
200
- },
201
- });
202
- }
203
- }
204
- return matches;
205
- }
206
-
207
- /**
208
- * 특정 capability를 가진 호스트를 리소스 기준으로 정렬해 반환한다.
209
- * 자동 선택은 하지 않고, 명시적 선택 목록 정렬에만 사용한다.
210
- * @param {string} capability — e.g. "codex", "claude", "gpu"
211
- * @param {"cores"|"ram_gb"} sortBy
212
- * @param {string} [repoRoot]
213
- * @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
214
- */
215
- export function selectHostByResources(capability, sortBy, repoRoot) {
216
- if (sortBy !== "cores" && sortBy !== "ram_gb") {
217
- throw new TypeError('sortBy must be "cores" or "ram_gb"');
218
- }
219
-
220
- return selectHostForCapability(capability, repoRoot).sort((a, b) => {
221
- const aValue = a.specs?.[sortBy] ?? -Infinity;
222
- const bValue = b.specs?.[sortBy] ?? -Infinity;
223
- return bValue - aValue;
224
- });
225
- }
226
-
227
- /** hosts.json 캐시 초기화 (테스트용) */
228
- export function resetHostsCache() {
229
- hostsCache = null;
230
- hostsCacheRoot = null;
231
- }
@@ -1,43 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
-
3
- export function detectChangedFiles({
4
- cwd = process.cwd(),
5
- includeStaged = true,
6
- includeUnstaged = true,
7
- includeUntracked = true,
8
- } = {}) {
9
- const staged = includeStaged
10
- ? runGit(cwd, ["diff", "--cached", "--name-only"])
11
- : [];
12
- const unstaged = includeUnstaged ? runGit(cwd, ["diff", "--name-only"]) : [];
13
- const untracked = includeUntracked
14
- ? runGit(cwd, ["ls-files", "--others", "--exclude-standard"])
15
- : [];
16
- const files = [...new Set([...staged, ...unstaged, ...untracked])];
17
-
18
- return {
19
- stagedCount: staged.length,
20
- unstagedCount: unstaged.length,
21
- untrackedCount: untracked.length,
22
- totalChanged: files.length,
23
- files,
24
- };
25
- }
26
-
27
- export function hasCodeChange(opts) {
28
- return detectChangedFiles(opts).totalChanged > 0;
29
- }
30
-
31
- function runGit(cwd, args) {
32
- try {
33
- return execFileSync("git", args, {
34
- cwd,
35
- encoding: "utf8",
36
- stdio: ["ignore", "pipe", "ignore"],
37
- })
38
- .split(/\r?\n/)
39
- .filter(Boolean);
40
- } catch {
41
- return [];
42
- }
43
- }
@@ -1,353 +0,0 @@
1
- // hub/lib/tfx-route-args.mjs
2
- // Phase 3 Step B — tfx-auto / tfx-route 플래그 파서.
3
- // 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
4
- //
5
- // 입력: ARGUMENTS 문자열 또는 토큰 배열.
6
- // 출력: {cli, mode, parallel, retry, isolation, remote, lead, noClaudeNative,
7
- // maxIterations, task, warnings}.
8
- //
9
- // 기존 플래그 (Phase 2 v10.9.33+):
10
- // --cli {auto|codex|antigravity|claude}
11
- // --mode {quick|deep|consensus|live}
12
- // --parallel {1|N|swarm}
13
- // --retry {0|1|ralph|auto-escalate} (Phase 3 에서 ralph/auto-escalate 신규)
14
- // --isolation {none|worktree}
15
- // --remote {none|<host>}
16
- // --rounds <N> (--mode live 전용, 기본 4)
17
- //
18
- // Phase 3 신규:
19
- // --lead {claude|codex} (tfx-auto-codex 의미 흡수)
20
- // --no-claude-native (Claude native sub-agent 경로 disable)
21
- // --max-iterations <N> (ralph / auto-escalate 상한, 0=unlimited)
22
-
23
- import { hasCodeChange } from "./staged-file-detect.mjs";
24
-
25
- export const DEFAULT_OPTIONS = Object.freeze({
26
- cli: "auto",
27
- mode: "quick",
28
- parallel: "1",
29
- retry: "1",
30
- isolation: "none",
31
- remote: "none",
32
- lead: "claude",
33
- noClaudeNative: false,
34
- maxIterations: 0,
35
- rounds: 4,
36
- });
37
-
38
- const VALID_VALUES = Object.freeze({
39
- cli: ["auto", "codex", "antigravity", "claude"],
40
- mode: ["quick", "deep", "consensus", "live"],
41
- retry: ["0", "1", "ralph", "auto-escalate"],
42
- isolation: ["none", "worktree"],
43
- lead: ["claude", "codex"],
44
- });
45
-
46
- const VALUE_FLAGS = new Set([
47
- "--cli",
48
- "--mode",
49
- "--parallel",
50
- "--retry",
51
- "--isolation",
52
- "--remote",
53
- "--lead",
54
- "--max-iterations",
55
- "--rounds",
56
- ]);
57
-
58
- const BOOL_FLAGS = new Set(["--no-claude-native"]);
59
-
60
- export function parseArgs(input) {
61
- const tokens = Array.isArray(input)
62
- ? input.slice()
63
- : tokenize(String(input || ""));
64
- const opts = { ...DEFAULT_OPTIONS };
65
- const warnings = [];
66
- const taskTokens = [];
67
- const providedFlags = new Set();
68
-
69
- let i = 0;
70
- while (i < tokens.length) {
71
- const raw = tokens[i];
72
-
73
- if (BOOL_FLAGS.has(raw)) {
74
- providedFlags.add(raw);
75
- applyBool(opts, raw);
76
- i += 1;
77
- continue;
78
- }
79
-
80
- // --flag=value 지원
81
- const eqIdx = raw.indexOf("=");
82
- let flag = raw;
83
- let value = null;
84
- if (eqIdx > 0 && raw.startsWith("--")) {
85
- flag = raw.slice(0, eqIdx);
86
- value = raw.slice(eqIdx + 1);
87
- }
88
-
89
- if (VALUE_FLAGS.has(flag)) {
90
- providedFlags.add(flag);
91
- if (value === null) {
92
- const next = tokens[i + 1];
93
- if (next === undefined || next.startsWith("--")) {
94
- warnings.push(`${flag} needs a value`);
95
- i += 1;
96
- continue;
97
- }
98
- value = next;
99
- i += 2;
100
- } else {
101
- i += 1;
102
- }
103
- applyValue(opts, flag, value, warnings);
104
- continue;
105
- }
106
-
107
- if (raw.startsWith("--")) {
108
- warnings.push(`unknown flag: ${raw}`);
109
- i += 1;
110
- continue;
111
- }
112
-
113
- taskTokens.push(raw);
114
- i += 1;
115
- }
116
-
117
- validate(opts, warnings, providedFlags);
118
-
119
- return {
120
- ...opts,
121
- task: taskTokens.join(" ").trim(),
122
- warnings,
123
- };
124
- }
125
-
126
- function tokenize(str) {
127
- const tokens = [];
128
- let cur = "";
129
- let quote = null;
130
- for (const ch of str) {
131
- if (quote) {
132
- if (ch === quote) {
133
- quote = null;
134
- } else {
135
- cur += ch;
136
- }
137
- continue;
138
- }
139
- if (ch === '"' || ch === "'") {
140
- quote = ch;
141
- continue;
142
- }
143
- if (/\s/.test(ch)) {
144
- if (cur) {
145
- tokens.push(cur);
146
- cur = "";
147
- }
148
- continue;
149
- }
150
- cur += ch;
151
- }
152
- if (cur) tokens.push(cur);
153
- return tokens;
154
- }
155
-
156
- function applyBool(opts, flag) {
157
- switch (flag) {
158
- case "--no-claude-native":
159
- opts.noClaudeNative = true;
160
- break;
161
- }
162
- }
163
-
164
- function applyValue(opts, flag, value, warnings) {
165
- switch (flag) {
166
- case "--cli":
167
- if (value === "gemini") {
168
- opts.cli = "antigravity";
169
- warnings.push(
170
- "--cli gemini is deprecated; using --cli antigravity instead",
171
- );
172
- } else {
173
- opts.cli = value;
174
- }
175
- break;
176
- case "--mode":
177
- opts.mode = value;
178
- break;
179
- case "--parallel":
180
- opts.parallel = value;
181
- break;
182
- case "--retry":
183
- opts.retry = value;
184
- break;
185
- case "--isolation":
186
- opts.isolation = value;
187
- break;
188
- case "--remote":
189
- opts.remote = value;
190
- break;
191
- case "--lead":
192
- opts.lead = value;
193
- break;
194
- case "--max-iterations": {
195
- const n = Number.parseInt(value, 10);
196
- if (Number.isNaN(n) || n < 0) {
197
- warnings.push(
198
- `invalid --max-iterations=${value}, expected non-negative integer (0=unlimited)`,
199
- );
200
- } else {
201
- opts.maxIterations = n;
202
- }
203
- break;
204
- }
205
- case "--rounds": {
206
- const n = Number(value);
207
- if (!Number.isInteger(n) || n <= 0) {
208
- warnings.push(`invalid --rounds=${value}, expected positive integer`);
209
- } else {
210
- opts.rounds = n;
211
- }
212
- break;
213
- }
214
- }
215
- }
216
-
217
- function validate(opts, warnings, providedFlags = new Set()) {
218
- for (const key of Object.keys(VALID_VALUES)) {
219
- if (!VALID_VALUES[key].includes(opts[key])) {
220
- warnings.push(
221
- `invalid --${key}=${opts[key]}, expected one of ${VALID_VALUES[key].join("|")}`,
222
- );
223
- }
224
- }
225
- if (
226
- opts.parallel !== "1" &&
227
- opts.parallel !== "swarm" &&
228
- !/^\d+$/.test(opts.parallel)
229
- ) {
230
- warnings.push(`invalid --parallel=${opts.parallel}, expected 1|N|swarm`);
231
- }
232
-
233
- if (opts.mode === "live") {
234
- const ignored = [
235
- ["--parallel", "parallel", DEFAULT_OPTIONS.parallel],
236
- ["--isolation", "isolation", DEFAULT_OPTIONS.isolation],
237
- ["--remote", "remote", DEFAULT_OPTIONS.remote],
238
- ];
239
- for (const [flag, key, fallback] of ignored) {
240
- if (providedFlags.has(flag)) {
241
- warnings.push(`${flag} ignored with --mode live`);
242
- opts[key] = fallback;
243
- }
244
- }
245
- if (providedFlags.has("--retry") && opts.retry === "ralph") {
246
- warnings.push("--retry ralph ignored with --mode live");
247
- opts.retry = DEFAULT_OPTIONS.retry;
248
- }
249
- } else if (providedFlags.has("--rounds")) {
250
- warnings.push("--rounds ignored unless --mode live");
251
- opts.rounds = DEFAULT_OPTIONS.rounds;
252
- }
253
-
254
- const parallelOne = opts.parallel === "1" || opts.parallel === 1;
255
- if (parallelOne && opts.isolation === "worktree") {
256
- warnings.push(
257
- "--isolation worktree requires --parallel >=2 or swarm; forcing isolation=none",
258
- );
259
- opts.isolation = "none";
260
- }
261
- if (opts.remote !== "none" && opts.parallel !== "swarm") {
262
- warnings.push(
263
- `--remote ${opts.remote} ignored (requires --parallel swarm)`,
264
- );
265
- }
266
- }
267
-
268
- export { VALID_VALUES };
269
-
270
- /**
271
- * Decide dispatch mode based on parsed args and current git state.
272
- *
273
- * Issue #281: auto router code changes should use isolated swarm dispatch for
274
- * multi-task work unless the user explicitly selected local parallel mode.
275
- *
276
- * @param {object} args
277
- * @param {object} [opts]
278
- * @param {string} [opts.cwd]
279
- * @param {Function} [opts.detector]
280
- * @returns {{
281
- * mode: "single" | "multi" | "swarm",
282
- * escalated: boolean,
283
- * warning: string | null,
284
- * reason: string,
285
- * }}
286
- */
287
- export function decideDispatchMode(
288
- args = {},
289
- { cwd = process.cwd(), detector = hasCodeChange } = {},
290
- ) {
291
- const tasksCount = countTasks(args);
292
- const parallel = normalizeParallel(args.parallel);
293
- const hasUserParallel = parallel !== null && parallel !== "1";
294
- const hasUserSwarm = parallel === "swarm" || args.isolation === "worktree";
295
- const codeChanged = Boolean(detector({ cwd }));
296
-
297
- if (hasUserSwarm) {
298
- return {
299
- mode: "swarm",
300
- escalated: false,
301
- warning: null,
302
- reason: "user-explicit-swarm",
303
- };
304
- }
305
-
306
- if (hasUserParallel && codeChanged) {
307
- return {
308
- mode: "multi",
309
- escalated: false,
310
- warning: `[tfx-auto] WARNING: 코드 변경 감지 (cwd race 위험). --parallel swarm --isolation worktree 권장. 사용자 명시 --parallel=${args.parallel} 존중하여 multi 로 진행.`,
311
- reason: "user-explicit-multi-with-code-change",
312
- };
313
- }
314
-
315
- if (tasksCount >= 2 && codeChanged) {
316
- return {
317
- mode: "swarm",
318
- escalated: true,
319
- warning: `[tfx-auto] 코드 변경 ${tasksCount}건 태스크 + dirty cwd 감지. swarm dispatch 자동 escalate (--parallel swarm --isolation worktree). Issue #281.`,
320
- reason: "auto-escalate-code-change",
321
- };
322
- }
323
-
324
- if (tasksCount >= 2 || hasUserParallel) {
325
- return {
326
- mode: "multi",
327
- escalated: false,
328
- warning: null,
329
- reason: "multi-no-code-change",
330
- };
331
- }
332
-
333
- return {
334
- mode: "single",
335
- escalated: false,
336
- warning: null,
337
- reason: "single-task",
338
- };
339
- }
340
-
341
- function countTasks(args) {
342
- if (Array.isArray(args.tasks)) return args.tasks.length;
343
- if (typeof args.tasks === "number") return args.tasks;
344
- if (typeof args.tasks === "string") return args.tasks.trim() ? 1 : 0;
345
- if (typeof args.task === "string") return args.task.trim() ? 1 : 0;
346
- return args.tasks ?? 0;
347
- }
348
-
349
- function normalizeParallel(value) {
350
- if (value == null) return null;
351
- if (value === "swarm") return value;
352
- return String(value);
353
- }