triflux 10.9.18 → 10.9.19

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "triflux",
11
11
  "description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
12
- "version": "10.9.18",
12
+ "version": "10.9.19",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "10.9.18"
33
+ "version": "10.9.19"
34
34
  }
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  // hub/team/process-cleanup.mjs — 고아 node/python 프로세스 감지 및 정리
2
2
  // Windows: Get-CimInstance Win32_Process로 parent PID + cmdLine 접근
3
3
  // Unix: ps aux 파싱
4
- import { execFile as nodeExecFile } from "node:child_process";
4
+ import { execFile as nodeExecFile, execFileSync } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { IS_WINDOWS } from "../platform.mjs";
7
7
 
@@ -12,6 +12,28 @@ 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) {
17
+ try {
18
+ execFileSync("taskkill", ["/F", "/PID", String(pid)], {
19
+ stdio: "ignore",
20
+ timeout: 5000,
21
+ windowsHide: true,
22
+ });
23
+ return;
24
+ } catch (taskkillError) {
25
+ try {
26
+ process.kill(pid);
27
+ return;
28
+ } catch {
29
+ throw taskkillError;
30
+ }
31
+ }
32
+ }
33
+
34
+ process.kill(pid, "SIGKILL");
35
+ }
36
+
15
37
  // cmdLine 패턴 기반 화이트리스트 (고아 후보에서 제외)
16
38
  const WHITELIST_CMDLINE = [/oh-my-claudecode/i, /triflux[\\/]hub[\\/]s/i];
17
39
 
@@ -293,7 +315,7 @@ export function createProcessCleanup(opts = {}) {
293
315
  /**
294
316
  * 마지막 scan 결과의 프로세스를 kill한다.
295
317
  * dryRun=true이면 kill 없이 목록만 반환한다.
296
- * SIGTERM → 5s 대기 → SIGKILL 순서.
318
+ * SIGTERM → 5s 대기 → 강제 종료(taskkill/SIGKILL) 순서.
297
319
  * @returns {Promise<Array<{pid,name,killed,error}>>}
298
320
  */
299
321
  async function kill() {
@@ -312,14 +334,14 @@ export function createProcessCleanup(opts = {}) {
312
334
  // SIGTERM
313
335
  process.kill(p.pid, "SIGTERM");
314
336
 
315
- // 5초 대기 후 살아있으면 SIGKILL
337
+ // 5초 대기 후 살아있으면 강제 종료
316
338
  await new Promise((resolve) => setTimeout(resolve, SIGTERM_GRACE_MS));
317
339
 
318
340
  try {
319
341
  // 프로세스가 아직 살아있는지 확인 (signal 0)
320
342
  process.kill(p.pid, 0);
321
- // 여전히 살아있음 → SIGKILL
322
- process.kill(p.pid, "SIGKILL");
343
+ // 여전히 살아있음 → Windows는 taskkill/process.kill, 그 외는 SIGKILL
344
+ forceKillPid(p.pid);
323
345
  } catch {
324
346
  // ESRCH: 이미 종료됨 — 정상
325
347
  }
@@ -172,6 +172,23 @@ export function createSwarmHypervisor(opts) {
172
172
 
173
173
  const results = new Map(); // shardName → validated result
174
174
  const failures = new Map(); // shardName → failure info
175
+ let integrationResult = null;
176
+ let resolveIntegrationPromise = null;
177
+ let integrationPromiseState = {
178
+ state: "idle",
179
+ startedAt: null,
180
+ settledAt: null,
181
+ partial: false,
182
+ integrated: [],
183
+ failed: [],
184
+ integrationFailures: [],
185
+ skipped: [],
186
+ integrationBranch: null,
187
+ error: null,
188
+ };
189
+ const integrationPromise = new Promise((resolve) => {
190
+ resolveIntegrationPromise = resolve;
191
+ });
175
192
 
176
193
  if (meshRegistryFallback) {
177
194
  eventLog.append("mesh_registry_fallback", { reason: meshRegistryFallback });
@@ -186,6 +203,52 @@ export function createSwarmHypervisor(opts) {
186
203
  emitter.emit("stateChange", { from: prev, to: next, reason });
187
204
  }
188
205
 
206
+ function markIntegrationPromisePending() {
207
+ if (integrationPromiseState.state !== "idle") return;
208
+ integrationPromiseState = {
209
+ ...integrationPromiseState,
210
+ state: "pending",
211
+ startedAt: Date.now(),
212
+ };
213
+ }
214
+
215
+ function settleIntegrationPromise(payload) {
216
+ if (integrationPromiseState.state === "fulfilled" && integrationResult) {
217
+ return integrationResult;
218
+ }
219
+
220
+ integrationResult = Object.freeze({
221
+ integrated: Object.freeze([...(payload.integrated || [])]),
222
+ failed: Object.freeze([...(payload.failed || [])]),
223
+ integrationFailures: Object.freeze([
224
+ ...(payload.integrationFailures || []),
225
+ ]),
226
+ skipped: Object.freeze([...(payload.skipped || [])]),
227
+ integrationBranch: payload.integrationBranch || null,
228
+ results: Object.freeze([...(payload.results || [])]),
229
+ partial:
230
+ payload.partial ??
231
+ (Array.isArray(payload.failed) && payload.failed.length > 0),
232
+ error: payload.error || null,
233
+ });
234
+
235
+ integrationPromiseState = {
236
+ state: "fulfilled",
237
+ startedAt: integrationPromiseState.startedAt ?? Date.now(),
238
+ settledAt: Date.now(),
239
+ partial: integrationResult.partial,
240
+ integrated: [...integrationResult.integrated],
241
+ failed: [...integrationResult.failed],
242
+ integrationFailures: [...integrationResult.integrationFailures],
243
+ skipped: [...integrationResult.skipped],
244
+ integrationBranch: integrationResult.integrationBranch,
245
+ error: integrationResult.error,
246
+ };
247
+
248
+ resolveIntegrationPromise?.(integrationResult);
249
+ return integrationResult;
250
+ }
251
+
189
252
  // ── Worker lifecycle ────────────────────────────────────────
190
253
 
191
254
  function buildSessionConfig(shard) {
@@ -516,114 +579,163 @@ export function createSwarmHypervisor(opts) {
516
579
 
517
580
  const integrated = [];
518
581
  const integrationFailures = [];
519
- const { integrationBranch } = await prepareIntegrationBranchImpl({
520
- runId,
521
- baseBranch,
522
- rootDir: workdir,
523
- });
582
+ const preIntegrationFailures = [...failures.keys()];
583
+ let integrationBranch = null;
524
584
 
525
- for (const shardName of plan.mergeOrder) {
526
- if (failures.has(shardName)) {
527
- eventLog.append("skip_failed_shard", { shard: shardName });
528
- continue;
529
- }
585
+ try {
586
+ ({ integrationBranch } = await prepareIntegrationBranchImpl({
587
+ runId,
588
+ baseBranch,
589
+ rootDir: workdir,
590
+ }));
530
591
 
531
- const worker = workers.get(shardName);
532
- if (!worker) continue;
592
+ for (const shardName of plan.mergeOrder) {
593
+ if (failures.has(shardName)) {
594
+ eventLog.append("skip_failed_shard", { shard: shardName });
595
+ continue;
596
+ }
533
597
 
534
- // Fetch remote shard branch to local (push-blocked hosts like Ultra4)
535
- const shard = plan.shards.find((s) => s.name === shardName);
536
- if (shard?.host && shard._remoteEnv) {
537
- const hostConfig = getHostConfig(shard.host, workdir);
538
- const sshUser = hostConfig?.ssh_user || shard.host;
539
- const remoteRepoPath = resolveRemoteDir(
540
- workdir,
541
- shard._remoteEnv,
542
- );
543
- const fetchResult = await fetchRemoteShard({
544
- host: shard.host,
545
- sshUser,
546
- remoteRepoPath,
547
- branchName: worker.branchName || `swarm/${runId}/${shardName}`,
548
- rootDir: workdir,
549
- });
598
+ const worker = workers.get(shardName);
599
+ if (!worker) continue;
600
+
601
+ // Fetch remote shard branch to local (push-blocked hosts like Ultra4)
602
+ const shard = plan.shards.find((s) => s.name === shardName);
603
+ if (shard?.host && shard._remoteEnv) {
604
+ const hostConfig = getHostConfig(shard.host, workdir);
605
+ const sshUser = hostConfig?.ssh_user || shard.host;
606
+ const remoteRepoPath = resolveRemoteDir(
607
+ workdir,
608
+ shard._remoteEnv,
609
+ );
610
+ const fetchResult = await fetchRemoteShard({
611
+ host: shard.host,
612
+ sshUser,
613
+ remoteRepoPath,
614
+ branchName: worker.branchName || `swarm/${runId}/${shardName}`,
615
+ rootDir: workdir,
616
+ });
550
617
 
551
- if (!fetchResult.ok) {
552
- eventLog.append("remote_fetch_failed", {
618
+ if (!fetchResult.ok) {
619
+ eventLog.append("remote_fetch_failed", {
620
+ shard: shardName,
621
+ error: fetchResult.error,
622
+ });
623
+ await maybeCleanupWorktree(shardName, worker, shard);
624
+ integrationFailures.push(shardName);
625
+ continue;
626
+ }
627
+ eventLog.append("remote_fetch_ok", {
553
628
  shard: shardName,
554
- error: fetchResult.error,
629
+ headCommit: fetchResult.headCommit,
630
+ });
631
+ }
632
+
633
+ // Read shard output log for changed files
634
+ const changedFiles = detectChangedFiles(shardName, worker);
635
+
636
+ // Validate against lease map
637
+ const validation = validateResult(shardName, changedFiles);
638
+ if (!validation.ok) {
639
+ failures.set(shardName, {
640
+ mode: FAILURE_MODES.F4_LEASE_VIOLATION,
641
+ violations: validation.violations,
642
+ });
643
+ eventLog.append("lease_violation_revert", {
644
+ shard: shardName,
645
+ violations: validation.violations,
555
646
  });
556
647
  await maybeCleanupWorktree(shardName, worker, shard);
557
648
  integrationFailures.push(shardName);
558
649
  continue;
559
650
  }
560
- eventLog.append("remote_fetch_ok", {
561
- shard: shardName,
562
- headCommit: fetchResult.headCommit,
563
- });
564
- }
565
-
566
- // Read shard output log for changed files
567
- const changedFiles = detectChangedFiles(shardName, worker);
568
651
 
569
- // Validate against lease map
570
- const validation = validateResult(shardName, changedFiles);
571
- if (!validation.ok) {
572
- failures.set(shardName, {
573
- mode: FAILURE_MODES.F4_LEASE_VIOLATION,
574
- violations: validation.violations,
575
- });
576
- eventLog.append("lease_violation_revert", {
577
- shard: shardName,
578
- violations: validation.violations,
652
+ const shardBranch = worker.branchName || `swarm/${runId}/${shardName}`;
653
+ const rebaseResult = await rebaseShardOntoIntegrationImpl({
654
+ shardBranch,
655
+ integrationBranch,
656
+ rootDir: workdir,
579
657
  });
580
- await maybeCleanupWorktree(shardName, worker, shard);
581
- integrationFailures.push(shardName);
582
- continue;
583
- }
658
+ if (!rebaseResult.ok) {
659
+ eventLog.append("integration_rebase_failed", {
660
+ shard: shardName,
661
+ shardBranch,
662
+ integrationBranch,
663
+ error: rebaseResult.error,
664
+ });
665
+ await maybeCleanupWorktree(shardName, worker, shard);
666
+ integrationFailures.push(shardName);
667
+ continue;
668
+ }
584
669
 
585
- const shardBranch = worker.branchName || `swarm/${runId}/${shardName}`;
586
- const rebaseResult = await rebaseShardOntoIntegrationImpl({
587
- shardBranch,
588
- integrationBranch,
589
- rootDir: workdir,
590
- });
591
- if (!rebaseResult.ok) {
592
- eventLog.append("integration_rebase_failed", {
670
+ results.set(shardName, {
593
671
  shard: shardName,
594
- shardBranch,
672
+ changedFiles,
673
+ branchName: shardBranch,
674
+ worktreePath: worker.worktreePath || null,
595
675
  integrationBranch,
596
- error: rebaseResult.error,
676
+ headCommit: rebaseResult.headCommit,
677
+ completedAt: Date.now(),
597
678
  });
598
- await maybeCleanupWorktree(shardName, worker, shard);
599
- integrationFailures.push(shardName);
600
- continue;
679
+ integrated.push(shardName);
680
+
681
+ await maybeCleanupWorktree(shardName, worker, shard, shardBranch);
601
682
  }
683
+ } catch (err) {
684
+ const unresolved = plan.mergeOrder.filter(
685
+ (name) =>
686
+ !integrated.includes(name) &&
687
+ !preIntegrationFailures.includes(name) &&
688
+ !integrationFailures.includes(name),
689
+ );
690
+ const failed = [
691
+ ...new Set([
692
+ ...preIntegrationFailures,
693
+ ...integrationFailures,
694
+ ...unresolved,
695
+ ]),
696
+ ];
697
+ const skipped = preIntegrationFailures.filter(
698
+ (name) => !integrationFailures.includes(name),
699
+ );
602
700
 
603
- results.set(shardName, {
604
- shard: shardName,
605
- changedFiles,
606
- branchName: shardBranch,
607
- worktreePath: worker.worktreePath || null,
701
+ eventLog.append("integration_complete", {
702
+ integrated,
703
+ failed,
704
+ integrationFailures,
608
705
  integrationBranch,
609
- headCommit: rebaseResult.headCommit,
610
- completedAt: Date.now(),
706
+ skipped,
707
+ error: err.message,
611
708
  });
612
- integrated.push(shardName);
613
709
 
614
- await maybeCleanupWorktree(shardName, worker, shard, shardBranch);
710
+ setState(SWARM_STATES.FAILED, "integration_error");
711
+ const payload = settleIntegrationPromise({
712
+ integrated,
713
+ failed,
714
+ integrationFailures,
715
+ integrationBranch,
716
+ skipped,
717
+ results: [...results.values()],
718
+ partial: true,
719
+ error: err.message,
720
+ });
721
+ emitter.emit("integrationComplete", payload);
722
+ return payload;
615
723
  }
616
724
 
725
+ const skipped = preIntegrationFailures.filter(
726
+ (name) => !integrationFailures.includes(name),
727
+ );
728
+ const failed = [...new Set([...skipped, ...integrationFailures])];
729
+
617
730
  eventLog.append("integration_complete", {
618
731
  integrated,
619
- failed: integrationFailures,
732
+ failed,
733
+ integrationFailures,
620
734
  integrationBranch,
621
- skipped: [...failures.keys()].filter(
622
- (n) => !integrationFailures.includes(n),
623
- ),
735
+ skipped,
624
736
  });
625
737
 
626
- if (integrationFailures.length > 0 && integrated.length === 0) {
738
+ if (failed.length > 0 && integrated.length === 0) {
627
739
  setState(SWARM_STATES.FAILED, "all_shards_failed_integration");
628
740
  } else {
629
741
  setState(
@@ -632,12 +744,17 @@ export function createSwarmHypervisor(opts) {
632
744
  );
633
745
  }
634
746
 
635
- emitter.emit("integrationComplete", {
747
+ const payload = settleIntegrationPromise({
636
748
  integrated,
637
- failed: integrationFailures,
749
+ failed,
750
+ integrationFailures,
638
751
  integrationBranch,
752
+ skipped,
639
753
  results: [...results.values()],
754
+ partial: failed.length > 0,
640
755
  });
756
+ emitter.emit("integrationComplete", payload);
757
+ return payload;
641
758
  }
642
759
 
643
760
  async function maybeCleanupWorktree(
@@ -758,6 +875,18 @@ export function createSwarmHypervisor(opts) {
758
875
  mergeOrder: plan?.mergeOrder || [],
759
876
  criticalShards: plan?.criticalShards || [],
760
877
  locks: lockManager?.snapshot() || [],
878
+ integrationPromise: Object.freeze({
879
+ state: integrationPromiseState.state,
880
+ startedAt: integrationPromiseState.startedAt,
881
+ settledAt: integrationPromiseState.settledAt,
882
+ partial: integrationPromiseState.partial,
883
+ integrated: [...integrationPromiseState.integrated],
884
+ failed: [...integrationPromiseState.failed],
885
+ integrationFailures: [...integrationPromiseState.integrationFailures],
886
+ skipped: [...integrationPromiseState.skipped],
887
+ integrationBranch: integrationPromiseState.integrationBranch,
888
+ error: integrationPromiseState.error,
889
+ }),
761
890
  });
762
891
  }
763
892
 
@@ -806,6 +935,7 @@ export function createSwarmHypervisor(opts) {
806
935
  }
807
936
 
808
937
  plan = swarmPlan;
938
+ markIntegrationPromisePending();
809
939
 
810
940
  // Hub alive 확인 — 죽어있으면 재시작
811
941
  if (ensureHubAliveFn) {
@@ -914,6 +1044,9 @@ export function createSwarmHypervisor(opts) {
914
1044
  launch,
915
1045
  shutdown,
916
1046
  getStatus,
1047
+ integrationComplete() {
1048
+ return integrationPromise;
1049
+ },
917
1050
  getMeshRegistry() {
918
1051
  return sharedRegistry;
919
1052
  },
@@ -74,6 +74,9 @@ const INTERNAL_PATTERNS = [
74
74
  /\.err\b/,
75
75
  /completion[-_]token/i,
76
76
  /^---\s*HANDOFF\s*---$/i,
77
+ /Get-Content/,
78
+ /-color\s+never/,
79
+ /AppData[\\/]Local[\\/]Temp[\\/]tfx-headless/,
77
80
  ];
78
81
 
79
82
  function isInternalLine(line) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.18",
3
+ "version": "10.9.19",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,8 +42,8 @@
42
42
  "setup": "node scripts/setup.mjs",
43
43
  "preinstall": "node scripts/preinstall.mjs",
44
44
  "postinstall": "node scripts/setup.mjs",
45
- "lint": "biome check .",
46
- "lint:fix": "biome check --fix .",
45
+ "lint": "biome check bin config hooks hub hud mesh scripts tests .claude-plugin .github package.json package-lock.json biome.json",
46
+ "lint:fix": "biome check --write bin config hooks hub hud mesh scripts tests .claude-plugin .github package.json package-lock.json biome.json",
47
47
  "health": "npm test && npm run lint",
48
48
  "test": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 \"tests/**/*.test.mjs\" \"scripts/__tests__/**/*.test.mjs\"",
49
49
  "test:unit": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/unit/**/*.test.mjs",
@@ -34,7 +34,7 @@ const { extractPrompt, sanitizeForKeywordDetection } = detectorModule;
34
34
 
35
35
  function loadCompiledRules() {
36
36
  const rules = loadRules(rulesPath);
37
- assert.equal(rules.length, 32);
37
+ assert.ok(rules.length >= 32);
38
38
  return compileRules(rules);
39
39
  }
40
40
 
@@ -108,8 +108,8 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
108
108
 
109
109
  test("loadRules: 유효한 JSON 로드", () => {
110
110
  const rules = loadRules(rulesPath);
111
- assert.equal(rules.length, 32);
112
- assert.equal(rules.filter((rule) => rule.skill).length, 18);
111
+ assert.ok(rules.length >= 32);
112
+ assert.ok(rules.filter((rule) => rule.skill).length >= 18);
113
113
  assert.equal(rules.filter((rule) => rule.mcp_route).length, 10);
114
114
  });
115
115
 
@@ -130,7 +130,7 @@ test("loadRules: 잘못된 파일 처리", () => {
130
130
  test("compileRules: 정규식 컴파일 성공", () => {
131
131
  const rules = loadRules(rulesPath);
132
132
  const compiled = compileRules(rules);
133
- assert.equal(compiled.length, 32);
133
+ assert.equal(compiled.length, rules.length);
134
134
  for (const rule of compiled) {
135
135
  assert.ok(Array.isArray(rule.compiledPatterns));
136
136
  assert.ok(rule.compiledPatterns.length > 0);