triflux 5.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/pipeline/index.mjs +76 -0
- package/hub/team/native.mjs +253 -57
- package/hub/team/routing.mjs +87 -18
- package/hud/hud-qos-status.mjs +43 -0
- package/package.json +1 -1
- package/scripts/tfx-route.sh +24 -48
- package/scripts/token-snapshot.mjs +15 -1
package/hub/pipeline/index.mjs
CHANGED
|
@@ -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';
|
package/hub/team/native.mjs
CHANGED
|
@@ -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}
|
package/hub/team/routing.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
96
|
-
if (graph_type === 'SEQUENTIAL')
|
|
97
|
-
|
|
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
|
|
121
|
+
const taskLevels = {};
|
|
101
122
|
const visiting = new Set();
|
|
102
123
|
|
|
103
124
|
function getLevel(task) {
|
|
104
|
-
if (
|
|
125
|
+
if (taskLevels[task.id] !== undefined) return taskLevels[task.id];
|
|
105
126
|
if (visiting.has(task.id)) {
|
|
106
|
-
|
|
127
|
+
taskLevels[task.id] = 0; // 순환 끊기
|
|
107
128
|
return 0;
|
|
108
129
|
}
|
|
109
130
|
if (!task.depends_on || task.depends_on.length === 0) {
|
|
110
|
-
|
|
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
|
-
|
|
120
|
-
return
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -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
package/scripts/tfx-route.sh
CHANGED
|
@@ -461,37 +461,22 @@ team_claim_task() {
|
|
|
461
461
|
esac
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
team_complete_task() {
|
|
465
|
-
local result="${1:-success}" # success/failed/timeout
|
|
466
|
-
local result_summary="${2:-작업 완료}"
|
|
467
|
-
[[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
|
|
468
|
-
|
|
469
|
-
local summary_trimmed
|
|
470
|
-
summary_trimmed=$(echo "$result_summary" | head -c 4096)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
#
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
--task-id "$TFX_TEAM_TASK_ID" \
|
|
481
|
-
--status completed \
|
|
482
|
-
--owner "$TFX_TEAM_AGENT_NAME" \
|
|
483
|
-
--metadata-patch "$metadata_patch"; then
|
|
484
|
-
echo "[tfx-route] 경고: 팀 task 완료 보고 실패 (team=$TFX_TEAM_NAME, task=$TFX_TEAM_TASK_ID, result=$result)" >&2
|
|
485
|
-
fi
|
|
486
|
-
fi
|
|
487
|
-
|
|
488
|
-
# 리드에게 메시지 전송
|
|
489
|
-
team_send_message "$summary_trimmed" "task ${TFX_TEAM_TASK_ID} ${result}"
|
|
490
|
-
|
|
491
|
-
# Hub result 발행 (poll_messages 채널 활성화)
|
|
492
|
-
if [[ -n "$result_payload" ]]; then
|
|
493
|
-
if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
|
|
494
|
-
result \
|
|
464
|
+
team_complete_task() {
|
|
465
|
+
local result="${1:-success}" # success/failed/timeout
|
|
466
|
+
local result_summary="${2:-작업 완료}"
|
|
467
|
+
[[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
|
|
468
|
+
|
|
469
|
+
local summary_trimmed result_payload
|
|
470
|
+
summary_trimmed=$(echo "$result_summary" | head -c 4096)
|
|
471
|
+
result_payload=$(bridge_json_stringify task-result "$TFX_TEAM_TASK_ID" "$result" 2>/dev/null || true)
|
|
472
|
+
|
|
473
|
+
# task 파일 completion 쓰기는 Worker Step 6 TaskUpdate가 authority다.
|
|
474
|
+
# route 레벨에서는 task.result 발행 + 로컬 backup만 유지한다.
|
|
475
|
+
|
|
476
|
+
# Hub result 발행 (poll_messages 채널 활성화)
|
|
477
|
+
if [[ -n "$result_payload" ]]; then
|
|
478
|
+
if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
|
|
479
|
+
result \
|
|
495
480
|
--agent "$TFX_TEAM_AGENT_NAME" \
|
|
496
481
|
--topic task.result \
|
|
497
482
|
--payload "$result_payload" \
|
|
@@ -652,23 +637,14 @@ route_agent() {
|
|
|
652
637
|
CLI_ARGS="-m gemini-3-flash-preview -y --prompt"
|
|
653
638
|
CLI_EFFORT="flash"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
654
639
|
|
|
655
|
-
# ─── 탐색/검증/테스트 (
|
|
656
|
-
explore)
|
|
657
|
-
CLI_TYPE="
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
664
|
-
test-engineer)
|
|
665
|
-
CLI_TYPE="codex"; CLI_CMD="codex"
|
|
666
|
-
CLI_ARGS="exec ${codex_base}"
|
|
667
|
-
CLI_EFFORT="high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
668
|
-
qa-tester)
|
|
669
|
-
CLI_TYPE="codex"; CLI_CMD="codex"
|
|
670
|
-
CLI_ARGS="exec --profile thorough ${codex_base} review"
|
|
671
|
-
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
|
+
;;
|
|
672
648
|
|
|
673
649
|
# ─── 경량 ───
|
|
674
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
|
-
// ──
|
|
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
|