triflux 6.0.21 → 6.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.
@@ -0,0 +1,103 @@
1
+ // hub/reflexion.mjs — Cross-Session Error Learning Engine
2
+ // 에러를 구조화 저장 → 다음 세션에서 유사 에러 패턴 매칭 → 자동 솔루션 적용
3
+
4
+ /**
5
+ * 에러 메시지를 정규화된 패턴 시그니처로 변환
6
+ * 파일 경로, 줄 번호, 타임스탬프, UUID, 숫자 리터럴을 플레이스홀더로 치환
7
+ * @param {string} errorMessage
8
+ * @returns {string}
9
+ */
10
+ export function normalizeError(errorMessage) {
11
+ if (!errorMessage || typeof errorMessage !== 'string') return '';
12
+ let p = errorMessage;
13
+ // Windows 파일 경로 (C:\Users\...) — Unix 경로보다 먼저 처리
14
+ p = p.replace(/[A-Za-z]:\\[\w\\.\-/]+/g, '<FILE>');
15
+ // Unix 파일 경로 (/home/user/file.js)
16
+ p = p.replace(/(?:\/[\w.\-]+){2,}/g, '<FILE>');
17
+ // 줄:컬럼 (file.js:42:10)
18
+ p = p.replace(/:(\d+)(:\d+)?(?=[\s,)\]]|$)/g, ':<LINE>');
19
+ // "line 42" / "Line 42"
20
+ p = p.replace(/\b[Ll]ine\s+\d+/g, 'line <LINE>');
21
+ // ISO 타임스탬프
22
+ p = p.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[\w.+-]*/g, '<TIME>');
23
+ // UUID (8-4-4-4-12) — Unix 타임스탬프보다 먼저 처리 (UUID 내부 숫자 보호)
24
+ p = p.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<ID>');
25
+ // 긴 hex 해시 (32+자리)
26
+ p = p.replace(/\b[0-9a-f]{32,}\b/gi, '<ID>');
27
+ // Unix 타임스탬프 (10-13자리 숫자)
28
+ p = p.replace(/\b\d{10,13}\b/g, '<TIME>');
29
+ // 4자리 이상 숫자 리터럴
30
+ p = p.replace(/\b\d{4,}\b/g, '<NUM>');
31
+ // 소문자화 + 공백 정규화
32
+ p = p.toLowerCase().replace(/\s+/g, ' ').trim();
33
+ return p;
34
+ }
35
+
36
+ /**
37
+ * 에러에 대한 기존 솔루션 검색
38
+ * @param {object} store - createStore() 반환 객체
39
+ * @param {string} errorMessage - 원본 에러 메시지
40
+ * @param {object} [context={}] - { file, function, cli, agent }
41
+ * @returns {{ found: boolean, entries: Array, bestMatch: object|null }}
42
+ */
43
+ export function lookupSolution(store, errorMessage, context = {}) {
44
+ const pattern = normalizeError(errorMessage);
45
+ if (!pattern) return { found: false, entries: [], bestMatch: null };
46
+
47
+ const entries = store.findReflexion(pattern, context);
48
+ if (!entries.length) return { found: false, entries: [], bestMatch: null };
49
+
50
+ const bestMatch = entries[0]; // confidence DESC 정렬 결과
51
+ return { found: true, entries, bestMatch };
52
+ }
53
+
54
+ /**
55
+ * 에러 해결 후 학습 저장
56
+ * 동일 패턴이 존재하면 hit 업데이트, 없으면 새로 생성
57
+ * @param {object} store
58
+ * @param {{ error: string, solution: string, context?: object, success?: boolean }} opts
59
+ * @returns {object|null}
60
+ */
61
+ export function learnFromError(store, { error, solution, context = {}, success = false }) {
62
+ const pattern = normalizeError(error);
63
+ if (!pattern || !solution) return null;
64
+
65
+ // 동일 패턴이 이미 존재하는지 확인
66
+ const existing = store.findReflexion(pattern, context);
67
+ if (existing.length && existing[0].error_pattern === pattern) {
68
+ return store.updateReflexionHit(existing[0].id, success);
69
+ }
70
+
71
+ return store.addReflexion({
72
+ error_pattern: pattern,
73
+ error_message: error,
74
+ context,
75
+ solution,
76
+ solution_code: null,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * 솔루션 적용 결과 피드백
82
+ * @param {object} store
83
+ * @param {string} entryId
84
+ * @param {boolean} success
85
+ * @returns {object|null}
86
+ */
87
+ export function reportOutcome(store, entryId, success) {
88
+ return store.updateReflexionHit(entryId, success);
89
+ }
90
+
91
+ /**
92
+ * 신뢰도 자동 조정 (success_count / hit_count, 샘플 크기 기반 감쇠)
93
+ * hit_count가 작으면 0.5(기본값)쪽으로 보수적으로 수렴
94
+ * @param {object} entry - { hit_count, success_count }
95
+ * @returns {number} 0~1 사이 신뢰도
96
+ */
97
+ export function recalcConfidence(entry) {
98
+ if (!entry || !entry.hit_count || entry.hit_count <= 0) return 0.5;
99
+ const ratio = entry.success_count / entry.hit_count;
100
+ // 샘플 크기 기반 감쇠: hit_count가 10 미만이면 기본값(0.5) 방향으로 보정
101
+ const decay = Math.min(1, entry.hit_count / 10);
102
+ return ratio * decay + 0.5 * (1 - decay);
103
+ }
@@ -0,0 +1,141 @@
1
+ // hub/research.mjs — 자율 웹 리서치 엔진 코어
2
+ // 검색 쿼리 생성 → 결과 정규화 → 보고서 빌드 → 저장
3
+
4
+ import { mkdirSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { TFX_REPORTS_DIR } from './paths.mjs';
7
+
8
+ /**
9
+ * 주제에서 검색 쿼리 3-5개를 자동 생성한다.
10
+ * 한국어 주제 → 한국어 + 영어 혼합, 영어 주제 → 영어 쿼리.
11
+ * @param {string} topic - 리서치 주제
12
+ * @param {'ko'|'en'|'auto'} [lang='auto'] - 언어 힌트
13
+ * @returns {string[]} 검색 쿼리 배열
14
+ */
15
+ export function generateQueries(topic, lang = 'auto') {
16
+ if (!topic || typeof topic !== 'string' || !topic.trim()) return [];
17
+
18
+ const t = topic.trim();
19
+ const detectedLang = lang === 'auto' ? detectLang(t) : lang;
20
+
21
+ if (detectedLang === 'ko') {
22
+ return [
23
+ `${t} 정리`,
24
+ `${t} 비교 분석`,
25
+ `${t} 최신 동향 ${new Date().getFullYear()}`,
26
+ `${toEnglishQuery(t)} overview`,
27
+ `${toEnglishQuery(t)} comparison ${new Date().getFullYear()}`,
28
+ ];
29
+ }
30
+
31
+ return [
32
+ `${t} overview`,
33
+ `${t} comparison`,
34
+ `${t} best practices ${new Date().getFullYear()}`,
35
+ `${t} pros and cons`,
36
+ ];
37
+ }
38
+
39
+ /**
40
+ * 검색 원시 결과를 정규화한다. 중복 URL 제거 + 빈/null 필터링.
41
+ * @param {Array<object|null|undefined>} rawResults - 검색 엔진 원시 결과
42
+ * @returns {Array<{title: string, url: string, snippet: string}>}
43
+ */
44
+ export function normalizeResults(rawResults) {
45
+ if (!Array.isArray(rawResults)) return [];
46
+
47
+ const seen = new Set();
48
+ const out = [];
49
+
50
+ for (const r of rawResults) {
51
+ if (!r || typeof r !== 'object') continue;
52
+ const url = (r.url || r.link || '').trim();
53
+ const title = (r.title || r.name || '').trim();
54
+ const snippet = (r.snippet || r.description || r.content || '').trim();
55
+
56
+ if (!url || seen.has(url)) continue;
57
+ seen.add(url);
58
+ out.push({ title, url, snippet });
59
+ }
60
+
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * 리서치 보고서를 마크다운으로 빌드한다.
66
+ * @param {string} topic - 리서치 주제
67
+ * @param {string[]} findings - 핵심 발견 목록
68
+ * @param {Array<{title: string, url: string, snippet: string}>} sources - 출처 목록
69
+ * @returns {string} 마크다운 문자열
70
+ */
71
+ export function buildReport(topic, findings, sources) {
72
+ const date = new Date().toISOString().split('T')[0];
73
+ const findingsSection = (findings || [])
74
+ .map((f, i) => `${i + 1}. ${f}`)
75
+ .join('\n');
76
+ const sourcesSection = (sources || [])
77
+ .map((s) => `- [${s.title || s.url}](${s.url})${s.snippet ? ` — ${s.snippet}` : ''}`)
78
+ .join('\n');
79
+
80
+ return `# Research: ${topic}
81
+ Date: ${date}
82
+
83
+ ## Executive Summary
84
+ ${topic}에 대한 자동 리서치 결과입니다.
85
+
86
+ ## Key Findings
87
+ ${findingsSection || '_발견 없음_'}
88
+
89
+ ## Actionable Recommendations
90
+ 리서치 결과를 바탕으로 다음 단계를 검토하세요.
91
+
92
+ ## Sources
93
+ ${sourcesSection || '_출처 없음_'}
94
+ `;
95
+ }
96
+
97
+ /**
98
+ * 보고서를 .tfx/reports/research-{timestamp}.md에 저장한다.
99
+ * @param {string} topic - 리서치 주제 (파일명 생성용)
100
+ * @param {string} content - 마크다운 보고서 내용
101
+ * @param {string} [baseDir=process.cwd()] - 프로젝트 루트 경로
102
+ * @returns {string} 저장된 파일 경로
103
+ */
104
+ export function saveReport(topic, content, baseDir = process.cwd()) {
105
+ const dir = join(baseDir, TFX_REPORTS_DIR);
106
+ mkdirSync(dir, { recursive: true });
107
+
108
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
109
+ const slug = (topic || 'untitled')
110
+ .replace(/[^a-zA-Z0-9가-힣\s-]/g, '')
111
+ .replace(/\s+/g, '-')
112
+ .slice(0, 40)
113
+ .toLowerCase();
114
+ const filename = `research-${ts}-${slug}.md`;
115
+ const filepath = join(dir, filename);
116
+
117
+ writeFileSync(filepath, content, 'utf-8');
118
+ return filepath;
119
+ }
120
+
121
+ // ── internal helpers ──
122
+
123
+ /**
124
+ * 텍스트에 한글이 포함되어 있으면 'ko', 아니면 'en'
125
+ * @param {string} text
126
+ * @returns {'ko'|'en'}
127
+ */
128
+ function detectLang(text) {
129
+ return /[가-힣]/.test(text) ? 'ko' : 'en';
130
+ }
131
+
132
+ /**
133
+ * 한국어 토픽에서 영어 검색 쿼리용 문자열 추출.
134
+ * 영문/숫자만 남기고, 없으면 원문 그대로 반환.
135
+ * @param {string} text
136
+ * @returns {string}
137
+ */
138
+ function toEnglishQuery(text) {
139
+ const eng = text.replace(/[가-힣\s]+/g, ' ').trim();
140
+ return eng || text;
141
+ }
package/hub/schema.sql CHANGED
@@ -125,3 +125,22 @@ CREATE TABLE IF NOT EXISTS pipeline_state (
125
125
  created_at INTEGER,
126
126
  updated_at INTEGER
127
127
  );
128
+
129
+ -- Reflexion 에러 학습 테이블
130
+ CREATE TABLE IF NOT EXISTS reflexion_entries (
131
+ id TEXT PRIMARY KEY,
132
+ error_pattern TEXT NOT NULL, -- 에러 시그니처 (정규화)
133
+ error_message TEXT NOT NULL, -- 원본 에러 메시지
134
+ context_json TEXT NOT NULL DEFAULT '{}', -- { file, function, cli, agent }
135
+ solution TEXT NOT NULL, -- 해결책 설명
136
+ solution_code TEXT, -- 해결 코드 스니펫 (있으면)
137
+ confidence REAL NOT NULL DEFAULT 0.5, -- 솔루션 신뢰도 (0-1)
138
+ hit_count INTEGER NOT NULL DEFAULT 1, -- 매칭 횟수
139
+ success_count INTEGER NOT NULL DEFAULT 0, -- 성공 횟수
140
+ last_hit_ms INTEGER NOT NULL,
141
+ created_at_ms INTEGER NOT NULL,
142
+ updated_at_ms INTEGER NOT NULL
143
+ );
144
+
145
+ CREATE INDEX IF NOT EXISTS idx_reflexion_pattern ON reflexion_entries(error_pattern);
146
+ CREATE INDEX IF NOT EXISTS idx_reflexion_confidence ON reflexion_entries(confidence DESC);
package/hub/store.mjs CHANGED
@@ -91,6 +91,12 @@ function parseAssignRow(row) {
91
91
  };
92
92
  }
93
93
 
94
+ function parseReflexionRow(row) {
95
+ if (!row) return null;
96
+ const { context_json, ...rest } = row;
97
+ return { ...rest, context: parseJson(context_json, {}) };
98
+ }
99
+
94
100
  /**
95
101
  * 저장소 생성
96
102
  * @param {string} dbPath
@@ -107,7 +113,7 @@ export function createStore(dbPath) {
107
113
 
108
114
  const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
109
115
  db.exec("CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)");
110
- const SCHEMA_VERSION = '2';
116
+ const SCHEMA_VERSION = '3';
111
117
  const curVer = (() => {
112
118
  try { return db.prepare("SELECT value FROM _meta WHERE key='schema_version'").pluck().get(); }
113
119
  catch { return null; }
@@ -221,6 +227,18 @@ export function createStore(dbPath) {
221
227
  ackedRecent: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='acked' AND created_at_ms > ? - 300000"),
222
228
  assignCountByStatus: db.prepare('SELECT COUNT(*) as cnt FROM assign_jobs WHERE status = ?'),
223
229
  activeAssignCount: db.prepare("SELECT COUNT(*) as cnt FROM assign_jobs WHERE status IN ('queued','running')"),
230
+
231
+ // reflexion
232
+ insertReflexion: db.prepare(`
233
+ INSERT INTO reflexion_entries (id, error_pattern, error_message, context_json, solution, solution_code, confidence, hit_count, success_count, last_hit_ms, created_at_ms, updated_at_ms)
234
+ VALUES (@id, @error_pattern, @error_message, @context_json, @solution, @solution_code, @confidence, @hit_count, @success_count, @last_hit_ms, @created_at_ms, @updated_at_ms)`),
235
+ getReflexionById: db.prepare('SELECT * FROM reflexion_entries WHERE id = ?'),
236
+ findReflexionExact: db.prepare('SELECT * FROM reflexion_entries WHERE error_pattern = ? ORDER BY confidence DESC'),
237
+ findReflexionLike: db.prepare("SELECT * FROM reflexion_entries WHERE error_pattern LIKE ? ESCAPE '\\' ORDER BY confidence DESC LIMIT 10"),
238
+ updateReflexionHitSuccess: db.prepare('UPDATE reflexion_entries SET hit_count = hit_count + 1, success_count = success_count + 1, last_hit_ms = ?, updated_at_ms = ? WHERE id = ?'),
239
+ updateReflexionHitOnly: db.prepare('UPDATE reflexion_entries SET hit_count = hit_count + 1, last_hit_ms = ?, updated_at_ms = ? WHERE id = ?'),
240
+ updateReflexionConfidence: db.prepare('UPDATE reflexion_entries SET confidence = ?, updated_at_ms = ? WHERE id = ?'),
241
+ pruneReflexionEntries: db.prepare('DELETE FROM reflexion_entries WHERE updated_at_ms < ? AND confidence < ?'),
224
242
  };
225
243
 
226
244
  const assignStatusListeners = new Set();
@@ -700,6 +718,60 @@ export function createStore(dbPath) {
700
718
  assign_timed_out: S.assignCountByStatus.get('timed_out').cnt,
701
719
  };
702
720
  },
721
+
722
+ // --- Reflexion CRUD ---
723
+
724
+ addReflexion({ error_pattern, error_message, context = {}, solution, solution_code = null }) {
725
+ const now = Date.now();
726
+ const id = uuidv7();
727
+ S.insertReflexion.run({
728
+ id,
729
+ error_pattern,
730
+ error_message,
731
+ context_json: JSON.stringify(context),
732
+ solution,
733
+ solution_code,
734
+ confidence: 0.5,
735
+ hit_count: 1,
736
+ success_count: 0,
737
+ last_hit_ms: now,
738
+ created_at_ms: now,
739
+ updated_at_ms: now,
740
+ });
741
+ return store.getReflexion(id);
742
+ },
743
+
744
+ getReflexion(id) {
745
+ return parseReflexionRow(S.getReflexionById.get(id));
746
+ },
747
+
748
+ findReflexion(errorPattern) {
749
+ let rows = S.findReflexionExact.all(errorPattern);
750
+ if (rows.length) return rows.map(parseReflexionRow);
751
+ const escaped = errorPattern.replace(/[%_\\]/g, '\\$&');
752
+ rows = S.findReflexionLike.all(`%${escaped.slice(0, 100)}%`);
753
+ return rows.map(parseReflexionRow);
754
+ },
755
+
756
+ updateReflexionHit(id, success = false) {
757
+ const now = Date.now();
758
+ if (success) {
759
+ S.updateReflexionHitSuccess.run(now, now, id);
760
+ } else {
761
+ S.updateReflexionHitOnly.run(now, now, id);
762
+ }
763
+ const entry = store.getReflexion(id);
764
+ if (entry && entry.hit_count > 0) {
765
+ const conf = entry.success_count / entry.hit_count;
766
+ S.updateReflexionConfidence.run(Math.max(0, Math.min(1, conf)), now, id);
767
+ }
768
+ return store.getReflexion(id);
769
+ },
770
+
771
+ pruneReflexion(maxAge_ms = 30 * 24 * 3600 * 1000, minConfidence = 0.2) {
772
+ const cutoff = Date.now() - maxAge_ms;
773
+ return S.pruneReflexionEntries.run(cutoff, minConfidence).changes;
774
+ },
703
775
  };
704
776
 
705
777
  return store;
@@ -38,7 +38,7 @@ function renderTmuxInstallHelp() {
38
38
  export { parseTeamArgs };
39
39
 
40
40
  export async function teamStart(args = []) {
41
- const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose } = parseTeamArgs(args);
41
+ const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose, mcpProfile } = parseTeamArgs(args);
42
42
  // --assign 사용 시 task를 자동 생성
43
43
  const task = rawTask || (assigns.length > 0 ? assigns.map(a => a.prompt).join(" + ") : "");
44
44
  if (!task) return printStartUsage();
@@ -72,7 +72,7 @@ export async function teamStart(args = []) {
72
72
  const state = effectiveMode === "in-process"
73
73
  ? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
74
74
  : effectiveMode === "headless"
75
- ? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose })
75
+ ? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, mcpProfile })
76
76
  : effectiveMode === "wt"
77
77
  ? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
78
78
  : await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
@@ -11,6 +11,7 @@ export function parseTeamArgs(args = []) {
11
11
  let progressive = true;
12
12
  let timeoutSec = 300;
13
13
  let verbose = false;
14
+ let mcpProfile = "";
14
15
 
15
16
  for (let index = 0; index < args.length; index += 1) {
16
17
  const current = args[index];
@@ -38,6 +39,8 @@ export function parseTeamArgs(args = []) {
38
39
  progressive = false;
39
40
  } else if (current === "--timeout" && args[index + 1]) {
40
41
  timeoutSec = Number(args[++index]) || 300;
42
+ } else if (current === "--mcp-profile" && args[index + 1]) {
43
+ mcpProfile = args[++index].trim();
41
44
  } else if (!current.startsWith("-")) {
42
45
  taskParts.push(current);
43
46
  }
@@ -54,5 +57,6 @@ export function parseTeamArgs(args = []) {
54
57
  progressive,
55
58
  timeoutSec,
56
59
  verbose,
60
+ mcpProfile,
57
61
  };
58
62
  }
@@ -1,14 +1,14 @@
1
1
  import { BOLD, DIM, GREEN, RESET, AMBER } from "../../../shared.mjs";
2
- import { runHeadlessInteractive } from "../../../headless.mjs";
2
+ import { runHeadlessInteractive, resolveCliType } from "../../../headless.mjs";
3
3
  import { ok, warn } from "../../render.mjs";
4
4
  import { buildTasks } from "../../services/task-model.mjs";
5
5
  import { clearTeamState } from "../../services/state-store.mjs";
6
6
 
7
- export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose }) {
7
+ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, mcpProfile }) {
8
8
  // --assign이 있으면 그것을 사용, 없으면 agents+subtasks 조합
9
9
  const assignments = assigns && assigns.length > 0
10
- ? assigns.map((a, i) => ({ cli: a.cli, prompt: a.prompt, role: a.role || `worker-${i + 1}` }))
11
- : subtasks.map((subtask, i) => ({ cli: agents[i] || agents[0], prompt: subtask, role: `worker-${i + 1}` }));
10
+ ? assigns.map((a, i) => ({ cli: resolveCliType(a.cli), prompt: a.prompt, role: a.role || `worker-${i + 1}`, mcp: mcpProfile }))
11
+ : subtasks.map((subtask, i) => ({ cli: resolveCliType(agents[i] || agents[0]), prompt: subtask, role: `worker-${i + 1}`, mcp: mcpProfile }));
12
12
 
13
13
  ok(`headless ${assignments.length}워커 시작`);
14
14
 
@@ -46,11 +46,17 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
46
46
  for (const r of failed) console.log(` ${AMBER}✗${RESET} ${r.paneName} (${r.cli}) exit=${r.exitCode}`);
47
47
  }
48
48
 
49
- // 결과 파일 경로 (Lead 필요시 Read()로 확인)
49
+ // handoff 요약 (Lead 토큰 절약 포맷)
50
50
  for (const r of results) {
51
51
  const icon = r.matched && r.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
52
- if (r.resultFile) {
53
- console.log(` ${icon} ${r.paneName}: ${r.resultFile}`);
52
+ if (r.handoffFormatted) {
53
+ const tag = r.handoffFallback ? `${DIM}(fallback)${RESET}` : "";
54
+ console.log(` ${icon} ${r.paneName} ${tag}`);
55
+ for (const line of r.handoffFormatted.split("\n")) {
56
+ console.log(` ${DIM}${line}${RESET}`);
57
+ }
58
+ } else {
59
+ if (r.resultFile) console.log(` ${icon} ${r.paneName}: ${r.resultFile}`);
54
60
  }
55
61
  }
56
62
 
@@ -83,6 +89,7 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
83
89
  startedAt: Date.now(),
84
90
  members,
85
91
  headlessResults: results,
92
+ handoffs: results.map((r) => ({ paneName: r.paneName, cli: r.cli, ...r.handoff })),
86
93
  tasks: buildTasks(assignments.map(a => a.prompt), members.filter((m) => m.role === "worker")),
87
94
  postSave() {
88
95
  // headless는 실행 완료 후 즉시 정리 — HUD에 잔존 방지