@triflux/remote 10.87.0 → 10.88.1

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.
@@ -1,3 +1,4 @@
1
+ import { isJevEnabled, judgeWorkerHealth } from "@triflux/core/hub/lib/jev-addon.mjs";
1
2
  import {
2
3
  capturePaneOutput,
3
4
  detectMultiplexer,
@@ -47,10 +48,16 @@ export async function teamDebug(args = []) {
47
48
  if (!(state.members || []).length)
48
49
  console.log(` ${DIM}(members 없음)${RESET}`);
49
50
  for (const member of state.members || []) {
50
- console.log(`\n [${member.name}] ${member.pane}`);
51
- for (const line of (capturePaneOutput(member.pane, lines) || "(empty)")
52
- .split("\n")
53
- .slice(-lines)) {
51
+ const rawTail = capturePaneOutput(member.pane, lines) || "(empty)";
52
+ let jevLabel = "";
53
+ if (isJevEnabled() && rawTail !== "(empty)") {
54
+ const verdict = await judgeWorkerHealth({ stdoutTail: rawTail });
55
+ if (verdict?.status) {
56
+ jevLabel = ` ${DIM}[jev:${verdict.status}]${RESET}`;
57
+ }
58
+ }
59
+ console.log(`\n [${member.name}] ${member.pane}${jevLabel}`);
60
+ for (const line of rawTail.split("\n").slice(-lines)) {
54
61
  console.log(` ${line}`);
55
62
  }
56
63
  }
@@ -2,6 +2,8 @@
2
2
  // HANDOFF 블록 파싱, 검증, fallback 생성, Lead 포맷팅
3
3
  // 설계: docs/design/handoff-schema-v7.md (PR #70 에서 삭제, c0722ecd 에 남아 있다)
4
4
 
5
+ import { judgeHandoffQuality, judgeReviewGate } from "@triflux/core/hub/lib/jev-addon.mjs";
6
+
5
7
  // ── enum 허용 값 ──
6
8
  const STATUS_VALUES = ["ok", "partial", "failed"];
7
9
  const LEAD_ACTION_VALUES = ["accept", "needs_read", "retry", "reassign"];
@@ -305,3 +307,101 @@ export function processHandoff(rawText, context = {}) {
305
307
  fallback: false,
306
308
  };
307
309
  }
310
+
311
+ /**
312
+ * Jev 애드온이 활성화된 경우 인계 파일의 lead_action과 가짜 성공 여부를 보정한다.
313
+ * 비활성 상태이거나 신뢰도 임계값 미만이면 processHandoff 결과를 그대로 반환한다.
314
+ */
315
+ export async function processHandoffWithJev(
316
+ rawText,
317
+ context = {},
318
+ jevOptions = {},
319
+ ) {
320
+ const base = processHandoff(rawText, context);
321
+ const verdict = await judgeHandoffQuality(
322
+ {
323
+ rawText,
324
+ handoff: base.handoff,
325
+ exitCode: context.exitCode ?? 0,
326
+ gitDiffFiles: context.gitDiffFiles,
327
+ role: context.role,
328
+ },
329
+ jevOptions,
330
+ );
331
+ if (!verdict) return base;
332
+
333
+ const declaredFilesCount = Array.isArray(base.handoff.files_changed)
334
+ ? base.handoff.files_changed.filter(Boolean).length
335
+ : 0;
336
+ const hasEmptyGitDiff =
337
+ Array.isArray(context.gitDiffFiles) &&
338
+ context.gitDiffFiles.filter(Boolean).length === 0;
339
+ const canAccept =
340
+ base.handoff.status === "ok" &&
341
+ (context.exitCode ?? 0) === 0 &&
342
+ declaredFilesCount > 0 &&
343
+ !hasEmptyGitDiff &&
344
+ base.handoff.lead_action === "accept";
345
+
346
+ let effectiveAction = verdict.leadAction;
347
+ if (effectiveAction === "accept" && !canAccept) {
348
+ effectiveAction =
349
+ base.handoff.lead_action === "retry" ? "retry" : "needs_read";
350
+ }
351
+
352
+ const updatedHandoff = {
353
+ ...base.handoff,
354
+ lead_action: effectiveAction,
355
+ ...(effectiveAction === "retry" && base.handoff.status === "ok"
356
+ ? { status: "partial" }
357
+ : {}),
358
+ _jev: {
359
+ lead_action: verdict.leadAction,
360
+ effective_lead_action: effectiveAction,
361
+ confidence: verdict.confidence,
362
+ },
363
+ };
364
+ const warnings = [...base.warnings];
365
+ if (effectiveAction !== base.handoff.lead_action) {
366
+ warnings.push(
367
+ `jev overridden lead_action: ${base.handoff.lead_action} -> ${effectiveAction}`,
368
+ );
369
+ }
370
+ return {
371
+ ...base,
372
+ handoff: updatedHandoff,
373
+ formatted: formatHandoffForLead(updatedHandoff),
374
+ warnings,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * 교차 리뷰 게이트 판정: Jev로 리뷰 생략, 교차 리뷰, 리드 에스컬레이션 및 리뷰 수렴 여부를 판단한다.
380
+ */
381
+ export async function evaluateReviewGateWithJev(
382
+ {
383
+ diffStat = "",
384
+ handoffSummary = "",
385
+ previousReviews = "",
386
+ currentReview = "",
387
+ } = {},
388
+ jevOptions = {},
389
+ ) {
390
+ const verdict = await judgeReviewGate(
391
+ { diffStat, handoffSummary, previousReviews, currentReview },
392
+ jevOptions,
393
+ );
394
+ if (!verdict) {
395
+ return {
396
+ gate: "cross_review",
397
+ converged: false,
398
+ fallback: true,
399
+ };
400
+ }
401
+ return {
402
+ gate: verdict.gate,
403
+ converged: verdict.converged,
404
+ confidence: verdict.confidence,
405
+ fallback: false,
406
+ };
407
+ }
@@ -15,6 +15,7 @@ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { resolveNestedCodexAgentProfile } from "../../scripts/lib/agent-route-policy.mjs";
17
17
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
18
+ import { judgeWorkerHealth } from "@triflux/core/hub/lib/jev-addon.mjs";
18
19
  import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
19
20
  import {
20
21
  createActivityLifecycle,
@@ -29,7 +30,7 @@ import { resolveDashboardLayout } from "./dashboard-layout.mjs";
29
30
  import {
30
31
  formatHandoffForLead,
31
32
  HANDOFF_INSTRUCTION_SHORT,
32
- processHandoff,
33
+ processHandoffWithJev,
33
34
  } from "./handoff.mjs";
34
35
  import { createInterventionLadder } from "./intervention.mjs";
35
36
  import {
@@ -371,11 +372,47 @@ export function readResult(resultFile, paneId) {
371
372
  return capturePsmuxPane(paneId, 30);
372
373
  }
373
374
 
374
- function createHeadlessIntervention(dispatch, fallback) {
375
- return async (context) => {
375
+ export function createHeadlessIntervention(dispatch, fallback, deps = {}) {
376
+ return async (context = {}) => {
376
377
  if (typeof fallback === "function")
377
378
  return (await fallback(context)) === true;
378
379
 
380
+ const capturePane = deps.capturePsmuxPane || capturePsmuxPane;
381
+ const judgeHealth = deps.judgeWorkerHealth || judgeWorkerHealth;
382
+ const buildLadder =
383
+ deps.createInterventionLadder || createInterventionLadder;
384
+ const paneTarget = context.paneId || dispatch.paneId;
385
+ const paneSnap = (() => {
386
+ try {
387
+ return capturePane(paneTarget, 50) || "";
388
+ } catch {
389
+ return "";
390
+ }
391
+ })();
392
+ const errTail = (() => {
393
+ try {
394
+ const errPath = `${dispatch.resultFile}.err`;
395
+ return existsSync(errPath)
396
+ ? readFileSync(errPath, "utf8").slice(-2000)
397
+ : "";
398
+ } catch {
399
+ return "";
400
+ }
401
+ })();
402
+ const health = await judgeHealth({
403
+ stdoutTail: paneSnap.slice(-2000),
404
+ stderrTail: errTail,
405
+ elapsedSec: Math.round((Number(context.inactiveMs) || 0) / 1000),
406
+ });
407
+ if (health?.status === "progressing") return true;
408
+
409
+ const reinstructPrompt =
410
+ health?.status === "waiting_input"
411
+ ? "[triflux intervention] 대화형 입력 대기가 감지되었다. 사용자 확인을 기다리지 말고 기본값이나 자율 판단으로 남은 작업을 마쳐라."
412
+ : health?.status === "looping_error"
413
+ ? "[triflux intervention] 동일 오류 반복 루프가 감지되었다. 직전 실패 명령을 반복하지 말고 우회 경로를 택하거나 실패 원인을 HANDOFF 블록에 기록하고 종료하라."
414
+ : undefined;
415
+
379
416
  const readActivitySignature = () => {
380
417
  let mtime = 0;
381
418
  try {
@@ -383,24 +420,25 @@ function createHeadlessIntervention(dispatch, fallback) {
383
420
  } catch {
384
421
  /* result file has not been written yet */
385
422
  }
386
- return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
423
+ return `${capturePane(paneTarget, 50)}\0${mtime}`;
387
424
  };
388
- const ladder = createInterventionLadder({
425
+ const ladder = buildLadder({
389
426
  target: {
390
427
  channel: "tmux-pane",
391
- paneId: context.paneId || dispatch.paneId,
428
+ paneId: paneTarget,
392
429
  cli: dispatch.cli,
393
430
  sessionId: dispatch.sessionId,
394
431
  },
432
+ ...(reinstructPrompt ? { reinstructPrompt } : {}),
395
433
  readActivitySignature,
396
434
  deps: {
397
435
  // 이 트랙이 재실행 소유자다. pane 채널은 기존 completion wrapper를 다시
398
436
  // dispatch하여 token/result 계약을 보존한다.
399
437
  resumeHandler: async () => {
400
438
  if (!dispatch.command) return { ok: false };
401
- const restarted = dispatchCommand(
439
+ const restarted = (deps.dispatchCommand || dispatchCommand)(
402
440
  context.sessionName,
403
- context.paneId || dispatch.paneId,
441
+ paneTarget,
404
442
  dispatch.command,
405
443
  );
406
444
  return { ok: Boolean(restarted) };
@@ -823,11 +861,12 @@ async function collectResults(results) {
823
861
  return await Promise.all(
824
862
  results.map(async ({ d, completion, output }) => {
825
863
  const workerGitDiffFiles = collectGitDiffFiles(d.cwd);
826
- const handoffResult = processHandoff(output, {
864
+ const handoffResult = await processHandoffWithJev(output, {
827
865
  exitCode: completion.exitCode,
828
866
  resultFile: d.resultFile,
829
867
  cli: d.cli,
830
868
  gitDiffFiles: workerGitDiffFiles,
869
+ role: d.role,
831
870
  });
832
871
  if (
833
872
  completion.exitCode === 0 &&
@@ -1745,7 +1784,7 @@ export async function runHeadlessInteractive(
1745
1784
  process.on("exit", exitHandler);
1746
1785
  // kill() 후 리스너 제거를 위해 참조 보관
1747
1786
  const originalKill = handle.kill.bind(handle);
1748
- handle.kill = function () {
1787
+ handle.kill = () => {
1749
1788
  originalKill();
1750
1789
  process.removeListener("exit", exitHandler);
1751
1790
  };
@@ -0,0 +1,186 @@
1
+ // hub/team/lead-launch.mjs: `tfx lead <역할>` 로 리드 세션을 띄운다.
2
+ // 리드는 사람이 직접 쓰는 세션이라 CLI 별 실행 명령을 사용자가 정한다(기본은 CLI 이름).
3
+ // 워커는 tfx-live 가 기본 명령으로 띄우므로 이 설정과 무관하다.
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { promisify } from "node:util";
10
+ import { modelFamilyPaths } from "../../scripts/lib/model-family-resolver.mjs";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
14
+ const TFX_LIVE = join(PKG_ROOT, "bin", "tfx-live.mjs");
15
+ const LEAD_SKILL = join(PKG_ROOT, "skills", "tfx-lead", "SKILL.md");
16
+ export const LEAD_CLIS = ["claude", "codex", "agy"];
17
+
18
+ export function leadConfigPath() {
19
+ return join(modelFamilyPaths().stateDir, "lead.json");
20
+ }
21
+
22
+ /** lead.json 을 읽는다. 없으면 null. 10.85.0 의 `launcher` 한 칸은 Claude 실행 명령으로 읽는다. */
23
+ export function readLeadConfig(path = leadConfigPath()) {
24
+ let text;
25
+ try {
26
+ text = readFileSync(path, "utf8");
27
+ } catch (error) {
28
+ if (error.code === "ENOENT") return null;
29
+ throw error;
30
+ }
31
+ const config = JSON.parse(text);
32
+ const isObject = (value) =>
33
+ value !== null && typeof value === "object" && !Array.isArray(value);
34
+ if (
35
+ !isObject(config) ||
36
+ (config.launchers !== undefined && !isObject(config.launchers))
37
+ )
38
+ throw new Error(`${path} 는 {"launchers": {...}} 형식이어야 한다`);
39
+ const launchers = { ...config.launchers };
40
+ if (config.launcher !== undefined && launchers.claude === undefined)
41
+ launchers.claude = config.launcher;
42
+ for (const [cli, launcher] of Object.entries(launchers)) {
43
+ if (
44
+ typeof launcher !== "string" ||
45
+ !launcher.trim() ||
46
+ /[\r\n]/u.test(launcher)
47
+ )
48
+ throw new Error(`${path} 의 launchers.${cli} 는 한 줄 문자열이어야 한다`);
49
+ launchers[cli] = launcher.trim();
50
+ }
51
+ return { launchers };
52
+ }
53
+
54
+ export function writeLeadConfig(launchers, path = leadConfigPath()) {
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, `${JSON.stringify({ launchers }, null, 2)}\n`);
57
+ }
58
+
59
+ const CLI_LABELS = {
60
+ claude: "Claude",
61
+ codex: "Codex",
62
+ agy: "Antigravity(agy)",
63
+ };
64
+
65
+ /**
66
+ * setup 과 update 때 아직 정하지 않은 CLI 의 리드 실행 명령을 한 번 묻는다.
67
+ * 설치되지 않은 CLI 는 묻지 않고, 물을 수 없으면(비대화형) 다음 대화형 실행으로 미룬다.
68
+ */
69
+ export async function offerLeadLaunchers({
70
+ interactive,
71
+ askText,
72
+ hasCommand,
73
+ notice = () => {},
74
+ configPath = leadConfigPath(),
75
+ }) {
76
+ const launchers = { ...readLeadConfig(configPath)?.launchers };
77
+ const missing = LEAD_CLIS.filter(
78
+ (cli) => launchers[cli] === undefined && hasCommand(cli),
79
+ );
80
+ if (missing.length === 0) return "present";
81
+ if (!interactive) return "deferred";
82
+ notice(
83
+ "tfx lead 로 리드 세션을 띄울 때 쓸 실행 명령을 정한다. 래퍼, 별칭, 셸 함수로 켜면 그 명령을 적고, 그대로 쓰면 Enter. 워커에는 쓰지 않는다.",
84
+ );
85
+ for (const cli of missing) {
86
+ const answer = await askText(
87
+ `${CLI_LABELS[cli]} 를 켤 때 쓰는 명령 [${cli}]`,
88
+ );
89
+ launchers[cli] = answer.trim().split(/\r?\n/u)[0] || cli;
90
+ }
91
+ writeLeadConfig(launchers, configPath);
92
+ return "saved";
93
+ }
94
+
95
+ /** 실행 명령은 --launcher, lead.json 의 launchers.<cli>, CLI 이름 순으로 고른다. */
96
+ export function resolveLauncher({ cli = "claude", flag, configPath } = {}) {
97
+ if (flag) return flag;
98
+ return readLeadConfig(configPath)?.launchers[cli] ?? cli;
99
+ }
100
+
101
+ /** tmux 대상 문법과 겹치는 문자(. :)와 공백만 바꿔 `<폴더>-lead` 로 만든다. */
102
+ export function leadSessionName(cwd) {
103
+ return `${basename(resolve(cwd)).replace(/[.:\s]/gu, "-")}-lead`;
104
+ }
105
+
106
+ /** /tfx-lead 와 같은 규칙으로 이름을 정한다. 날짜로 시작하면 그대로 쓴다. */
107
+ export function leadDisplayName(role, now = new Date()) {
108
+ if (/^\d{1,2}\.\d{1,2} \S/u.test(role)) return role;
109
+ return `${now.getMonth() + 1}.${now.getDate()} ${role}`;
110
+ }
111
+
112
+ /** Claude 는 mod 가 /tfx-lead 를 처리한다. Codex 와 agy 는 mod 가 없어 스킬 파일을 읽게 한다. */
113
+ export function leadEntryPrompt(cli, role) {
114
+ if (cli === "claude") return `/tfx-lead ${role}`;
115
+ return `${LEAD_SKILL} 를 읽고 이 세션이 ${role} 리드를 맡는다. 그 스킬의 절차를 따른다.`;
116
+ }
117
+
118
+ async function runLive(args, run) {
119
+ let stdout;
120
+ try {
121
+ ({ stdout } = await run(process.execPath, [TFX_LIVE, ...args], {
122
+ encoding: "utf8",
123
+ maxBuffer: 1024 * 1024,
124
+ }));
125
+ } catch (error) {
126
+ // tfx-live 는 실패해도 원인을 담은 JSON 을 stdout 에 쓰고 exit 1 로 끝난다.
127
+ if (!error.stdout) throw error;
128
+ stdout = error.stdout;
129
+ }
130
+ return JSON.parse(stdout);
131
+ }
132
+
133
+ export async function launchLead(
134
+ {
135
+ cli = "claude",
136
+ role,
137
+ cwd = process.cwd(),
138
+ session,
139
+ model,
140
+ effort,
141
+ launcher,
142
+ },
143
+ { run = execFileAsync } = {},
144
+ ) {
145
+ if (!LEAD_CLIS.includes(cli))
146
+ throw new Error(`--cli 는 ${LEAD_CLIS.join(", ")} 중 하나여야 한다`);
147
+ if (!role?.trim()) throw new Error("역할이 필요하다: tfx lead <역할>");
148
+ const target = session || leadSessionName(cwd);
149
+ const name = leadDisplayName(role.trim());
150
+ const summary = { cli, session: target, name, launcher };
151
+ const start = await runLive(
152
+ [
153
+ "start",
154
+ "--cli",
155
+ cli,
156
+ "--session",
157
+ target,
158
+ "--name",
159
+ name,
160
+ "--launcher",
161
+ launcher,
162
+ "--cwd",
163
+ resolve(cwd),
164
+ ...(model ? ["--model", model] : []),
165
+ ...(effort ? ["--effort", effort] : []),
166
+ ],
167
+ run,
168
+ );
169
+ if (!start.ready) return { ok: false, ...summary, start };
170
+ // Claude 슬래시 명령은 tmux 입력으로만 실행되고, Claude 안에서 호출해도 SendMessage 로 바뀌지 않게 한다.
171
+ const ask = await runLive(
172
+ [
173
+ "ask",
174
+ "--cli",
175
+ cli,
176
+ "--session",
177
+ target,
178
+ "--prompt",
179
+ leadEntryPrompt(cli, role.trim()),
180
+ "--no-wait",
181
+ ...(cli === "claude" ? ["--via-tmux"] : []),
182
+ ],
183
+ run,
184
+ );
185
+ return { ok: Boolean(ask.ok), ...summary, start, ask };
186
+ }
@@ -36,9 +36,15 @@ export function parsePeerArgs(args) {
36
36
  );
37
37
  if (target === "--session")
38
38
  return { sessionId: parsed["--session"], message: parsed["--message"] };
39
- if (!/^(claude|codex):[^:]+$/.test(parsed["--to"]))
40
- throw new Error("Use exact claude:NAME or codex:NAME target");
41
- return { to: parsed["--to"], message: parsed["--message"] };
39
+ if (!/^(claude|codex|agy|antigravity):[^:]+$/i.test(parsed["--to"]))
40
+ throw new Error("Use exact claude:NAME, codex:NAME, or agy:NAME target");
41
+ const sep = parsed["--to"].indexOf(":");
42
+ const rawCli = parsed["--to"].slice(0, sep).toLowerCase();
43
+ const cli = rawCli === "antigravity" ? "agy" : rawCli;
44
+ return {
45
+ to: `${cli}:${parsed["--to"].slice(sep + 1)}`,
46
+ message: parsed["--message"],
47
+ };
42
48
  }
43
49
 
44
50
  export function validateCodexTarget(thread, id) {
@@ -468,3 +474,91 @@ export async function deliverClaude({
468
474
  } catch {}
469
475
  }
470
476
  }
477
+
478
+ export function deliverAgy({
479
+ conversationId,
480
+ appDataDir: targetAppDataDir = null,
481
+ requireExisting = false,
482
+ message,
483
+ sender = "unknown",
484
+ env = process.env,
485
+ home = null,
486
+ fs: io = fs,
487
+ } = {}) {
488
+ if (
489
+ typeof conversationId !== "string" ||
490
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
491
+ conversationId,
492
+ )
493
+ )
494
+ throw new Error("Invalid agy conversationId");
495
+ const normalizedId = conversationId.toLowerCase();
496
+ const resolvedHome = home || env.HOME || os.homedir();
497
+ const appDataDir =
498
+ targetAppDataDir ||
499
+ (home
500
+ ? path.join(home, ".gemini", "antigravity-cli")
501
+ : env.ANTIGRAVITY_APP_DATA_DIR ||
502
+ path.join(resolvedHome, ".gemini", "antigravity-cli"));
503
+ if (requireExisting) {
504
+ const hasConversation = [
505
+ path.join(appDataDir, "presence", `${normalizedId}.lock`),
506
+ path.join(appDataDir, "conversations", `${normalizedId}.db`),
507
+ path.join(appDataDir, "brain", normalizedId),
508
+ ].some((p) => io.existsSync(p));
509
+ if (!hasConversation) {
510
+ throw new Error("agy target conversation not found in appDataDir");
511
+ }
512
+ }
513
+ const messagesDir = path.join(
514
+ appDataDir,
515
+ "brain",
516
+ normalizedId,
517
+ ".system_generated",
518
+ "messages",
519
+ );
520
+ const undeliveredDir = path.join(messagesDir, "undelivered");
521
+ io.mkdirSync(undeliveredDir, { recursive: true, mode: 0o700 });
522
+ const msgId = randomUUID();
523
+ const payload = {
524
+ id: msgId,
525
+ recipient: normalizedId,
526
+ sender,
527
+ priority: "MESSAGE_PRIORITY_HIGH",
528
+ timestamp: new Date().toISOString(),
529
+ renderDetails: {
530
+ messageTitle: `Message from ${sender}`,
531
+ },
532
+ content: message,
533
+ };
534
+ const jsonPath = path.join(messagesDir, `${msgId}.json`);
535
+ const markerPath = path.join(undeliveredDir, msgId);
536
+ try {
537
+ io.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), {
538
+ mode: 0o600,
539
+ });
540
+ } catch (error) {
541
+ try {
542
+ io.unlinkSync?.(jsonPath);
543
+ } catch {}
544
+ throw error;
545
+ }
546
+ try {
547
+ io.writeFileSync(markerPath, "", { mode: 0o600 });
548
+ } catch (error) {
549
+ const markerExists = Boolean(io.existsSync?.(markerPath));
550
+ return {
551
+ status: markerExists ? "unknown" : "partial",
552
+ retrySafe: false,
553
+ modelReceipt: false,
554
+ msgId,
555
+ error: error.message,
556
+ };
557
+ }
558
+ return {
559
+ status: "written",
560
+ retrySafe: false,
561
+ modelReceipt: false,
562
+ msgId,
563
+ };
564
+ }
@@ -1,5 +1,6 @@
1
1
  // hub/team/orchestrator.mjs — 작업 분배 + 프롬프트 구성
2
2
  // 의존성: pane.mjs만 사용
3
+ import { judgeTaskAssignment } from "@triflux/core/hub/lib/jev-addon.mjs";
3
4
  import { injectPrompt as defaultInjectPrompt } from "./pane.mjs";
4
5
 
5
6
  /**
@@ -39,6 +40,85 @@ export function decomposeTask(taskDescription, agentCount) {
39
40
  return parts;
40
41
  }
41
42
 
43
+ /**
44
+ * Jev가 활성화된 경우 서브태스크별 최적 워커/CLI 매칭을 적용한다.
45
+ * 비활성 상태이거나 판정이 실패하면 결정론적 decomposeTask 순서대로 반환한다.
46
+ */
47
+ export async function assignTasksWithJev(
48
+ taskDescription,
49
+ workers,
50
+ jevOptions = {},
51
+ ) {
52
+ if (!Array.isArray(workers) || workers.length === 0) return [];
53
+ const subtasks = decomposeTask(taskDescription, workers.length);
54
+ const base = workers.map((w, i) => ({
55
+ ...w,
56
+ subtask: subtasks[i] || subtasks[subtasks.length - 1],
57
+ }));
58
+ if (workers.length <= 1) return base;
59
+
60
+ const verdict = await judgeTaskAssignment({ subtasks, workers }, jevOptions);
61
+ if (
62
+ !verdict ||
63
+ !Array.isArray(verdict.assignments) ||
64
+ verdict.assignments.length !== workers.length
65
+ ) {
66
+ return base;
67
+ }
68
+ const usedSubtasks = new Set();
69
+ const workerAssignments = new Array(workers.length).fill(null);
70
+
71
+ for (let i = 0; i < workers.length; i += 1) {
72
+ const match = verdict.assignments.find(
73
+ (item) =>
74
+ item.workerIndex === i &&
75
+ Number.isInteger(item.subtaskIndex) &&
76
+ item.subtaskIndex >= 0 &&
77
+ item.subtaskIndex < subtasks.length &&
78
+ !usedSubtasks.has(item.subtaskIndex),
79
+ );
80
+ if (match) {
81
+ usedSubtasks.add(match.subtaskIndex);
82
+ workerAssignments[i] = {
83
+ subtaskIdx: match.subtaskIndex,
84
+ recommendedCli: match.recommendedCli || workers[i].cli,
85
+ };
86
+ }
87
+ }
88
+
89
+ for (let i = 0; i < workers.length; i += 1) {
90
+ if (workerAssignments[i]) continue;
91
+ let fallbackIdx = !usedSubtasks.has(i) && i < subtasks.length ? i : -1;
92
+ if (fallbackIdx === -1) {
93
+ for (let s = 0; s < subtasks.length; s += 1) {
94
+ if (!usedSubtasks.has(s)) {
95
+ fallbackIdx = s;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ if (fallbackIdx === -1) fallbackIdx = Math.min(i, subtasks.length - 1);
101
+ usedSubtasks.add(fallbackIdx);
102
+ workerAssignments[i] = {
103
+ subtaskIdx: fallbackIdx,
104
+ recommendedCli: workers[i].cli,
105
+ };
106
+ }
107
+
108
+ return workers.map((w, i) => {
109
+ const { subtaskIdx, recommendedCli } = workerAssignments[i];
110
+ return {
111
+ ...w,
112
+ subtask: subtasks[subtaskIdx] || subtasks[subtasks.length - 1],
113
+ _jev: {
114
+ subtaskIndex: subtaskIdx,
115
+ recommendedCli,
116
+ confidence: verdict.confidence,
117
+ },
118
+ };
119
+ });
120
+ }
121
+
42
122
  /**
43
123
  * 리드(보통 claude) 초기 프롬프트 생성
44
124
  * @param {string} taskDescription