triflux 5.0.3 → 5.1.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.
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { writeFileSync, mkdirSync } from 'node:fs';
6
6
  import { join, resolve } from 'node:path';
7
+ import { homedir } from 'node:os';
7
8
 
8
9
  import { canTransition, transitionPhase, ralphRestart, TERMINAL } from './transitions.mjs';
9
10
  import {
@@ -83,6 +84,26 @@ export function createPipeline(db, teamName, opts = {}) {
83
84
  return { ok: true, state: { ...state } };
84
85
  },
85
86
 
87
+ /**
88
+ * DAG 컨텍스트를 파이프라인 상태에 저장
89
+ * @param {{ dag_width: number, levels: Record<number, string[]>, edges: Array<{from:string, to:string}>, max_complexity: string, taskResults: Record<string, *> }} dagContext
90
+ */
91
+ setDagContext(dagContext) {
92
+ const current = readPipelineState(db, teamName);
93
+ if (!current) return;
94
+ const artifacts = { ...(current.artifacts || {}), dagContext };
95
+ state = updatePipelineState(db, teamName, { artifacts });
96
+ },
97
+
98
+ /**
99
+ * DAG 컨텍스트 조회 (편의 메서드)
100
+ * @returns {{ dag_width: number, levels: Record<number, string[]>, edges: Array<{from:string, to:string}>, max_complexity: string, taskResults: Record<string, *> } | null}
101
+ */
102
+ getDagContext() {
103
+ const current = readPipelineState(db, teamName) || state;
104
+ return current?.artifacts?.dagContext || null;
105
+ },
106
+
86
107
  /**
87
108
  * artifact 저장 (plan_path, prd_path, verify_report 등)
88
109
  * @param {string} key
@@ -136,5 +157,60 @@ export function createPipeline(db, teamName, opts = {}) {
136
157
  };
137
158
  }
138
159
 
160
+ // ── 토큰 벤치마크 훅 ──
161
+
162
+ let _tokenSnapshotMod = null;
163
+
164
+ async function loadTokenSnapshot() {
165
+ if (_tokenSnapshotMod) return _tokenSnapshotMod;
166
+ try {
167
+ _tokenSnapshotMod = await import('../../scripts/token-snapshot.mjs');
168
+ } catch {
169
+ _tokenSnapshotMod = null;
170
+ }
171
+ return _tokenSnapshotMod;
172
+ }
173
+
174
+ /**
175
+ * 파이프라인 시작 시 토큰 스냅샷 캡처
176
+ * @param {string} label - 스냅샷 라벨 (e.g. teamName + timestamp)
177
+ * @returns {Promise<{label: string, snapshot: object}|null>}
178
+ */
179
+ export async function benchmarkStart(label) {
180
+ const mod = await loadTokenSnapshot();
181
+ if (!mod?.takeSnapshot) return null;
182
+ try {
183
+ const snapshot = mod.takeSnapshot(label);
184
+ return { label, snapshot };
185
+ } catch { return null; }
186
+ }
187
+
188
+ /**
189
+ * 파이프라인 종료 시 diff 계산 + 결과 저장
190
+ * @param {string} preLabel - 시작 스냅샷 라벨
191
+ * @param {string} postLabel - 종료 스냅샷 라벨
192
+ * @param {object} options - { agent?, cli?, id? }
193
+ * @returns {Promise<object|null>} diff 결과
194
+ */
195
+ export async function benchmarkEnd(preLabel, postLabel, options = {}) {
196
+ const mod = await loadTokenSnapshot();
197
+ if (!mod?.takeSnapshot || !mod?.computeDiff) return null;
198
+ try {
199
+ // 종료 스냅샷 캡처
200
+ mod.takeSnapshot(postLabel);
201
+ // diff 계산 (결과는 DIFFS_DIR에 자동 저장됨)
202
+ const diff = mod.computeDiff(preLabel, postLabel, options);
203
+
204
+ // 추가로 타임스탬프 기반 사본 저장
205
+ const diffsDir = join(homedir(), '.omc', 'state', 'cx-auto-tokens', 'diffs');
206
+ mkdirSync(diffsDir, { recursive: true });
207
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
208
+ const outPath = join(diffsDir, `${ts}.json`);
209
+ writeFileSync(outPath, JSON.stringify(diff, null, 2));
210
+
211
+ return diff;
212
+ } catch { return null; }
213
+ }
214
+
139
215
  export { ensurePipelineTable } from './state.mjs';
140
216
  export { PHASES, TERMINAL, ALLOWED, canTransition } from './transitions.mjs';
@@ -10,17 +10,17 @@ import * as fs from "node:fs/promises";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
 
13
- const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
14
- export const SLIM_WRAPPER_SUBAGENT_TYPE = "slim-wrapper";
15
- /** scout 역할 기본 설정 — read-only 탐색 전용 */
16
- export const SCOUT_ROLE_CONFIG = {
17
- cli: "codex",
18
- role: "scientist",
19
- mcp_profile: "analyze",
20
- maxIterations: 2,
21
- readOnly: true,
22
- };
23
- const ROUTE_LOG_RE = /\[tfx-route\]/i;
13
+ const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
14
+ export const SLIM_WRAPPER_SUBAGENT_TYPE = "slim-wrapper";
15
+ /** scout 역할 기본 설정 — read-only 탐색 전용 */
16
+ export const SCOUT_ROLE_CONFIG = {
17
+ cli: "codex",
18
+ role: "scientist",
19
+ mcp_profile: "analyze",
20
+ maxIterations: 2,
21
+ readOnly: true,
22
+ };
23
+ const ROUTE_LOG_RE = /\[tfx-route\]/i;
24
24
  const ROUTE_COMMAND_RE = /(?:^|[\s"'`])(?:bash\s+)?(?:[^"'`\s]*\/)?tfx-route\.sh\b/i;
25
25
  const ROUTE_PROMPT_RE = /tfx-route\.sh/i;
26
26
  const DIRECT_TOOL_BYPASS_RE = /\b(?:Read|Edit|Write)\s*\(/;
@@ -77,6 +77,7 @@ export function buildSlimWrapperAgent(cli, opts = {}) {
77
77
  * usedRoute: boolean,
78
78
  * abnormal: boolean,
79
79
  * reason: string|null,
80
+ * slopDetected: boolean,
80
81
  * }}
81
82
  */
82
83
  export function verifySlimWrapperRouteExecution(input = {}) {
@@ -96,6 +97,7 @@ export function verifySlimWrapperRouteExecution(input = {}) {
96
97
  : sawDirectToolBypass
97
98
  ? "direct_tool_bypass_detected"
98
99
  : "missing_tfx_route_evidence";
100
+ const slopDetected = detectSlop(stdoutText);
99
101
 
100
102
  return {
101
103
  expectedRouteInvocation,
@@ -106,6 +108,7 @@ export function verifySlimWrapperRouteExecution(input = {}) {
106
108
  usedRoute,
107
109
  abnormal,
108
110
  reason,
111
+ slopDetected,
109
112
  };
110
113
  }
111
114
 
@@ -154,7 +157,7 @@ function getRouteTimeout(role, _mcpProfile) {
154
157
  * @param {number} [opts.maxIterations=3] — 피드백 루프 최대 반복 횟수
155
158
  * @returns {string} 슬림 래퍼 프롬프트
156
159
  */
157
- export function buildSlimWrapperPrompt(cli, opts = {}) {
160
+ export function buildSlimWrapperPrompt(cli, opts = {}) {
158
161
  const {
159
162
  subtask,
160
163
  role = "executor",
@@ -171,13 +174,13 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
171
174
 
172
175
  const routeTimeoutSec = getRouteTimeout(role, mcp_profile);
173
176
  const escaped = subtask.replace(/'/g, "'\\''");
174
- const pipelineHint = pipelinePhase
175
- ? `\n파이프라인 단계: ${pipelinePhase}`
176
- : '';
177
- const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
178
- const scoutConstraint = (role === "scout" || role === "scientist")
179
- ? "\n이 워커는 scout(탐색 전용)이다. 코드를 수정하거나 파일을 생성하지 마라. 기존 코드를 읽고 분석하여 보고만 하라."
180
- : "";
177
+ const pipelineHint = pipelinePhase
178
+ ? `\n파이프라인 단계: ${pipelinePhase}`
179
+ : '';
180
+ const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
181
+ const scoutConstraint = (role === "scout" || role === "scientist")
182
+ ? "\n이 워커는 scout(탐색 전용)이다. 코드를 수정하거나 파일을 생성하지 마라. 기존 코드를 읽고 분석하여 보고만 하라."
183
+ : "";
181
184
 
182
185
  // Bash 도구 timeout (모두 600초 이내)
183
186
  const launchTimeoutMs = 15000; // Step 1: fork + job_id 반환
@@ -192,10 +195,10 @@ Step 0 — 시작 보고 (턴 경계 생성):
192
195
  TaskUpdate(taskId: "${taskId}", status: "in_progress")
193
196
  SendMessage(type: "message", recipient: "${leadName}", content: "작업 시작: ${agentName}", summary: "task ${taskId} started")
194
197
 
195
- [HARD CONSTRAINT] 허용 도구: Bash, TaskUpdate, TaskGet, TaskList, SendMessage만 사용한다.
196
- Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도구 사용을 금지한다.
197
- 코드를 직접 읽거나 수정하면 안 된다. 반드시 아래 Bash 명령(tfx-route.sh)을 통해 Codex/Gemini에 위임하라.
198
- 이 규칙을 위반하면 작업 실패로 간주한다.${scoutConstraint}
198
+ [HARD CONSTRAINT] 허용 도구: Bash, TaskUpdate, TaskGet, TaskList, SendMessage만 사용한다.
199
+ Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도구 사용을 금지한다.
200
+ 코드를 직접 읽거나 수정하면 안 된다. 반드시 아래 Bash 명령(tfx-route.sh)을 통해 Codex/Gemini에 위임하라.
201
+ 이 규칙을 위반하면 작업 실패로 간주한다.${scoutConstraint}
199
202
 
200
203
  gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
201
204
  프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
@@ -232,40 +235,40 @@ SendMessage 후 너는 IDLE 상태가 된다. 리드의 응답을 기다려라.
232
235
 
233
236
  Step 6 — 최종 종료 (반드시 실행):
234
237
  TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "success"|"failed"|"fallback", iterations: ITERATION})
235
- SendMessage(type: "message", recipient: "${leadName}", content: "최종 완료: ${agentName} (ITERATION회 실행)", summary: "task ${taskId} final")
236
- → 종료. 이후 추가 도구 호출 금지.`;
237
- }
238
-
239
- /**
240
- * scout 파견용 프롬프트 생성
241
- * @param {object} opts
242
- * @param {string} opts.question — 탐색 질문
243
- * @param {string} [opts.scope] — 탐색 범위 힌트 (파일 패턴)
244
- * @param {string} [opts.teamName] — 팀 이름
245
- * @param {string} [opts.taskId] — 태스크 ID
246
- * @param {string} [opts.agentName] — 에이전트 이름
247
- * @param {string} [opts.leadName] — 리드 이름
248
- * @returns {string} slim wrapper 프롬프트
249
- */
250
- export function buildScoutDispatchPrompt(opts = {}) {
251
- const { question, scope = "", teamName, taskId, agentName, leadName } = opts;
252
- const subtask = scope
253
- ? `${question} 탐색 범위: ${scope}`
254
- : question;
255
- return buildSlimWrapperPrompt("codex", {
256
- subtask,
257
- role: "scientist",
258
- teamName,
259
- taskId,
260
- agentName,
261
- leadName,
262
- mcp_profile: "analyze",
263
- maxIterations: SCOUT_ROLE_CONFIG.maxIterations,
264
- });
265
- }
266
-
267
- /**
268
- * v3 하이브리드 래퍼 프롬프트 생성
238
+ SendMessage(type: "message", recipient: "${leadName}", content: "최종 완료: ${agentName} (ITERATION회 실행)", summary: "task ${taskId} final")
239
+ → 종료. 이후 추가 도구 호출 금지.`;
240
+ }
241
+
242
+ /**
243
+ * scout 파견용 프롬프트 생성
244
+ * @param {object} opts
245
+ * @param {string} opts.question — 탐색 질문
246
+ * @param {string} [opts.scope] — 탐색 범위 힌트 (파일 패턴)
247
+ * @param {string} [opts.teamName] — 팀 이름
248
+ * @param {string} [opts.taskId] — 태스크 ID
249
+ * @param {string} [opts.agentName] — 에이전트 이름
250
+ * @param {string} [opts.leadName] — 리드 이름
251
+ * @returns {string} slim wrapper 프롬프트
252
+ */
253
+ export function buildScoutDispatchPrompt(opts = {}) {
254
+ const { question, scope = "", teamName, taskId, agentName, leadName } = opts;
255
+ const subtask = scope
256
+ ? `${question} 탐색 범위: ${scope}`
257
+ : question;
258
+ return buildSlimWrapperPrompt("codex", {
259
+ subtask,
260
+ role: "scientist",
261
+ teamName,
262
+ taskId,
263
+ agentName,
264
+ leadName,
265
+ mcp_profile: "analyze",
266
+ maxIterations: SCOUT_ROLE_CONFIG.maxIterations,
267
+ });
268
+ }
269
+
270
+ /**
271
+ * v3 하이브리드 래퍼 프롬프트 생성
269
272
  * psmux pane 기반 비동기 실행 + polling 패턴.
270
273
  * Agent가 idle 상태를 유지하여 인터럽트 수신이 가능하다.
271
274
  *
@@ -442,6 +445,199 @@ export function formatPollReport(pollResult = {}) {
442
445
  : `${completed.length}/${total} 완료`;
443
446
  }
444
447
 
448
+ // ── Anti-slop 필터링 ────────────────────────────────────────────
449
+
450
+ /**
451
+ * 문자열을 정규화: 소문자 변환 + 연속 공백을 단일 공백으로 + trim
452
+ * @param {string} s
453
+ * @returns {string}
454
+ */
455
+ function normalizeText(s) {
456
+ return String(s || "").toLowerCase().replace(/\s+/g, " ").trim();
457
+ }
458
+
459
+ /**
460
+ * 두 문자열의 단어 집합 Jaccard 유사도를 계산한다.
461
+ * @param {string} a — 정규화된 문자열
462
+ * @param {string} b — 정규화된 문자열
463
+ * @returns {number} 0.0–1.0
464
+ */
465
+ function jaccardSimilarity(a, b) {
466
+ const setA = new Set(a.split(" ").filter(Boolean));
467
+ const setB = new Set(b.split(" ").filter(Boolean));
468
+ if (setA.size === 0 && setB.size === 0) return 1.0;
469
+ if (setA.size === 0 || setB.size === 0) return 0.0;
470
+ let intersection = 0;
471
+ for (const w of setA) {
472
+ if (setB.has(w)) intersection++;
473
+ }
474
+ return intersection / (setA.size + setB.size - intersection);
475
+ }
476
+
477
+ /**
478
+ * findings 배열에서 중복을 제거한다.
479
+ * 정규화 후 description의 Jaccard 유사도 >= 0.8이면 중복으로 판정.
480
+ * 동일 file+line인 경우도 중복 후보로 취급.
481
+ *
482
+ * @param {Array<{description:string, file?:string, line?:number, severity?:string}>} findings
483
+ * @returns {Array<{description:string, file?:string, line?:number, severity?:string, occurrences:number}>}
484
+ */
485
+ export function deduplicateFindings(findings) {
486
+ if (!Array.isArray(findings) || findings.length === 0) return [];
487
+
488
+ const groups = []; // [{canonical, items:[]}]
489
+
490
+ for (const f of findings) {
491
+ const norm = normalizeText(f.description);
492
+ let merged = false;
493
+ for (const g of groups) {
494
+ if (jaccardSimilarity(norm, g.norm) >= 0.8) {
495
+ g.items.push(f);
496
+ merged = true;
497
+ break;
498
+ }
499
+ }
500
+ if (!merged) {
501
+ groups.push({ norm, canonical: f, items: [f] });
502
+ }
503
+ }
504
+
505
+ return groups.map((g) => ({
506
+ description: g.canonical.description,
507
+ ...(g.canonical.file != null ? { file: g.canonical.file } : {}),
508
+ ...(g.canonical.line != null ? { line: g.canonical.line } : {}),
509
+ ...(g.canonical.severity != null ? { severity: g.canonical.severity } : {}),
510
+ occurrences: g.items.length,
511
+ }));
512
+ }
513
+
514
+ /**
515
+ * scout 보고서 원문을 핵심 발견 사항만 추출하여 압축한다.
516
+ * 파일:라인 + 한줄 요약 형태로 변환하며 최대 ~500토큰(2000자) 이하로 제한.
517
+ *
518
+ * @param {string} rawReport — 자유형 텍스트
519
+ * @returns {{findings: Array<{file:string, line:string, summary:string}>, summary:string, tokenEstimate:number}}
520
+ */
521
+ export function compressScoutReport(rawReport) {
522
+ const MAX_CHARS = 2000;
523
+ const text = String(rawReport || "");
524
+
525
+ // 파일:라인 패턴 추출 (path/to/file.ext:123 형태 + 뒤따르는 설명)
526
+ const fileLineRe = /([a-zA-Z0-9_./-]+\.[a-zA-Z]{1,10}):(\d+)\s*[:\-–—]?\s*(.+)/g;
527
+ const findings = [];
528
+ let match;
529
+ while ((match = fileLineRe.exec(text)) !== null) {
530
+ findings.push({
531
+ file: match[1],
532
+ line: match[2],
533
+ summary: match[3].trim().slice(0, 120),
534
+ });
535
+ }
536
+
537
+ // 문장 단위로 핵심 요약 구성
538
+ const sentences = text
539
+ .split(/[.\n]/)
540
+ .map((s) => s.trim())
541
+ .filter((s) => s.length > 10);
542
+ let summary = sentences.slice(0, 5).join(". ");
543
+
544
+ // 토큰 추정: ~4자 = 1토큰
545
+ const estimateTokens = (s) => Math.ceil(s.length / 4);
546
+
547
+ // findings를 먼저 계산하고, 남은 공간에 맞춰 summary를 자른다
548
+ const findingsJson = JSON.stringify(findings);
549
+ const findingsBudget = Math.min(findingsJson.length, Math.floor(MAX_CHARS * 0.3));
550
+
551
+ let trimmedFindings = findings;
552
+ if (findingsJson.length > findingsBudget && findings.length > 0) {
553
+ trimmedFindings = [];
554
+ let used = 2; // []
555
+ for (const f of findings) {
556
+ const entryLen = JSON.stringify(f).length + 1;
557
+ if (used + entryLen > findingsBudget) break;
558
+ trimmedFindings.push(f);
559
+ used += entryLen;
560
+ }
561
+ }
562
+
563
+ const summaryBudget = MAX_CHARS - JSON.stringify(trimmedFindings).length;
564
+ if (summary.length > summaryBudget) {
565
+ summary = summary.slice(0, Math.max(0, summaryBudget - 3)) + "...";
566
+ }
567
+
568
+ return {
569
+ findings: trimmedFindings,
570
+ summary,
571
+ tokenEstimate: estimateTokens(summary + JSON.stringify(trimmedFindings)),
572
+ };
573
+ }
574
+
575
+ /**
576
+ * 여러 scout 보고서의 발견 사항을 종합하여 가중 신뢰도를 계산한다.
577
+ * 동일 발견이 여러 scout에서 보고되면 신뢰도가 높다.
578
+ *
579
+ * @param {Array<{agentName:string, findings:Array<{description:string}>}>} scoutReports
580
+ * @returns {Array<{description:string, confidence:number, reporters:string[]}>}
581
+ */
582
+ export function weightedConsensus(scoutReports) {
583
+ if (!Array.isArray(scoutReports) || scoutReports.length === 0) return [];
584
+
585
+ const totalScouts = scoutReports.length;
586
+ // {normDesc -> {description, reporters: Set}}
587
+ const consensusMap = new Map();
588
+
589
+ for (const report of scoutReports) {
590
+ const agent = String(report.agentName || "unknown");
591
+ const findings = Array.isArray(report.findings) ? report.findings : [];
592
+ for (const f of findings) {
593
+ const norm = normalizeText(f.description);
594
+ let matched = false;
595
+ for (const [key, entry] of consensusMap) {
596
+ if (jaccardSimilarity(norm, key) >= 0.8) {
597
+ entry.reporters.add(agent);
598
+ matched = true;
599
+ break;
600
+ }
601
+ }
602
+ if (!matched) {
603
+ consensusMap.set(norm, {
604
+ description: f.description,
605
+ reporters: new Set([agent]),
606
+ });
607
+ }
608
+ }
609
+ }
610
+
611
+ return Array.from(consensusMap.values()).map((entry) => ({
612
+ description: entry.description,
613
+ confidence: Math.round((entry.reporters.size / totalScouts) * 100) / 100,
614
+ reporters: Array.from(entry.reporters),
615
+ }));
616
+ }
617
+
618
+ // ── Slop detection helper ───────────────────────────────────────
619
+
620
+ const SLOP_REPEAT_THRESHOLD = 3;
621
+
622
+ /**
623
+ * 텍스트에서 동일 패턴이 SLOP_REPEAT_THRESHOLD회 이상 반복되는지 판정한다.
624
+ * 줄 단위로 정규화하여 비교.
625
+ * @param {string} text
626
+ * @returns {boolean}
627
+ */
628
+ function detectSlop(text) {
629
+ const lines = String(text || "")
630
+ .split("\n")
631
+ .map(normalizeText)
632
+ .filter((l) => l.length > 15); // 너무 짧은 줄은 무시
633
+ const counts = new Map();
634
+ for (const line of lines) {
635
+ counts.set(line, (counts.get(line) || 0) + 1);
636
+ if (counts.get(line) >= SLOP_REPEAT_THRESHOLD) return true;
637
+ }
638
+ return false;
639
+ }
640
+
445
641
  /**
446
642
  * 팀 이름 생성 (타임스탬프 기반)
447
643
  * @returns {string}
@@ -8,17 +8,20 @@
8
8
  * strategy: "quick_single" | "thorough_single" | "quick_team" | "thorough_team" | "batch_single",
9
9
  * reason: string,
10
10
  * dag_width: number,
11
- * max_complexity: string
11
+ * max_complexity: string,
12
+ * dagContext: { dag_width: number, levels: Record<number, string[]>, edges: Array<{from:string, to:string}>, max_complexity: string, taskResults: Record<string, *> }
12
13
  * }}
13
14
  */
14
15
  export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
15
16
  const N = subtasks.length;
16
17
  if (N === 0) {
17
- return { strategy: 'quick_single', reason: 'empty_subtasks', dag_width: 0, max_complexity: 'S' };
18
+ const dagContext = { dag_width: 0, levels: {}, edges: [], max_complexity: 'S', taskResults: {} };
19
+ return { strategy: 'quick_single', reason: 'empty_subtasks', dag_width: 0, max_complexity: 'S', dagContext };
18
20
  }
19
21
 
20
- const dag_width = computeDagWidth(subtasks, graph_type);
22
+ const { width: dag_width, levels, edges } = computeDagInfo(subtasks, graph_type);
21
23
  const max_complexity = getMaxComplexity(subtasks);
24
+ const dagContext = { dag_width, levels, edges, max_complexity, taskResults: {} };
22
25
  const isHighComplexity = ['L', 'XL'].includes(max_complexity);
23
26
  const allSameAgent = new Set(subtasks.map((s) => s.agent)).size === 1;
24
27
  const allSmall = subtasks.every((s) => normalizeComplexity(s.complexity) === 'S');
@@ -31,6 +34,7 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
31
34
  reason: 'single_high_complexity',
32
35
  dag_width,
33
36
  max_complexity,
37
+ dagContext,
34
38
  };
35
39
  }
36
40
  return {
@@ -38,6 +42,7 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
38
42
  reason: 'single_low_complexity',
39
43
  dag_width,
40
44
  max_complexity,
45
+ dagContext,
41
46
  };
42
47
  }
43
48
 
@@ -49,6 +54,7 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
49
54
  reason: 'sequential_chain',
50
55
  dag_width,
51
56
  max_complexity,
57
+ dagContext,
52
58
  };
53
59
  }
54
60
  return {
@@ -56,6 +62,7 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
56
62
  reason: 'sequential_chain',
57
63
  dag_width,
58
64
  max_complexity,
65
+ dagContext,
59
66
  };
60
67
  }
61
68
 
@@ -66,6 +73,7 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
66
73
  reason: 'same_agent_small_batch',
67
74
  dag_width,
68
75
  max_complexity,
76
+ dagContext,
69
77
  };
70
78
  }
71
79
 
@@ -76,6 +84,7 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
76
84
  reason: 'parallel_high_complexity',
77
85
  dag_width,
78
86
  max_complexity,
87
+ dagContext,
79
88
  };
80
89
  }
81
90
  return {
@@ -83,31 +92,43 @@ export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
83
92
  reason: 'parallel_low_complexity',
84
93
  dag_width,
85
94
  max_complexity,
95
+ dagContext,
86
96
  };
87
97
  }
88
98
 
89
99
  /**
90
- * DAG 계산 - 레벨별 최대 병렬 태스크
100
+ * DAG 정보 계산 레벨별 태스크 배열, 간선, 최대 폭
91
101
  * @param {Array<{id:string, depends_on?:string[]}>} subtasks
92
102
  * @param {string} graph_type
93
- * @returns {number}
103
+ * @returns {{ width: number, levels: Record<number, string[]>, edges: Array<{from:string, to:string}> }}
94
104
  */
95
- function computeDagWidth(subtasks, graph_type) {
96
- if (graph_type === 'SEQUENTIAL') return 1;
97
- if (graph_type === 'INDEPENDENT') return subtasks.length;
105
+ function computeDagInfo(subtasks, graph_type) {
106
+ if (graph_type === 'SEQUENTIAL') {
107
+ const levels = {};
108
+ const edges = [];
109
+ subtasks.forEach((t, i) => {
110
+ levels[i] = [t.id];
111
+ if (i > 0) edges.push({ from: subtasks[i - 1].id, to: t.id });
112
+ });
113
+ return { width: 1, levels, edges };
114
+ }
115
+ if (graph_type === 'INDEPENDENT') {
116
+ const levels = { 0: subtasks.map((t) => t.id) };
117
+ return { width: subtasks.length, levels, edges: [] };
118
+ }
98
119
 
99
120
  // DAG: 레벨별 계산 (순환 의존 방어)
100
- const levels = {};
121
+ const taskLevels = {};
101
122
  const visiting = new Set();
102
123
 
103
124
  function getLevel(task) {
104
- if (levels[task.id] !== undefined) return levels[task.id];
125
+ if (taskLevels[task.id] !== undefined) return taskLevels[task.id];
105
126
  if (visiting.has(task.id)) {
106
- levels[task.id] = 0; // 순환 끊기
127
+ taskLevels[task.id] = 0; // 순환 끊기
107
128
  return 0;
108
129
  }
109
130
  if (!task.depends_on || task.depends_on.length === 0) {
110
- levels[task.id] = 0;
131
+ taskLevels[task.id] = 0;
111
132
  return 0;
112
133
  }
113
134
  visiting.add(task.id);
@@ -116,17 +137,65 @@ function computeDagWidth(subtasks, graph_type) {
116
137
  return dep ? getLevel(dep) : 0;
117
138
  });
118
139
  visiting.delete(task.id);
119
- levels[task.id] = Math.max(...depLevels) + 1;
120
- return levels[task.id];
140
+ taskLevels[task.id] = Math.max(...depLevels) + 1;
141
+ return taskLevels[task.id];
121
142
  }
122
143
 
123
144
  subtasks.forEach(getLevel);
124
145
 
125
- const levelCounts = {};
126
- for (const level of Object.values(levels)) {
127
- levelCounts[level] = (levelCounts[level] || 0) + 1;
146
+ // 레벨별 태스크 그룹핑
147
+ const levels = {};
148
+ for (const [id, level] of Object.entries(taskLevels)) {
149
+ if (!levels[level]) levels[level] = [];
150
+ levels[level].push(id);
151
+ }
152
+
153
+ // 간선 수집
154
+ const edges = [];
155
+ for (const task of subtasks) {
156
+ if (task.depends_on) {
157
+ for (const depId of task.depends_on) {
158
+ edges.push({ from: depId, to: task.id });
159
+ }
160
+ }
128
161
  }
129
- return Math.max(...Object.values(levelCounts), 1);
162
+
163
+ const width = Math.max(...Object.values(levels).map((arr) => arr.length), 1);
164
+ return { width, levels, edges };
165
+ }
166
+
167
+ /**
168
+ * 선행 태스크의 결과를 dagContext.edges에서 조회하여 반환
169
+ * @param {string} taskId - 조회 대상 태스크 ID
170
+ * @param {{ dagContext?: { edges: Array<{from:string, to:string}>, taskResults: Record<string, *> } }} pipelineState
171
+ * @returns {Record<string, *>} 선행 태스크 ID → 결과 매핑
172
+ */
173
+ export function getUpstreamResults(taskId, pipelineState) {
174
+ const ctx = pipelineState?.dagContext;
175
+ if (!ctx) return {};
176
+ const upstreamIds = ctx.edges.filter((e) => e.to === taskId).map((e) => e.from);
177
+ const results = {};
178
+ for (const id of upstreamIds) {
179
+ if (id in (ctx.taskResults || {})) {
180
+ results[id] = ctx.taskResults[id];
181
+ }
182
+ }
183
+ return results;
184
+ }
185
+
186
+ /**
187
+ * 태스크 완료 시 결과를 dagContext에 기록
188
+ * @param {string} taskId - 완료된 태스크 ID
189
+ * @param {*} result - 태스크 결과
190
+ * @param {{ dagContext?: { taskResults: Record<string, *> } }} pipelineState
191
+ * @returns {boolean} 기록 성공 여부
192
+ */
193
+ export function updateTaskResult(taskId, result, pipelineState) {
194
+ const ctx = pipelineState?.dagContext;
195
+ if (!ctx) return false;
196
+ if (!ctx.taskResults) ctx.taskResults = {};
197
+ ctx.taskResults[taskId] = result;
198
+ return true;
130
199
  }
131
200
 
132
201
  /**
@@ -1410,6 +1410,17 @@ function scanGeminiSessionTokens() {
1410
1410
  // ============================================================================
1411
1411
  // 라인 렌더러
1412
1412
  // ============================================================================
1413
+ // 최근 벤치마크 diff 파일 읽기 (cx-auto-tokens/diffs/ 에서 최신 1개)
1414
+ function readLatestBenchmarkDiff() {
1415
+ const diffsDir = join(homedir(), ".omc", "state", "cx-auto-tokens", "diffs");
1416
+ if (!existsSync(diffsDir)) return null;
1417
+ try {
1418
+ const files = readdirSync(diffsDir).filter(f => f.endsWith(".json")).sort().reverse();
1419
+ if (files.length === 0) return null;
1420
+ return readJson(join(diffsDir, files[0]), null);
1421
+ } catch { return null; }
1422
+ }
1423
+
1413
1424
  // 토큰 절약액 누적치 읽기 (tfx-auto token tracker)
1414
1425
  function readTokenSavings() {
1415
1426
  const savingsPath = join(homedir(), ".omc", "state", "tfx-auto-tokens", "savings-total.json");
@@ -1429,6 +1440,29 @@ function formatSavings(dollars) {
1429
1440
  return `$${dollars.toFixed(2)}`;
1430
1441
  }
1431
1442
 
1443
+ /**
1444
+ * 파이프라인 벤치마크 diff 결과를 HUD 요약 문자열로 포맷
1445
+ * @param {object} diff - computeDiff() 반환 결과
1446
+ * @returns {string} 토큰 소비 요약 (input/output, 비용, 절감률)
1447
+ */
1448
+ function formatTokenSummary(diff) {
1449
+ if (!diff?.delta?.total || !diff?.savings) return "";
1450
+ const t = diff.delta.total;
1451
+ const s = diff.savings;
1452
+
1453
+ const inputStr = formatTokenCount(t.input);
1454
+ const outputStr = formatTokenCount(t.output);
1455
+ const actualStr = formatSavings(s.actualCost);
1456
+ const claudeStr = formatSavings(s.claudeCost);
1457
+ const savedPct = s.claudeCost > 0
1458
+ ? Math.round((s.saved / s.claudeCost) * 100)
1459
+ : 0;
1460
+
1461
+ return `${dim("tok:")}${inputStr}${dim("in")} ${outputStr}${dim("out")} ` +
1462
+ `${dim("cost:")}${actualStr} ` +
1463
+ `${dim("sv:")}${green(formatSavings(s.saved))}${dim("(")}${savedPct}%${dim(")")}`;
1464
+ }
1465
+
1432
1466
  // sv 퍼센트 포맷 (1000+ → k 표기, 5자 고정폭)
1433
1467
  const SV_CELL_WIDTH = 5;
1434
1468
  function formatSvPct(value) {
@@ -1800,6 +1834,15 @@ async function main() {
1800
1834
  const teamRow = getTeamRow();
1801
1835
  if (teamRow) rows.push(teamRow);
1802
1836
 
1837
+ // 최근 벤치마크 diff → 토큰 요약 행 추가
1838
+ const latestDiff = readLatestBenchmarkDiff();
1839
+ if (latestDiff) {
1840
+ const summary = formatTokenSummary(latestDiff);
1841
+ if (summary) {
1842
+ rows.push({ prefix: `${dim("$")}:`, left: summary, right: "" });
1843
+ }
1844
+ }
1845
+
1803
1846
  // 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
1804
1847
  const codexActive = codexBuckets != null;
1805
1848
  const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "5.0.3",
3
+ "version": "5.1.1",
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": {
@@ -637,23 +637,14 @@ route_agent() {
637
637
  CLI_ARGS="-m gemini-3-flash-preview -y --prompt"
638
638
  CLI_EFFORT="flash"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
639
639
 
640
- # ─── 탐색/검증/테스트 (Codex 우선, claude-native fallback은 apply_cli_mode auto에서) ───
641
- explore)
642
- CLI_TYPE="codex"; CLI_CMD="codex"
643
- CLI_ARGS="exec --profile fast ${codex_base}"
644
- CLI_EFFORT="fast"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
645
- verifier)
646
- CLI_TYPE="codex"; CLI_CMD="codex"
647
- CLI_ARGS="exec --profile thorough ${codex_base} review"
648
- CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
649
- test-engineer)
650
- CLI_TYPE="codex"; CLI_CMD="codex"
651
- CLI_ARGS="exec ${codex_base}"
652
- CLI_EFFORT="high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
653
- qa-tester)
654
- CLI_TYPE="codex"; CLI_CMD="codex"
655
- CLI_ARGS="exec --profile thorough ${codex_base} review"
656
- CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
640
+ # ─── 탐색/검증/테스트 (Claude-native 우선, TFX_NO_CLAUDE_NATIVE=1일 때만 Codex 리매핑) ───
641
+ explore|verifier|test-engineer|qa-tester)
642
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
643
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
644
+ case "$agent" in
645
+ test-engineer|qa-tester) DEFAULT_TIMEOUT=1200; RUN_MODE="bg" ;;
646
+ esac
647
+ ;;
657
648
 
658
649
  # ─── 경량 ───
659
650
  spark)
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
13
13
  import { join, dirname } from "node:path";
14
14
  import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
15
16
 
16
17
  const HOME = homedir();
17
18
  const STATE_DIR = join(HOME, ".omc", "state", "cx-auto-tokens");
@@ -519,7 +520,18 @@ function generateReport(sessionId) {
519
520
  return reportData;
520
521
  }
521
522
 
522
- // ── CLI 핸들러 ──
523
+ // ── Named exports (파이프라인 벤치마크 훅용) ──
524
+ export { takeSnapshot, computeDiff, estimateSavings, formatTokenCount, formatCost, DIFFS_DIR, STATE_DIR };
525
+
526
+ // ── CLI 핸들러 (직접 실행 시에만) ──
527
+ const __filename = fileURLToPath(import.meta.url);
528
+ const isDirectRun = process.argv[1] && join(dirname(process.argv[1])) === dirname(__filename)
529
+ && process.argv[1].endsWith("token-snapshot.mjs");
530
+
531
+ if (!isDirectRun) {
532
+ // imported as module — skip CLI
533
+ } else {
534
+
523
535
  const [,, command, ...args] = process.argv;
524
536
 
525
537
  switch (command) {
@@ -559,3 +571,5 @@ switch (command) {
559
571
  node token-snapshot.mjs report <session-id> 종합 보고서 생성
560
572
  (session-id 대신 "all"로 전체 보고서)`);
561
573
  }
574
+
575
+ } // end isDirectRun guard