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.
- package/.claude-plugin/marketplace.json +2 -2
- package/hooks/hook-orchestrator.mjs +155 -24
- package/hub/team/process-cleanup.mjs +27 -5
- package/hub/team/swarm-hypervisor.mjs +215 -82
- package/hub/team/tui-viewer.mjs +3 -0
- package/package.json +3 -3
- package/scripts/__tests__/keyword-detector.test.mjs +4 -4
|
@@ -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.
|
|
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.
|
|
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 {
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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초 대기 후 살아있으면
|
|
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
|
-
|
|
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
|
|
520
|
-
|
|
521
|
-
baseBranch,
|
|
522
|
-
rootDir: workdir,
|
|
523
|
-
});
|
|
582
|
+
const preIntegrationFailures = [...failures.keys()];
|
|
583
|
+
let integrationBranch = null;
|
|
524
584
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
585
|
+
try {
|
|
586
|
+
({ integrationBranch } = await prepareIntegrationBranchImpl({
|
|
587
|
+
runId,
|
|
588
|
+
baseBranch,
|
|
589
|
+
rootDir: workdir,
|
|
590
|
+
}));
|
|
530
591
|
|
|
531
|
-
const
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
workdir
|
|
541
|
-
shard.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
+
changedFiles,
|
|
673
|
+
branchName: shardBranch,
|
|
674
|
+
worktreePath: worker.worktreePath || null,
|
|
595
675
|
integrationBranch,
|
|
596
|
-
|
|
676
|
+
headCommit: rebaseResult.headCommit,
|
|
677
|
+
completedAt: Date.now(),
|
|
597
678
|
});
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
worktreePath: worker.worktreePath || null,
|
|
701
|
+
eventLog.append("integration_complete", {
|
|
702
|
+
integrated,
|
|
703
|
+
failed,
|
|
704
|
+
integrationFailures,
|
|
608
705
|
integrationBranch,
|
|
609
|
-
|
|
610
|
-
|
|
706
|
+
skipped,
|
|
707
|
+
error: err.message,
|
|
611
708
|
});
|
|
612
|
-
integrated.push(shardName);
|
|
613
709
|
|
|
614
|
-
|
|
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
|
|
732
|
+
failed,
|
|
733
|
+
integrationFailures,
|
|
620
734
|
integrationBranch,
|
|
621
|
-
skipped
|
|
622
|
-
(n) => !integrationFailures.includes(n),
|
|
623
|
-
),
|
|
735
|
+
skipped,
|
|
624
736
|
});
|
|
625
737
|
|
|
626
|
-
if (
|
|
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
|
-
|
|
747
|
+
const payload = settleIntegrationPromise({
|
|
636
748
|
integrated,
|
|
637
|
-
failed
|
|
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
|
},
|
package/hub/team/tui-viewer.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "10.9.
|
|
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 --
|
|
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.
|
|
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.
|
|
112
|
-
assert.
|
|
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,
|
|
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);
|