@triflux/remote 10.0.0-alpha.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.
- package/hub/pipe.mjs +579 -0
- package/hub/public/dashboard.html +355 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/server.mjs +1124 -0
- package/hub/store-adapter.mjs +851 -0
- package/hub/store.mjs +897 -0
- package/hub/team/agent-map.json +11 -0
- package/hub/team/ansi.mjs +379 -0
- package/hub/team/backend.mjs +90 -0
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +106 -0
- package/hub/team/cli/commands/start/parse-args.mjs +130 -0
- package/hub/team/cli/commands/start/start-headless.mjs +109 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/cli/help.mjs +42 -0
- package/hub/team/cli/index.mjs +41 -0
- package/hub/team/cli/manifest.mjs +29 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +208 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +117 -0
- package/hub/team/cli/services/runtime-mode.mjs +62 -0
- package/hub/team/cli/services/state-store.mjs +48 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/dashboard-anchor.mjs +14 -0
- package/hub/team/dashboard-layout.mjs +33 -0
- package/hub/team/dashboard-open.mjs +153 -0
- package/hub/team/dashboard.mjs +274 -0
- package/hub/team/handoff.mjs +303 -0
- package/hub/team/headless.mjs +1149 -0
- package/hub/team/native-supervisor.mjs +392 -0
- package/hub/team/native.mjs +649 -0
- package/hub/team/nativeProxy.mjs +681 -0
- package/hub/team/orchestrator.mjs +161 -0
- package/hub/team/pane.mjs +153 -0
- package/hub/team/psmux.mjs +1354 -0
- package/hub/team/routing.mjs +223 -0
- package/hub/team/session.mjs +611 -0
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +361 -0
- package/hub/team/tui-lite.mjs +380 -0
- package/hub/team/tui-viewer.mjs +463 -0
- package/hub/team/tui.mjs +1245 -0
- package/hub/tools.mjs +554 -0
- package/hub/tray.mjs +376 -0
- package/hub/workers/claude-worker.mjs +475 -0
- package/hub/workers/codex-mcp.mjs +504 -0
- package/hub/workers/delegator-mcp.mjs +1076 -0
- package/hub/workers/factory.mjs +21 -0
- package/hub/workers/gemini-worker.mjs +373 -0
- package/hub/workers/interface.mjs +52 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/package.json +31 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hub/team/dashboard.mjs — 실시간 팀 상태 표시 (v2.2)
|
|
3
|
+
// tmux 의존 제거 — Hub task-list + native-supervisor 기반
|
|
4
|
+
//
|
|
5
|
+
// 실행:
|
|
6
|
+
// node hub/team/dashboard.mjs --session <세션이름> [--interval 2]
|
|
7
|
+
// node hub/team/dashboard.mjs --team <팀이름> [--interval 2]
|
|
8
|
+
import { get } from "node:http";
|
|
9
|
+
import { AMBER, GREEN, RED, GRAY, DIM, BOLD, RESET } from "./shared.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* HTTP GET JSON
|
|
13
|
+
* @param {string} url
|
|
14
|
+
* @returns {Promise<object|null>}
|
|
15
|
+
*/
|
|
16
|
+
function fetchJson(url) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const req = get(url, { timeout: 2000 }, (res) => {
|
|
19
|
+
let data = "";
|
|
20
|
+
res.on("data", (chunk) => (data += chunk));
|
|
21
|
+
res.on("end", () => {
|
|
22
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
req.on("error", () => resolve(null));
|
|
26
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* HTTP POST JSON (Hub bridge 용)
|
|
32
|
+
* @param {string} url
|
|
33
|
+
* @param {object} body
|
|
34
|
+
* @returns {Promise<object|null>}
|
|
35
|
+
*/
|
|
36
|
+
async function fetchPost(url, body = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
signal: AbortSignal.timeout(2000),
|
|
43
|
+
});
|
|
44
|
+
return await res.json();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 진행률 바 생성
|
|
52
|
+
* @param {number} pct — 0~100
|
|
53
|
+
* @param {number} width — 바 너비 (기본 8)
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function progressBar(pct, width = 8) {
|
|
57
|
+
const filled = Math.round((pct / 100) * width);
|
|
58
|
+
const empty = width - filled;
|
|
59
|
+
return `${GREEN}${"█".repeat(filled)}${GRAY}${"░".repeat(empty)}${RESET}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 업타임 포맷
|
|
64
|
+
* @param {number} ms
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function formatUptime(ms) {
|
|
68
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}초`;
|
|
69
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}분`;
|
|
70
|
+
return `${Math.round(ms / 3600000)}시간`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* task 상태 아이콘
|
|
75
|
+
* @param {string} status
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function statusIcon(status) {
|
|
79
|
+
switch (status) {
|
|
80
|
+
case "completed": return `${GREEN}✓${RESET}`;
|
|
81
|
+
case "in_progress": return `${AMBER}●${RESET}`;
|
|
82
|
+
case "failed": return `${RED}✗${RESET}`;
|
|
83
|
+
default: return `${GRAY}○${RESET}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 멤버 목록 구성: Hub tasks + supervisor + teamState 통합
|
|
89
|
+
* @param {Array} hubTasks — Hub bridge task-list 결과
|
|
90
|
+
* @param {Array} supervisorMembers — native-supervisor 멤버 상태
|
|
91
|
+
* @param {object} teamState — team-state.json 내용
|
|
92
|
+
* @returns {Array<{name: string, cli: string, status: string, subject: string, preview: string}>}
|
|
93
|
+
*/
|
|
94
|
+
function buildMemberList(hubTasks, supervisorMembers, teamState) {
|
|
95
|
+
const members = [];
|
|
96
|
+
const supervisorByName = new Map(supervisorMembers.map((m) => [m.name, m]));
|
|
97
|
+
|
|
98
|
+
// Hub tasks가 있으면 주 데이터 소스
|
|
99
|
+
if (hubTasks.length > 0) {
|
|
100
|
+
for (const task of hubTasks) {
|
|
101
|
+
const owner = task.owner || task.subject || "";
|
|
102
|
+
const sup = supervisorByName.get(owner);
|
|
103
|
+
members.push({
|
|
104
|
+
name: owner,
|
|
105
|
+
cli: task.metadata?.cli || sup?.cli || "",
|
|
106
|
+
status: task.status || "pending",
|
|
107
|
+
subject: task.subject || "",
|
|
108
|
+
preview: sup?.lastPreview || task.description?.slice(0, 80) || "",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return members;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Supervisor 데이터 폴백
|
|
115
|
+
if (supervisorMembers.length > 0) {
|
|
116
|
+
for (const m of supervisorMembers) {
|
|
117
|
+
if (m.role === "lead") continue;
|
|
118
|
+
members.push({
|
|
119
|
+
name: m.name,
|
|
120
|
+
cli: m.cli || "",
|
|
121
|
+
status: m.status === "running" ? "in_progress" : m.status === "exited" ? "completed" : m.status,
|
|
122
|
+
subject: "",
|
|
123
|
+
preview: m.lastPreview || "",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return members;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// teamState 폴백 (하위 호환)
|
|
130
|
+
const panes = teamState?.panes || {};
|
|
131
|
+
for (const [, paneInfo] of Object.entries(panes).filter(([, v]) => v.role !== "dashboard" && v.role !== "lead")) {
|
|
132
|
+
members.push({
|
|
133
|
+
name: paneInfo.agentId || paneInfo.name || "?",
|
|
134
|
+
cli: paneInfo.cli || "",
|
|
135
|
+
status: "unknown",
|
|
136
|
+
subject: paneInfo.subtask || "",
|
|
137
|
+
preview: "",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return members;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 대시보드 렌더링 (v2.2: Hub/supervisor 기반)
|
|
145
|
+
* @param {string} sessionName — 세션 또는 팀 이름
|
|
146
|
+
* @param {object} opts
|
|
147
|
+
* @param {string} opts.hubUrl — Hub URL (기본 http://127.0.0.1:27888)
|
|
148
|
+
* @param {string} [opts.teamName] — Hub task-list 조회용 팀 이름
|
|
149
|
+
* @param {string} [opts.supervisorUrl] — native-supervisor 제어 URL
|
|
150
|
+
* @param {object} [opts.teamState] — team-state.json 내용 (하위 호환)
|
|
151
|
+
*/
|
|
152
|
+
export async function renderDashboard(sessionName, opts = {}) {
|
|
153
|
+
const {
|
|
154
|
+
hubUrl = "http://127.0.0.1:27888",
|
|
155
|
+
teamName,
|
|
156
|
+
supervisorUrl,
|
|
157
|
+
teamState = {},
|
|
158
|
+
} = opts;
|
|
159
|
+
const W = 50;
|
|
160
|
+
const border = "─".repeat(W);
|
|
161
|
+
|
|
162
|
+
// 데이터 수집 (병렬)
|
|
163
|
+
const [hubStatus, taskListRes, supervisorRes] = await Promise.all([
|
|
164
|
+
fetchJson(`${hubUrl}/status`),
|
|
165
|
+
teamName ? fetchPost(`${hubUrl}/bridge/team/task-list`, { team_name: teamName }) : null,
|
|
166
|
+
supervisorUrl ? fetchJson(`${supervisorUrl}/status`) : null,
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const hubOnline = !!hubStatus;
|
|
170
|
+
const hubState = hubOnline ? `${GREEN}● online${RESET}` : `${RED}● offline${RESET}`;
|
|
171
|
+
const uptime = hubStatus?.hub?.uptime ? formatUptime(hubStatus.hub.uptime) : "-";
|
|
172
|
+
const queueSize = hubStatus?.hub?.queue_depth ?? 0;
|
|
173
|
+
|
|
174
|
+
// Hub task 데이터
|
|
175
|
+
const hubTasks = taskListRes?.ok ? (taskListRes.data?.tasks || []) : [];
|
|
176
|
+
const completedCount = hubTasks.filter((t) => t.status === "completed").length;
|
|
177
|
+
const totalCount = hubTasks.length;
|
|
178
|
+
|
|
179
|
+
// Supervisor 멤버 데이터
|
|
180
|
+
const supervisorMembers = supervisorRes?.ok ? (supervisorRes.data?.members || []) : [];
|
|
181
|
+
|
|
182
|
+
// 헤더
|
|
183
|
+
const progress = totalCount > 0 ? ` ${completedCount}/${totalCount}` : "";
|
|
184
|
+
console.log(`${AMBER}┌─ ${sessionName}${progress} ${GRAY}${"─".repeat(Math.max(0, W - sessionName.length - progress.length - 3))}${AMBER}┐${RESET}`);
|
|
185
|
+
console.log(`${AMBER}│${RESET} Hub: ${hubState} Uptime: ${DIM}${uptime}${RESET} Queue: ${DIM}${queueSize}${RESET}`);
|
|
186
|
+
console.log(`${AMBER}│${RESET}`);
|
|
187
|
+
|
|
188
|
+
// 멤버/워커 렌더링
|
|
189
|
+
const members = buildMemberList(hubTasks, supervisorMembers, teamState);
|
|
190
|
+
|
|
191
|
+
if (members.length === 0) {
|
|
192
|
+
console.log(`${AMBER}│${RESET} ${DIM}에이전트 정보 없음${RESET}`);
|
|
193
|
+
} else {
|
|
194
|
+
for (const m of members) {
|
|
195
|
+
const icon = statusIcon(m.status);
|
|
196
|
+
const label = `[${m.name}]`;
|
|
197
|
+
const cliTag = m.cli ? m.cli.charAt(0).toUpperCase() + m.cli.slice(1) : "";
|
|
198
|
+
|
|
199
|
+
// 진행률 추정
|
|
200
|
+
const pct = m.status === "completed" ? 100
|
|
201
|
+
: m.status === "in_progress" ? 50
|
|
202
|
+
: m.status === "failed" ? 100
|
|
203
|
+
: 0;
|
|
204
|
+
|
|
205
|
+
console.log(`${AMBER}│${RESET} ${BOLD}${label}${RESET} ${cliTag} ${icon} ${m.status || "pending"} ${progressBar(pct)}`);
|
|
206
|
+
|
|
207
|
+
// 미리보기: supervisor lastPreview > task subject
|
|
208
|
+
const preview = m.preview || m.subject || "";
|
|
209
|
+
if (preview) {
|
|
210
|
+
const truncated = preview.length > W - 8 ? preview.slice(0, W - 11) + "..." : preview;
|
|
211
|
+
console.log(`${AMBER}│${RESET} ${DIM}> ${truncated}${RESET}`);
|
|
212
|
+
}
|
|
213
|
+
console.log(`${AMBER}│${RESET}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 푸터
|
|
218
|
+
console.log(`${AMBER}└${GRAY}${border}${AMBER}┘${RESET}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** team-state.json 로드 (세션별 파일 우선, fallback: team-state.json) */
|
|
222
|
+
async function loadTeamState() {
|
|
223
|
+
try {
|
|
224
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
225
|
+
const { join } = await import("node:path");
|
|
226
|
+
const { homedir } = await import("node:os");
|
|
227
|
+
const hubDir = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
228
|
+
const sessionId = process.env.CLAUDE_SESSION_ID;
|
|
229
|
+
if (sessionId) {
|
|
230
|
+
const sessionPath = join(hubDir, `team-state-${sessionId}.json`);
|
|
231
|
+
if (existsSync(sessionPath)) return JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
232
|
+
}
|
|
233
|
+
const legacyPath = join(hubDir, "team-state.json");
|
|
234
|
+
if (existsSync(legacyPath)) return JSON.parse(readFileSync(legacyPath, "utf8"));
|
|
235
|
+
return {};
|
|
236
|
+
} catch {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── CLI 실행 ──
|
|
242
|
+
if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
243
|
+
const sessionIdx = process.argv.indexOf("--session");
|
|
244
|
+
const teamIdx = process.argv.indexOf("--team");
|
|
245
|
+
const sessionName = sessionIdx !== -1 ? process.argv[sessionIdx + 1] : null;
|
|
246
|
+
const teamName = teamIdx !== -1 ? process.argv[teamIdx + 1] : null;
|
|
247
|
+
const intervalSec = parseInt(process.argv[process.argv.indexOf("--interval") + 1] || "2", 10);
|
|
248
|
+
|
|
249
|
+
const displayName = sessionName || teamName;
|
|
250
|
+
if (!displayName) {
|
|
251
|
+
console.error("사용법: node dashboard.mjs --session <세션이름> [--team <팀이름>] [--interval 2]");
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Ctrl+C로 종료
|
|
256
|
+
process.on("SIGINT", () => process.exit(0));
|
|
257
|
+
|
|
258
|
+
// 갱신 루프
|
|
259
|
+
while (true) {
|
|
260
|
+
const teamState = await loadTeamState();
|
|
261
|
+
const effectiveTeamName = teamName || null;
|
|
262
|
+
const supervisorUrl = teamState?.native?.controlUrl || null;
|
|
263
|
+
|
|
264
|
+
// 화면 클리어 (ANSI)
|
|
265
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
266
|
+
await renderDashboard(displayName, {
|
|
267
|
+
teamName: effectiveTeamName,
|
|
268
|
+
supervisorUrl,
|
|
269
|
+
teamState,
|
|
270
|
+
});
|
|
271
|
+
console.log(`${DIM} ${intervalSec}초 간격 갱신 | Ctrl+C로 종료${RESET}`);
|
|
272
|
+
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// hub/team/handoff.mjs — Worker → Lead handoff 프로토콜
|
|
2
|
+
// HANDOFF 블록 파싱, 검증, fallback 생성, Lead 포맷팅
|
|
3
|
+
// 설계: docs/design/handoff-schema-v7.md
|
|
4
|
+
|
|
5
|
+
// ── enum 허용 값 ──
|
|
6
|
+
const STATUS_VALUES = ["ok", "partial", "failed"];
|
|
7
|
+
const LEAD_ACTION_VALUES = ["accept", "needs_read", "retry", "reassign"];
|
|
8
|
+
const CONFIDENCE_VALUES = ["high", "medium", "low"];
|
|
9
|
+
const RISK_VALUES = ["low", "med", "high"];
|
|
10
|
+
const ERROR_STAGE_VALUES = ["dispatch", "execution", "timeout"];
|
|
11
|
+
const YES_NO = ["yes", "no"];
|
|
12
|
+
|
|
13
|
+
const TOKEN_HARD_CAP = 150;
|
|
14
|
+
const HANDOFF_MARKER = "--- HANDOFF ---";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 워커 프롬프트에 삽입할 HANDOFF 블록 생성 지시
|
|
18
|
+
*/
|
|
19
|
+
export const HANDOFF_INSTRUCTION = `
|
|
20
|
+
After completing the task, you MUST output a HANDOFF block in exactly this format at the end of your response:
|
|
21
|
+
|
|
22
|
+
--- HANDOFF ---
|
|
23
|
+
status: ok | partial | failed
|
|
24
|
+
lead_action: accept | needs_read | retry | reassign
|
|
25
|
+
task: <1-3 word task type>
|
|
26
|
+
files_changed: <comma-separated file paths, or "none">
|
|
27
|
+
verdict: <one sentence conclusion>
|
|
28
|
+
confidence: high | medium | low
|
|
29
|
+
risk: low | med | high
|
|
30
|
+
detail: <result file path if available, or "none">
|
|
31
|
+
|
|
32
|
+
If the task failed, also include:
|
|
33
|
+
error_stage: dispatch | execution | timeout
|
|
34
|
+
retryable: yes | no
|
|
35
|
+
partial_output: yes | no
|
|
36
|
+
|
|
37
|
+
Rules:
|
|
38
|
+
- The HANDOFF block must start with exactly "--- HANDOFF ---"
|
|
39
|
+
- Each field must be on its own line as "key: value"
|
|
40
|
+
- verdict must be a single concise sentence
|
|
41
|
+
- Do not skip any required field
|
|
42
|
+
`.trim();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CLI 프롬프트 길이 제한을 고려한 축약 HANDOFF 지시
|
|
46
|
+
*/
|
|
47
|
+
export const HANDOFF_INSTRUCTION_SHORT =
|
|
48
|
+
`After completing, output this block at the end:
|
|
49
|
+
--- HANDOFF ---
|
|
50
|
+
status: ok | partial | failed
|
|
51
|
+
lead_action: accept | needs_read | retry | reassign
|
|
52
|
+
verdict: <one sentence>
|
|
53
|
+
files_changed: <comma-separated paths or "none">
|
|
54
|
+
confidence: high | medium | low`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* raw 텍스트에서 HANDOFF 블록을 파싱한다.
|
|
58
|
+
* @param {string} rawText
|
|
59
|
+
* @returns {object|null} 파싱된 필드 객체, 블록이 없으면 null
|
|
60
|
+
*/
|
|
61
|
+
export function parseHandoff(rawText) {
|
|
62
|
+
if (!rawText || typeof rawText !== "string") return null;
|
|
63
|
+
|
|
64
|
+
// P1 fix: 마지막 HANDOFF 블록을 파싱 (프롬프트 에코로 인한 중복 마커 대응)
|
|
65
|
+
const markerIdx = rawText.lastIndexOf(HANDOFF_MARKER);
|
|
66
|
+
if (markerIdx === -1) return null;
|
|
67
|
+
|
|
68
|
+
const blockStart = markerIdx + HANDOFF_MARKER.length;
|
|
69
|
+
// 다음 "---" 마커 또는 텍스트 끝까지
|
|
70
|
+
const rest = rawText.slice(blockStart);
|
|
71
|
+
const endIdx = rest.indexOf("\n---");
|
|
72
|
+
const block = endIdx === -1 ? rest : rest.slice(0, endIdx);
|
|
73
|
+
|
|
74
|
+
const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
75
|
+
const parsed = {};
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const colonIdx = line.indexOf(":");
|
|
79
|
+
if (colonIdx === -1) continue;
|
|
80
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/\s+/g, "_");
|
|
81
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
82
|
+
if (key && value) parsed[key] = value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// files_changed: 쉼표 구분 → 배열
|
|
86
|
+
if (parsed.files_changed) {
|
|
87
|
+
if (parsed.files_changed === "none") {
|
|
88
|
+
parsed.files_changed = [];
|
|
89
|
+
} else {
|
|
90
|
+
parsed.files_changed = parsed.files_changed
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((f) => f.trim())
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return Object.keys(parsed).length > 0 ? parsed : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 토큰 수 추정 (rough: chars/4)
|
|
102
|
+
* @param {object} obj
|
|
103
|
+
* @returns {number}
|
|
104
|
+
*/
|
|
105
|
+
function estimateTokens(obj) {
|
|
106
|
+
return Math.ceil(JSON.stringify(obj).length / 4);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* enum 값 검증. 유효하지 않으면 null 반환.
|
|
111
|
+
* @param {string} value
|
|
112
|
+
* @param {string[]} allowed
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
function validateEnum(value, allowed) {
|
|
116
|
+
if (!value) return null;
|
|
117
|
+
const normalized = value.toLowerCase().trim();
|
|
118
|
+
return allowed.includes(normalized) ? normalized : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 파싱된 handoff를 검증하고 정규화한다.
|
|
123
|
+
* @param {object} parsed — parseHandoff() 결과
|
|
124
|
+
* @param {object} [context]
|
|
125
|
+
* @param {number} [context.exitCode]
|
|
126
|
+
* @param {string} [context.resultFile]
|
|
127
|
+
* @param {string[]} [context.gitDiffFiles]
|
|
128
|
+
* @returns {{ handoff: object, valid: boolean, warnings: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
export function validateHandoff(parsed, context = {}) {
|
|
131
|
+
const warnings = [];
|
|
132
|
+
const h = { ...parsed };
|
|
133
|
+
|
|
134
|
+
// context에서 자동 삽입 (누락 필드)
|
|
135
|
+
if (!h.status && context.exitCode !== undefined) {
|
|
136
|
+
h.status = context.exitCode === 0 ? "ok" : "failed";
|
|
137
|
+
warnings.push("status: context.exitCode에서 추론");
|
|
138
|
+
}
|
|
139
|
+
if (!h.detail && context.resultFile) {
|
|
140
|
+
h.detail = context.resultFile;
|
|
141
|
+
warnings.push("detail: context.resultFile에서 추론");
|
|
142
|
+
}
|
|
143
|
+
if (!h.files_changed && context.gitDiffFiles) {
|
|
144
|
+
h.files_changed = context.gitDiffFiles;
|
|
145
|
+
warnings.push("files_changed: context.gitDiffFiles에서 추론");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// enum 검증 + 정규화
|
|
149
|
+
const enumChecks = [
|
|
150
|
+
["status", STATUS_VALUES, "ok"],
|
|
151
|
+
["lead_action", LEAD_ACTION_VALUES, "needs_read"],
|
|
152
|
+
["confidence", CONFIDENCE_VALUES, "low"],
|
|
153
|
+
["risk", RISK_VALUES, "low"],
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
for (const [field, allowed, fallback] of enumChecks) {
|
|
157
|
+
if (h[field]) {
|
|
158
|
+
const validated = validateEnum(h[field], allowed);
|
|
159
|
+
if (!validated) {
|
|
160
|
+
warnings.push(`${field}: "${h[field]}" → fallback "${fallback}"`);
|
|
161
|
+
h[field] = fallback;
|
|
162
|
+
} else {
|
|
163
|
+
h[field] = validated;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 실패 시 추가 필드 검증
|
|
169
|
+
if (h.status === "failed" || h.status === "partial") {
|
|
170
|
+
if (h.error_stage) {
|
|
171
|
+
const v = validateEnum(h.error_stage, ERROR_STAGE_VALUES);
|
|
172
|
+
if (!v) { warnings.push(`error_stage: "${h.error_stage}" invalid`); delete h.error_stage; }
|
|
173
|
+
else h.error_stage = v;
|
|
174
|
+
}
|
|
175
|
+
for (const f of ["retryable", "partial_output"]) {
|
|
176
|
+
if (h[f]) {
|
|
177
|
+
const v = validateEnum(h[f], YES_NO);
|
|
178
|
+
if (!v) { warnings.push(`${f}: "${h[f]}" invalid`); delete h[f]; }
|
|
179
|
+
else h[f] = v;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// P2a: 필수 필드 체크 (핵심 3 + 라우팅용 4)
|
|
185
|
+
const coreRequired = ["status", "lead_action", "verdict"];
|
|
186
|
+
const routingRequired = ["task", "confidence", "risk", "detail"];
|
|
187
|
+
for (const f of coreRequired) {
|
|
188
|
+
if (!h[f]) warnings.push(`missing required: ${f}`);
|
|
189
|
+
}
|
|
190
|
+
for (const f of routingRequired) {
|
|
191
|
+
if (!h[f]) warnings.push(`missing routing field: ${f}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// P2b: 토큰 cap + 트림
|
|
195
|
+
const tokens = estimateTokens(h);
|
|
196
|
+
if (tokens > TOKEN_HARD_CAP) {
|
|
197
|
+
h.lead_action = "needs_read";
|
|
198
|
+
// verdict 트림 (80자 + 말줄임)
|
|
199
|
+
if (h.verdict && h.verdict.length > 80) {
|
|
200
|
+
h.verdict = h.verdict.slice(0, 77) + "...";
|
|
201
|
+
}
|
|
202
|
+
// files_changed 트림 (최대 3개 + "and N more")
|
|
203
|
+
if (Array.isArray(h.files_changed) && h.files_changed.length > 3) {
|
|
204
|
+
const rest = h.files_changed.length - 3;
|
|
205
|
+
h.files_changed = [...h.files_changed.slice(0, 3), `+${rest} more`];
|
|
206
|
+
}
|
|
207
|
+
warnings.push(`token cap exceeded (${tokens} > ${TOKEN_HARD_CAP}), trimmed`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const missingCore = coreRequired.filter((f) => !h[f]);
|
|
211
|
+
const missingRouting = routingRequired.filter((f) => !h[f]);
|
|
212
|
+
const valid = missingCore.length === 0 && missingRouting.length === 0;
|
|
213
|
+
|
|
214
|
+
return { handoff: h, valid, warnings };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 워커가 HANDOFF 블록을 생성하지 않은 경우 fallback 생성.
|
|
219
|
+
* @param {number} exitCode
|
|
220
|
+
* @param {string} resultFile
|
|
221
|
+
* @param {string} [cli]
|
|
222
|
+
* @returns {object}
|
|
223
|
+
*/
|
|
224
|
+
export function buildFallbackHandoff(exitCode, resultFile, cli) {
|
|
225
|
+
const ok = exitCode === 0;
|
|
226
|
+
return {
|
|
227
|
+
status: ok ? "ok" : "failed",
|
|
228
|
+
lead_action: ok ? "accept" : "retry",
|
|
229
|
+
task: "unknown",
|
|
230
|
+
files_changed: [],
|
|
231
|
+
verdict: `${cli || "worker"} completed (exit ${exitCode})`,
|
|
232
|
+
confidence: "low",
|
|
233
|
+
risk: "low",
|
|
234
|
+
detail: resultFile || "none",
|
|
235
|
+
...(ok ? {} : {
|
|
236
|
+
error_stage: exitCode === 124 ? "timeout" : "execution",
|
|
237
|
+
retryable: exitCode === 124 ? "no" : "yes",
|
|
238
|
+
}),
|
|
239
|
+
_fallback: true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Lead stdout용 최소 포맷 (80-120 토큰 목표)
|
|
245
|
+
* @param {object} handoff
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export function formatHandoffForLead(handoff) {
|
|
249
|
+
const h = handoff;
|
|
250
|
+
const files = Array.isArray(h.files_changed)
|
|
251
|
+
? (h.files_changed.length > 0 ? h.files_changed.join(", ") : "none")
|
|
252
|
+
: (h.files_changed || "none");
|
|
253
|
+
|
|
254
|
+
const lines = [
|
|
255
|
+
`[HANDOFF] status=${h.status || "?"} action=${h.lead_action || "?"} confidence=${h.confidence || "?"}`,
|
|
256
|
+
`verdict: ${h.verdict || "(no verdict)"}`,
|
|
257
|
+
`files: ${files}`,
|
|
258
|
+
`detail: ${h.detail || "none"}`,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
if (h.status === "failed" || h.status === "partial") {
|
|
262
|
+
const parts = [];
|
|
263
|
+
if (h.error_stage) parts.push(`stage=${h.error_stage}`);
|
|
264
|
+
if (h.retryable) parts.push(`retryable=${h.retryable}`);
|
|
265
|
+
if (parts.length) lines.push(`error: ${parts.join(" ")}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return lines.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 전체 파이프라인: raw text → parse → validate → format
|
|
273
|
+
* @param {string} rawText — 워커 전체 출력
|
|
274
|
+
* @param {object} [context] — { exitCode, resultFile, gitDiffFiles }
|
|
275
|
+
* @returns {{ handoff: object, formatted: string, valid: boolean, warnings: string[], fallback: boolean }}
|
|
276
|
+
*/
|
|
277
|
+
export function processHandoff(rawText, context = {}) {
|
|
278
|
+
const parsed = parseHandoff(rawText);
|
|
279
|
+
|
|
280
|
+
if (!parsed) {
|
|
281
|
+
const fb = buildFallbackHandoff(
|
|
282
|
+
context.exitCode ?? 1,
|
|
283
|
+
context.resultFile || "none",
|
|
284
|
+
context.cli,
|
|
285
|
+
);
|
|
286
|
+
return {
|
|
287
|
+
handoff: fb,
|
|
288
|
+
formatted: formatHandoffForLead(fb),
|
|
289
|
+
valid: false,
|
|
290
|
+
warnings: ["HANDOFF block not found, using fallback"],
|
|
291
|
+
fallback: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { handoff, valid, warnings } = validateHandoff(parsed, context);
|
|
296
|
+
return {
|
|
297
|
+
handoff,
|
|
298
|
+
formatted: formatHandoffForLead(handoff),
|
|
299
|
+
valid,
|
|
300
|
+
warnings,
|
|
301
|
+
fallback: false,
|
|
302
|
+
};
|
|
303
|
+
}
|