triflux 10.3.2 → 10.3.3

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.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +22 -22
  2. package/LICENSE +21 -21
  3. package/README.ko.md +16 -0
  4. package/README.md +8 -0
  5. package/hooks/hook-registry.json +256 -256
  6. package/hub/adaptive-inject.mjs +1 -1
  7. package/hub/assign-callbacks.mjs +120 -120
  8. package/hub/delegator/index.mjs +14 -14
  9. package/hub/delegator/tool-definitions.mjs +35 -35
  10. package/hub/hitl.mjs +143 -143
  11. package/hub/router.mjs +791 -791
  12. package/hub/session-fingerprint.mjs +1 -1
  13. package/hub/team/cli/commands/attach.mjs +37 -37
  14. package/hub/team/cli/commands/debug.mjs +74 -74
  15. package/hub/team/cli/commands/focus.mjs +53 -53
  16. package/hub/team/cli/commands/list.mjs +24 -24
  17. package/hub/team/cli/commands/start/start-in-process.mjs +40 -40
  18. package/hub/team/cli/commands/start/start-mux.mjs +73 -73
  19. package/hub/team/cli/commands/start/start-wt.mjs +69 -69
  20. package/hub/team/cli/commands/tasks.mjs +13 -13
  21. package/hub/team/cli/render.mjs +30 -30
  22. package/hub/team/cli/services/attach-fallback.mjs +54 -54
  23. package/hub/team/cli/services/member-selector.mjs +30 -30
  24. package/hub/team/cli/services/native-control.mjs +116 -116
  25. package/hub/team/cli/services/task-model.mjs +30 -30
  26. package/hub/team/notify.mjs +1 -1
  27. package/hub/team/orchestrator.mjs +161 -161
  28. package/hub/team/session.mjs +611 -611
  29. package/hub/team/shared.mjs +13 -13
  30. package/hub/tray.mjs +368 -368
  31. package/hub/workers/codex-mcp.mjs +507 -507
  32. package/hub/workers/factory.mjs +21 -21
  33. package/mesh/index.mjs +63 -0
  34. package/mesh/mesh-budget.mjs +128 -0
  35. package/mesh/mesh-heartbeat.mjs +100 -0
  36. package/mesh/mesh-protocol.mjs +96 -0
  37. package/mesh/mesh-queue.mjs +165 -0
  38. package/mesh/mesh-registry.mjs +78 -0
  39. package/mesh/mesh-router.mjs +76 -0
  40. package/package.json +2 -1
  41. package/scripts/completions/tfx.bash +47 -47
  42. package/scripts/completions/tfx.fish +44 -44
  43. package/scripts/completions/tfx.zsh +83 -83
  44. package/scripts/hub-ensure.mjs +120 -120
  45. package/scripts/keyword-detector.mjs +272 -272
  46. package/scripts/keyword-rules-expander.mjs +521 -521
  47. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  48. package/scripts/notion-read.mjs +553 -553
  49. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  50. package/scripts/tfx-batch-stats.mjs +96 -96
  51. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +0 -1
  52. package/skills/.omc/state/idle-notif-cooldown.json +0 -3
  53. package/skills/.omc/state/last-tool-error.json +0 -7
  54. package/skills/.omc/state/subagent-tracking.json +0 -7
  55. package/skills/tfx-remote-spawn/references/hosts.json +0 -16
@@ -1,556 +1,556 @@
1
- #!/usr/bin/env node
2
- // notion-read.mjs v1.2 — Notion 대형 페이지 리더 (Codex/Gemini/Claude MCP 위임)
3
- //
4
- // Codex/Gemini/Claude CLI에 설치된 Notion MCP를 활용하여 대형 페이지를 마크다운으로 추출.
5
- // 폴백 체인: Codex(무료) → Gemini(무료) → Claude(최후) → 에러
6
- // 이관 모드(--delegate): Claude(notion-guest 우선) 단독 실행 + 결과 파일 저장
7
- //
8
- // 사용법:
9
- // node notion-read.mjs <notion-url-or-page-id> [옵션]
10
- // tfx notion-read <notion-url-or-page-id> [옵션]
11
- //
12
- // 옵션:
13
- // --output, -o <file> 결과 파일 저장 (기본: stdout)
14
- // --timeout, -t <sec> CLI 타임아웃 (기본: 600)
15
- // --cli, -c <codex|gemini> CLI 강제 지정 (기본: 자동 + 폴백)
16
- // --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
17
- // --guest notion-guest 통합 사용 (기본: notion)
18
- // --delegate Claude 이관 모드 (notion-guest 우선, 파일 저장)
19
-
20
- import { execSync } from 'child_process';
21
- import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
22
- import { join, dirname, resolve } from 'path';
23
- import { homedir, tmpdir } from 'os';
24
-
25
- import { buildExecArgs } from '../hub/codex-adapter.mjs';
26
-
27
- const VERSION = '1.2';
28
- const CLAUDE_DIR = join(homedir(), '.claude');
29
- const MCP_CACHE = join(CLAUDE_DIR, 'cache', 'mcp-inventory.json');
30
- const LOG_FILE = join(CLAUDE_DIR, 'logs', 'tfx-route-stats.jsonl');
31
- const _ACC_FILE = join(CLAUDE_DIR, 'cache', 'sv-accumulator.json');
32
-
33
- // ── ANSI 색상 ──
34
- const AMBER = '\x1b[38;5;214m';
35
- const GREEN = '\x1b[38;5;82m';
36
- const RED = '\x1b[38;5;196m';
37
- const YELLOW = '\x1b[33m';
38
- const DIM = '\x1b[2m';
39
- const BOLD = '\x1b[1m';
40
- const RESET = '\x1b[0m';
41
- const GRAY = '\x1b[38;5;245m';
42
-
43
- // ── URL 파싱 ──
44
- function parseNotionUrl(input) {
45
- // 32자리 hex (하이픈 없는 page_id)
46
- if (/^[a-f0-9]{32}$/i.test(input)) {
47
- return { pageId: input, blockId: null };
48
- }
49
- // UUID 형식
50
- if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) {
51
- return { pageId: input.replace(/-/g, ""), blockId: null };
52
- }
53
- // URL에서 page_id + optional #block_id 추출
54
- const urlMatch = input.match(/([a-f0-9]{32})(?:#([a-f0-9]{32}))?/i);
55
- if (urlMatch) {
56
- return { pageId: urlMatch[1], blockId: urlMatch[2] || null };
57
- }
58
- return null;
59
- }
60
-
61
- // ── MCP 가용성 확인 ──
62
- function getNotionMcpClis(useGuest) {
63
- const serverName = useGuest ? "notion-guest" : "notion";
64
- const result = { codex: false, gemini: false };
65
-
66
- if (!existsSync(MCP_CACHE)) return result;
67
-
68
- try {
69
- const inv = JSON.parse(readFileSync(MCP_CACHE, "utf8"));
70
-
71
- if (inv.codex?.servers) {
72
- result.codex = inv.codex.servers.some(
73
- (s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
74
- );
75
- }
76
- if (inv.gemini?.servers) {
77
- result.gemini = inv.gemini.servers.some(
78
- (s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
79
- );
80
- }
81
- } catch {}
82
-
83
- return result;
84
- }
85
-
86
- // ── CLI 존재 확인 ──
87
- function cliExists(name) {
88
- try {
89
- const cmd = process.platform === "win32" ? `where ${name} 2>nul` : `which ${name} 2>/dev/null`;
90
- const result = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
91
- return !!result.trim();
92
- } catch {
93
- return false;
94
- }
95
- }
96
-
97
- // ── 프롬프트 생성 ──
98
- function buildPrompt(pageId, blockId, depth, useGuest, includeComments) {
99
- const mcpServer = useGuest ? "notion-guest" : "notion";
100
- const targetBlock = blockId || pageId;
101
- const blockNote = blockId
102
- ? `\n시작 블록: ${blockId} — 이 블록과 그 하위 블록만 읽어라.`
103
- : "";
104
-
105
- return `Notion 페이지를 마크다운으로 추출하라.
106
-
107
- 페이지 ID: ${pageId}${blockNote}
108
-
109
- ## 실행 지침
110
-
111
- ${mcpServer} MCP 서버의 도구를 사용하라.
112
-
113
- ### 1단계: 페이지 메타데이터
114
- 페이지 조회 도구를 호출하라 (page_id: "${pageId}").
115
- 제목과 주요 속성을 기록하라.
116
- 404 에러가 나면 ${useGuest ? "notion" : "notion-guest"} 서버로 재시도하라.
117
-
118
- ### 2단계: 블록 읽기 (페이지네이션 필수)
119
- 블록 자식 조회 도구를 호출하라 (block_id: "${targetBlock}", page_size: 100).
120
-
121
- **페이지네이션 — 반드시 수행:**
122
- - 응답의 has_more가 true이면, next_cursor를 start_cursor로 전달하여 반복 호출.
123
- - has_more가 false가 될 때까지 계속 반복. 대형 페이지는 5-10회 이상 필요.
124
- - 절대 첫 페이지만 읽고 멈추지 마라.
125
-
126
- ### 3단계: 중첩 블록 재귀
127
- 각 블록의 has_children이 true이면, 해당 block_id로 블록 자식 조회를 재귀 호출.
128
- 최대 깊이: ${depth}단계. 깊이 초과 시 "[깊이 초과]" 표시.
129
-
130
- ### 4단계: 댓글 수집${includeComments ? `
131
- 페이지 및 블록 댓글을 수집하라.
132
- - 댓글 조회 도구를 호출하라 (block_id: "${pageId}")로 페이지 전체 댓글을 가져와라.
133
- - 응답의 has_more가 true이면 next_cursor로 반복.
134
- - 각 댓글의 parent.type이 "block_id"이면 해당 블록의 인라인 댓글이다.
135
- - parent.type이 "page_id"이면 페이지 레벨 토론 댓글이다.
136
- - 404 에러 발생 시 댓글 권한이 없는 것이므로 건너뛰어라.` : `
137
- 댓글 수집을 건너뛴다 (--comments 플래그 미지정).`}
138
-
139
- ### 5단계: 마크다운 변환
140
- - heading_1/2/3 → #/##/###
141
- - paragraph → rich_text의 plain_text 연결
142
- - bulleted_list_item → - 항목
143
- - numbered_list_item → 1. 항목
144
- - to_do → - [ ] 또는 - [x]
145
- - toggle → **제목** + 하위 내용 들여쓰기
146
- - code → \`\`\`언어 + 코드 + \`\`\`
147
- - quote → > 인용
148
- - callout → > 콜아웃
149
- - table + table_row → 마크다운 테이블 (| 헤더 | ... |)
150
- - image → ![](url)
151
- - bookmark → [북마크](url)
152
- - divider → ---
153
- - column_list/column → 순서대로 출력
154
- - child_page → [하위 페이지: 제목]
155
- - child_database → [하위 DB: 제목]
156
- - synced_block → 원본 내용 출력
157
- - 기타 → [블록타입: 지원안됨]
158
-
159
- ### 출력 규칙
160
- - 페이지 제목을 # 헤더로 시작
161
- - 모든 블록을 빠짐없이 순서대로 출력
162
- - 읽기 실패 블록은 <!-- 읽기 실패: block_id --> 주석 남기기
163
- - rich_text의 annotations (bold, italic, code, strikethrough) 반영
164
- - 링크는 [텍스트](url) 형식${includeComments ? `
165
- - 블록 인라인 댓글: 해당 블록 바로 아래에 > **[댓글]** @작성자: 내용 형식으로 삽입
166
- - 페이지 토론 댓글: 문서 맨 끝에 ## 토론 섹션으로 모아서 출력
167
- - 댓글의 rich_text도 마크다운으로 변환` : ""}
168
- - 최종 결과만 출력 — 중간 과정 설명 불필요`;
169
- }
170
-
171
- // ── CLI 실행 (임시 파일 + execSync — Windows .cmd 호환) ──
172
- function runWithCli(cliType, prompt, timeout, runMode = 'fg') {
173
- const cliName = cliType === 'claude' ? 'claude' : cliType === 'codex' ? 'codex' : 'gemini';
174
- if (!cliExists(cliName)) {
175
- return { success: false, output: '', error: `${cliType} CLI 미설치`, cli: cliType };
176
- }
177
-
178
- // 프롬프트를 임시 파일에 저장 (shell escaping 회피)
179
- const promptFile = join(tmpdir(), `notion-prompt-${Date.now()}.md`);
180
- writeFileSync(promptFile, prompt, 'utf8');
181
- const promptPath = promptFile.replace(/\\/g, '/');
182
-
183
- // CLI에 전달할 짧은 메타 프롬프트
184
- const metaPrompt = `Read the file at ${promptPath} and execute all instructions in it exactly as described. Output only the final markdown result.`;
185
-
186
- let cmd;
1
+ #!/usr/bin/env node
2
+ // notion-read.mjs v1.2 — Notion 대형 페이지 리더 (Codex/Gemini/Claude MCP 위임)
3
+ //
4
+ // Codex/Gemini/Claude CLI에 설치된 Notion MCP를 활용하여 대형 페이지를 마크다운으로 추출.
5
+ // 폴백 체인: Codex(무료) → Gemini(무료) → Claude(최후) → 에러
6
+ // 이관 모드(--delegate): Claude(notion-guest 우선) 단독 실행 + 결과 파일 저장
7
+ //
8
+ // 사용법:
9
+ // node notion-read.mjs <notion-url-or-page-id> [옵션]
10
+ // tfx notion-read <notion-url-or-page-id> [옵션]
11
+ //
12
+ // 옵션:
13
+ // --output, -o <file> 결과 파일 저장 (기본: stdout)
14
+ // --timeout, -t <sec> CLI 타임아웃 (기본: 600)
15
+ // --cli, -c <codex|gemini> CLI 강제 지정 (기본: 자동 + 폴백)
16
+ // --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
17
+ // --guest notion-guest 통합 사용 (기본: notion)
18
+ // --delegate Claude 이관 모드 (notion-guest 우선, 파일 저장)
19
+
20
+ import { execSync } from 'child_process';
21
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
22
+ import { join, dirname, resolve } from 'path';
23
+ import { homedir, tmpdir } from 'os';
24
+
25
+ import { buildExecArgs } from '../hub/codex-adapter.mjs';
26
+
27
+ const VERSION = '1.2';
28
+ const CLAUDE_DIR = join(homedir(), '.claude');
29
+ const MCP_CACHE = join(CLAUDE_DIR, 'cache', 'mcp-inventory.json');
30
+ const LOG_FILE = join(CLAUDE_DIR, 'logs', 'tfx-route-stats.jsonl');
31
+ const _ACC_FILE = join(CLAUDE_DIR, 'cache', 'sv-accumulator.json');
32
+
33
+ // ── ANSI 색상 ──
34
+ const AMBER = '\x1b[38;5;214m';
35
+ const GREEN = '\x1b[38;5;82m';
36
+ const RED = '\x1b[38;5;196m';
37
+ const YELLOW = '\x1b[33m';
38
+ const DIM = '\x1b[2m';
39
+ const BOLD = '\x1b[1m';
40
+ const RESET = '\x1b[0m';
41
+ const GRAY = '\x1b[38;5;245m';
42
+
43
+ // ── URL 파싱 ──
44
+ function parseNotionUrl(input) {
45
+ // 32자리 hex (하이픈 없는 page_id)
46
+ if (/^[a-f0-9]{32}$/i.test(input)) {
47
+ return { pageId: input, blockId: null };
48
+ }
49
+ // UUID 형식
50
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) {
51
+ return { pageId: input.replace(/-/g, ""), blockId: null };
52
+ }
53
+ // URL에서 page_id + optional #block_id 추출
54
+ const urlMatch = input.match(/([a-f0-9]{32})(?:#([a-f0-9]{32}))?/i);
55
+ if (urlMatch) {
56
+ return { pageId: urlMatch[1], blockId: urlMatch[2] || null };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // ── MCP 가용성 확인 ──
62
+ function getNotionMcpClis(useGuest) {
63
+ const serverName = useGuest ? "notion-guest" : "notion";
64
+ const result = { codex: false, gemini: false };
65
+
66
+ if (!existsSync(MCP_CACHE)) return result;
67
+
68
+ try {
69
+ const inv = JSON.parse(readFileSync(MCP_CACHE, "utf8"));
70
+
71
+ if (inv.codex?.servers) {
72
+ result.codex = inv.codex.servers.some(
73
+ (s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
74
+ );
75
+ }
76
+ if (inv.gemini?.servers) {
77
+ result.gemini = inv.gemini.servers.some(
78
+ (s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
79
+ );
80
+ }
81
+ } catch {}
82
+
83
+ return result;
84
+ }
85
+
86
+ // ── CLI 존재 확인 ──
87
+ function cliExists(name) {
88
+ try {
89
+ const cmd = process.platform === "win32" ? `where ${name} 2>nul` : `which ${name} 2>/dev/null`;
90
+ const result = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
91
+ return !!result.trim();
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ // ── 프롬프트 생성 ──
98
+ function buildPrompt(pageId, blockId, depth, useGuest, includeComments) {
99
+ const mcpServer = useGuest ? "notion-guest" : "notion";
100
+ const targetBlock = blockId || pageId;
101
+ const blockNote = blockId
102
+ ? `\n시작 블록: ${blockId} — 이 블록과 그 하위 블록만 읽어라.`
103
+ : "";
104
+
105
+ return `Notion 페이지를 마크다운으로 추출하라.
106
+
107
+ 페이지 ID: ${pageId}${blockNote}
108
+
109
+ ## 실행 지침
110
+
111
+ ${mcpServer} MCP 서버의 도구를 사용하라.
112
+
113
+ ### 1단계: 페이지 메타데이터
114
+ 페이지 조회 도구를 호출하라 (page_id: "${pageId}").
115
+ 제목과 주요 속성을 기록하라.
116
+ 404 에러가 나면 ${useGuest ? "notion" : "notion-guest"} 서버로 재시도하라.
117
+
118
+ ### 2단계: 블록 읽기 (페이지네이션 필수)
119
+ 블록 자식 조회 도구를 호출하라 (block_id: "${targetBlock}", page_size: 100).
120
+
121
+ **페이지네이션 — 반드시 수행:**
122
+ - 응답의 has_more가 true이면, next_cursor를 start_cursor로 전달하여 반복 호출.
123
+ - has_more가 false가 될 때까지 계속 반복. 대형 페이지는 5-10회 이상 필요.
124
+ - 절대 첫 페이지만 읽고 멈추지 마라.
125
+
126
+ ### 3단계: 중첩 블록 재귀
127
+ 각 블록의 has_children이 true이면, 해당 block_id로 블록 자식 조회를 재귀 호출.
128
+ 최대 깊이: ${depth}단계. 깊이 초과 시 "[깊이 초과]" 표시.
129
+
130
+ ### 4단계: 댓글 수집${includeComments ? `
131
+ 페이지 및 블록 댓글을 수집하라.
132
+ - 댓글 조회 도구를 호출하라 (block_id: "${pageId}")로 페이지 전체 댓글을 가져와라.
133
+ - 응답의 has_more가 true이면 next_cursor로 반복.
134
+ - 각 댓글의 parent.type이 "block_id"이면 해당 블록의 인라인 댓글이다.
135
+ - parent.type이 "page_id"이면 페이지 레벨 토론 댓글이다.
136
+ - 404 에러 발생 시 댓글 권한이 없는 것이므로 건너뛰어라.` : `
137
+ 댓글 수집을 건너뛴다 (--comments 플래그 미지정).`}
138
+
139
+ ### 5단계: 마크다운 변환
140
+ - heading_1/2/3 → #/##/###
141
+ - paragraph → rich_text의 plain_text 연결
142
+ - bulleted_list_item → - 항목
143
+ - numbered_list_item → 1. 항목
144
+ - to_do → - [ ] 또는 - [x]
145
+ - toggle → **제목** + 하위 내용 들여쓰기
146
+ - code → \`\`\`언어 + 코드 + \`\`\`
147
+ - quote → > 인용
148
+ - callout → > 콜아웃
149
+ - table + table_row → 마크다운 테이블 (| 헤더 | ... |)
150
+ - image → ![](url)
151
+ - bookmark → [북마크](url)
152
+ - divider → ---
153
+ - column_list/column → 순서대로 출력
154
+ - child_page → [하위 페이지: 제목]
155
+ - child_database → [하위 DB: 제목]
156
+ - synced_block → 원본 내용 출력
157
+ - 기타 → [블록타입: 지원안됨]
158
+
159
+ ### 출력 규칙
160
+ - 페이지 제목을 # 헤더로 시작
161
+ - 모든 블록을 빠짐없이 순서대로 출력
162
+ - 읽기 실패 블록은 <!-- 읽기 실패: block_id --> 주석 남기기
163
+ - rich_text의 annotations (bold, italic, code, strikethrough) 반영
164
+ - 링크는 [텍스트](url) 형식${includeComments ? `
165
+ - 블록 인라인 댓글: 해당 블록 바로 아래에 > **[댓글]** @작성자: 내용 형식으로 삽입
166
+ - 페이지 토론 댓글: 문서 맨 끝에 ## 토론 섹션으로 모아서 출력
167
+ - 댓글의 rich_text도 마크다운으로 변환` : ""}
168
+ - 최종 결과만 출력 — 중간 과정 설명 불필요`;
169
+ }
170
+
171
+ // ── CLI 실행 (임시 파일 + execSync — Windows .cmd 호환) ──
172
+ function runWithCli(cliType, prompt, timeout, runMode = 'fg') {
173
+ const cliName = cliType === 'claude' ? 'claude' : cliType === 'codex' ? 'codex' : 'gemini';
174
+ if (!cliExists(cliName)) {
175
+ return { success: false, output: '', error: `${cliType} CLI 미설치`, cli: cliType };
176
+ }
177
+
178
+ // 프롬프트를 임시 파일에 저장 (shell escaping 회피)
179
+ const promptFile = join(tmpdir(), `notion-prompt-${Date.now()}.md`);
180
+ writeFileSync(promptFile, prompt, 'utf8');
181
+ const promptPath = promptFile.replace(/\\/g, '/');
182
+
183
+ // CLI에 전달할 짧은 메타 프롬프트
184
+ const metaPrompt = `Read the file at ${promptPath} and execute all instructions in it exactly as described. Output only the final markdown result.`;
185
+
186
+ let cmd;
187
187
  if (cliType === 'codex') {
188
188
  cmd = buildExecArgs({ prompt: metaPrompt });
189
- } else if (cliType === 'gemini') {
190
- cmd = `gemini -m gemini-3-flash-preview -y --allowed-mcp-server-names notion,notion-guest --prompt "${metaPrompt}"`;
191
- } else {
192
- // Claude CLI — print 모드 (MCP 도구 자동 접근)
193
- cmd = `claude -p "${metaPrompt}"`;
194
- }
195
-
196
- console.error(`${AMBER}▸${RESET} ${cliType}로 실행 중... (timeout: ${timeout}s)`);
197
- const startTime = Date.now();
198
-
199
- let stdout = '';
200
- let stderr = '';
201
- let exitCode = 0;
202
-
203
- try {
204
- stdout = execSync(cmd, {
205
- encoding: 'utf8',
206
- timeout: (timeout + 30) * 1000,
207
- maxBuffer: 10 * 1024 * 1024,
208
- stdio: ['pipe', 'pipe', 'pipe'],
209
- cwd: process.cwd(),
210
- windowsHide: true,
211
- });
212
- } catch (e) {
213
- exitCode = e.status || (e.killed ? 124 : 1);
214
- stdout = e.stdout || "";
215
- stderr = e.stderr || "";
216
- }
217
-
218
- const elapsed = Math.round((Date.now() - startTime) / 1000);
219
-
220
- // 임시 파일 정리
221
- try { unlinkSync(promptFile); } catch {}
222
-
223
- // 실행 로그 기록
224
- logExecution(cliType, exitCode, elapsed, timeout, stderr, runMode);
225
-
226
- if (exitCode === 0 && stdout) {
227
- return { success: true, output: stdout, cli: cliType, elapsed };
228
- }
229
-
230
- const isTimeout = exitCode === 124;
231
- return {
232
- success: false,
233
- output: stdout,
234
- error: isTimeout ? `timeout (${timeout}s)` : `exit ${exitCode}`,
235
- stderr: stderr.slice(-500),
236
- cli: cliType,
237
- elapsed,
238
- };
239
- }
240
-
241
- // ── Codex JSON-line 출력 정리 ──
242
- function cleanCodexOutput(raw) {
243
- const lines = raw.split(/\r?\n/);
244
- const texts = [];
245
-
246
- for (const line of lines) {
247
- const trimmed = line.trim();
248
- if (!trimmed) continue;
249
-
250
- // JSON-line 형식이면 파싱
251
- if (trimmed.startsWith("{")) {
252
- try {
253
- const obj = JSON.parse(trimmed);
254
- if (["message", "completed", "output_text"].includes(obj.type)) {
255
- const text = obj.text || obj.content || obj.output || "";
256
- if (text) texts.push(text);
257
- }
258
- continue;
259
- } catch {
260
- // JSON 파싱 실패 → 일반 텍스트로 처리
261
- }
262
- }
263
-
264
- texts.push(line);
265
- }
266
-
267
- return texts.join("\n");
268
- }
269
-
270
- // ── 실행 로그 (tfx-route.sh 호환) ──
271
- function logExecution(cliType, exitCode, elapsed, timeout, stderr, runMode = 'fg') {
272
- try {
273
- const logDir = dirname(LOG_FILE);
274
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
275
-
276
- const ts = new Date().toISOString();
277
- const status = exitCode === 0 ? 'success' : exitCode === 124 ? 'timeout' : 'failed';
278
- const entry = JSON.stringify({
279
- ts,
280
- agent: 'notion-read',
281
- cli: cliType,
282
- effort: cliType === 'codex' ? 'high' : cliType === 'claude' ? 'sonnet' : 'flash',
283
- run_mode: runMode,
284
- opus_oversight: 'false',
285
- status,
286
- exit_code: exitCode,
287
- elapsed_sec: elapsed,
288
- timeout_sec: timeout,
289
- mcp_profile: runMode === 'delegate' ? 'notion-guest' : 'notion',
290
- input_tokens: 0,
291
- output_tokens: 0,
292
- total_tokens: 0,
293
- });
294
- appendFileSync(LOG_FILE, entry + '\n');
295
- } catch {}
296
- }
297
-
298
- // ── 메인 ──
299
- function main() {
300
- const args = process.argv.slice(2);
301
-
302
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
303
- console.log(`
304
- ${AMBER}${BOLD}notion-read${RESET} ${DIM}v${VERSION}${RESET}
305
- ${GRAY}Notion 대형 페이지 리더 — Codex/Gemini MCP 위임${RESET}
306
-
307
- ${BOLD}사용법${RESET}
308
- tfx notion-read <notion-url-or-page-id> [옵션]
309
-
310
- ${BOLD}옵션${RESET}
311
- --output, -o <file> 결과 파일 저장 (기본: stdout)
312
- --timeout, -t <sec> CLI 타임아웃 (기본: 600)
313
- --cli, -c <codex|gemini|claude> CLI 강제 지정 (기본: 자동 + 폴백)
314
- --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
315
- --comments 블록/페이지 댓글 포함
316
- --guest notion-guest 통합 사용
317
- --delegate Claude 이관 모드 (notion-guest 우선, 파일 저장)
318
-
319
- ${BOLD}폴백 체인${RESET}
320
- Codex(무료) → Gemini(무료) → Claude(최후) → 에러
321
-
322
- ${BOLD}예시${RESET}
323
- tfx notion-read https://notion.so/Page-abc123def456...
324
- tfx notion-read abc123def456... --output page.md --comments
325
- tfx notion-read abc123def456... --cli gemini --timeout 900
326
- tfx notion-read abc123def456... --guest --comments
327
- tfx notion-read abc123def456... --delegate
328
- tfx notion-read abc123def456... --delegate --output .notion-cache/page.md
329
- `);
330
- return;
331
- }
332
-
333
- // 인자 파싱
334
- const input = args[0];
335
- let outputFile = null;
336
- let timeout = 600;
337
- let forceCli = null;
338
- let depth = 3;
339
- let useGuest = false;
340
- let includeComments = false;
341
- let delegateMode = false;
342
-
343
- for (let i = 1; i < args.length; i++) {
344
- switch (args[i]) {
345
- case "--output":
346
- case "-o":
347
- outputFile = args[++i];
348
- break;
349
- case "--timeout":
350
- case "-t":
351
- timeout = parseInt(args[++i], 10) || 600;
352
- break;
353
- case "--cli":
354
- case "-c":
355
- forceCli = args[++i];
356
- break;
357
- case "--depth":
358
- case "-d":
359
- depth = parseInt(args[++i], 10) || 3;
360
- break;
361
- case "--guest":
362
- useGuest = true;
363
- break;
364
- case "--comments":
365
- includeComments = true;
366
- break;
367
- case '--delegate':
368
- delegateMode = true;
369
- break;
370
- }
371
- }
372
-
373
- // URL 파싱
374
- const parsed = parseNotionUrl(input);
375
- if (!parsed) {
376
- console.error(`${RED}✗${RESET} 유효하지 않은 Notion URL/ID: ${input}`);
377
- process.exit(1);
378
- }
379
-
380
- console.error(
381
- `${AMBER}▸${RESET} 페이지: ${parsed.pageId}${parsed.blockId ? ` (블록: ${parsed.blockId})` : ""}`,
382
- );
383
- console.error(`${GRAY} 통합: ${delegateMode ? 'notion-guest(우선)' : useGuest ? 'notion-guest' : 'notion'} | 깊이: ${depth} | 댓글: ${includeComments ? 'O' : 'X'} | 타임아웃: ${timeout}s${RESET}`);
384
-
385
- // 프롬프트 생성
386
- const prompt = buildPrompt(parsed.pageId, parsed.blockId, depth, useGuest, includeComments);
387
- // Claude 폴백용: notion/notion-guest 양쪽 시도 프롬프트
388
- const claudePrompt = buildPrompt(parsed.pageId, parsed.blockId, depth, false, includeComments)
389
- .replace(
390
- 'notion MCP 서버의 도구를 사용하라.',
391
- '가능하면 notion-guest MCP 서버를 먼저 사용하라. 실패하면 notion MCP 서버를 사용하라.',
392
- );
393
-
394
- // delegate 모드: Claude 단독 + notion-guest 우선 + 파일 저장
395
- if (delegateMode) {
396
- console.error(`${AMBER}▸${RESET} delegate 모드 활성화: Claude로 notion-guest 우선 접근`);
397
-
398
- const delegatePrompt = `${claudePrompt}
399
-
400
- ### delegate 모드 추가 지시
401
- - notion-guest MCP 서버를 최우선으로 먼저 시도하라.
402
- - notion-guest가 실패하거나 미구성일 때만 notion 서버로 폴백하라.
403
- - 도구 호출 결과를 바탕으로 최종 마크다운만 출력하라.`;
404
-
405
- const delegateResult = runWithCli('claude', delegatePrompt, timeout, 'delegate');
406
- if (!delegateResult.success) {
407
- console.error(`${RED}✗${RESET} delegate 모드 실패: ${delegateResult.error}`);
408
- if (delegateResult.stderr) {
409
- console.error(`${GRAY} stderr: ${delegateResult.stderr.slice(0, 250)}${RESET}`);
410
- }
411
- console.error(`${GRAY} 대안: --delegate 없이 실행해 기존 폴백 체인을 사용하세요.${RESET}`);
412
- console.error(`${GRAY} 예: tfx notion-read ${parsed.pageId} --comments${RESET}`);
413
- process.exit(1);
414
- }
415
-
416
- const delegateOutput = delegateResult.output.trim();
417
- const isDelegateFailureOutput =
418
- (delegateOutput.includes('조회 실패') || delegateOutput.includes('읽기 실패') || delegateOutput.includes('not_found')) &&
419
- delegateOutput.length < 500;
420
-
421
- if (delegateOutput.length <= 100 || isDelegateFailureOutput) {
422
- console.error(`${RED}✗${RESET} delegate 모드 실패: Claude 결과가 비정상적입니다.`);
423
- console.error(`${GRAY} 대안: --delegate 없이 실행해 Codex/Gemini/Claude 폴백 체인을 사용하세요.${RESET}`);
424
- process.exit(1);
425
- }
426
-
427
- const delegateTarget = outputFile || join('.notion-cache', `${parsed.pageId}.md`);
428
- const delegateDir = dirname(delegateTarget);
429
- if (delegateDir && delegateDir !== '.' && !existsSync(delegateDir)) {
430
- mkdirSync(delegateDir, { recursive: true });
431
- }
432
- writeFileSync(delegateTarget, delegateOutput, 'utf8');
433
-
434
- const savedPath = resolve(delegateTarget);
435
- console.error(`${GREEN}✓${RESET} delegate 결과 저장: ${savedPath}`);
436
- console.error(`${GRAY} 후속 작업 참조 경로: ${savedPath}${RESET}`);
437
- return;
438
- }
439
-
440
- // MCP 가용성 확인
441
- const mcpAvail = getNotionMcpClis(useGuest);
442
- console.error(
443
- `${GRAY} MCP: codex=${mcpAvail.codex ? "O" : "X"} gemini=${mcpAvail.gemini ? "O" : "X"}${RESET}`,
444
- );
445
-
446
- // MCP 미설치 안내
447
- if (!mcpAvail.codex && !mcpAvail.gemini) {
448
- console.error(`${YELLOW}!${RESET} Codex/Gemini에 Notion MCP 미설치.`);
449
- console.error(`${GRAY} Codex: codex mcp add notion${RESET}`);
450
- console.error(`${GRAY} Gemini: ~/.gemini/settings.json에 notion 서버 추가${RESET}`);
451
- console.error(`${GRAY} 설치 후 tfx doctor --reset으로 캐시 갱신${RESET}`);
452
- } else if (!mcpAvail.codex) {
453
- console.error(`${GRAY} Codex에 Notion MCP 미설치: codex mcp add notion${RESET}`);
454
- } else if (!mcpAvail.gemini) {
455
- console.error(`${GRAY} Gemini에 Notion MCP 미설치: ~/.gemini/settings.json 확인${RESET}`);
456
- }
457
-
458
- // CLI 실행 순서 결정 (Codex → Gemini → Claude)
459
- let cliOrder;
460
- if (forceCli) {
461
- cliOrder = [forceCli];
462
- } else {
463
- cliOrder = [];
464
- if (mcpAvail.codex) cliOrder.push("codex");
465
- if (mcpAvail.gemini) cliOrder.push("gemini");
466
- // Claude는 항상 최종 폴백 (자체 Notion MCP — notion-guest 포함)
467
- cliOrder.push("claude");
468
- }
469
-
470
- // 실행 + 폴백
471
- let lastResult = null;
472
- let notionAccessFailed = false;
473
-
474
- for (const cli of cliOrder) {
475
- const currentPrompt = cli === "claude" ? claudePrompt : prompt;
476
- const result = runWithCli(cli, currentPrompt, timeout);
477
- lastResult = result;
478
-
479
- if (result.success) {
480
- // Codex JSON-line 출력 정리
481
- let output = cli === "codex" ? cleanCodexOutput(result.output) : result.output;
482
- output = output.trim();
483
-
484
- // 실패 마커 감지 (404, 접근 실패 등)
485
- const isFailureOutput =
486
- (output.includes("조회 실패") || output.includes("읽기 실패") || output.includes("not_found")) &&
487
- output.length < 500;
488
-
489
- if (output.length > 100 && !isFailureOutput) {
490
- console.error(`${GREEN}✓${RESET} ${cli}로 성공 (${output.length} chars, ${result.elapsed}s)`);
491
-
492
- if (outputFile) {
493
- const outDir = dirname(outputFile);
494
- if (outDir && outDir !== "." && !existsSync(outDir)) {
495
- mkdirSync(outDir, { recursive: true });
496
- }
497
- writeFileSync(outputFile, output, "utf8");
498
- console.error(`${GREEN}✓${RESET} 저장: ${outputFile}`);
499
- } else {
500
- console.log(output);
501
- }
502
- return;
503
- }
504
-
505
- // 404 접근 실패 감지
506
- if (isFailureOutput) {
507
- notionAccessFailed = true;
508
- console.error(`${YELLOW}!${RESET} ${cli}: Notion 접근 실패 (404) — 폴백`);
509
- if (!useGuest && cli !== "claude") {
510
- console.error(`${GRAY} --guest 플래그로 notion-guest 통합 시도 가능${RESET}`);
511
- }
512
- } else {
513
- console.error(`${YELLOW}!${RESET} ${cli}: 출력 부족 (${output.length} chars) — 폴백`);
514
- }
515
- } else {
516
- console.error(`${YELLOW}!${RESET} ${cli} 실패: ${result.error}`);
517
- if (result.stderr) {
518
- console.error(`${GRAY} stderr: ${result.stderr.slice(0, 200)}${RESET}`);
519
- }
520
- }
521
-
522
- // 다음 CLI로 폴백
523
- const idx = cliOrder.indexOf(cli);
524
- if (idx < cliOrder.length - 1) {
525
- const next = cliOrder[idx + 1];
526
- console.error(`${AMBER}▸${RESET} ${next}로 폴백 시도...`);
527
- }
528
- }
529
-
530
- // 전체 실패
531
- console.error(`${RED}✗${RESET} 모든 CLI 실패`);
532
- if (notionAccessFailed) {
533
- console.error(`${YELLOW}!${RESET} Notion 페이지 접근 권한 문제.`);
534
- console.error(`${GRAY} 1. Notion에서 페이지 → ... → 연결(Connections)에서 통합 추가${RESET}`);
535
- console.error(`${GRAY} 2. --guest 플래그로 notion-guest 통합 시도${RESET}`);
536
- console.error(`${GRAY} 3. --cli claude로 Claude 직접 사용 (Claude에 접근 권한 있는 경우)${RESET}`);
537
- }
538
-
539
- // 부분 결과라도 출력
540
- if (lastResult?.output) {
541
- const partial = lastResult.output.trim();
542
- if (partial.length > 10) {
543
- const cleaned = lastResult.cli === "codex" ? cleanCodexOutput(partial) : partial;
544
- if (outputFile) {
545
- writeFileSync(outputFile, cleaned, "utf8");
546
- console.error(`${YELLOW}!${RESET} 부분 결과 저장: ${outputFile}`);
547
- } else {
548
- console.log(cleaned);
549
- }
550
- }
551
- }
552
-
553
- process.exit(1);
554
- }
555
-
189
+ } else if (cliType === 'gemini') {
190
+ cmd = `gemini -m gemini-3-flash-preview -y --allowed-mcp-server-names notion,notion-guest --prompt "${metaPrompt}"`;
191
+ } else {
192
+ // Claude CLI — print 모드 (MCP 도구 자동 접근)
193
+ cmd = `claude -p "${metaPrompt}"`;
194
+ }
195
+
196
+ console.error(`${AMBER}▸${RESET} ${cliType}로 실행 중... (timeout: ${timeout}s)`);
197
+ const startTime = Date.now();
198
+
199
+ let stdout = '';
200
+ let stderr = '';
201
+ let exitCode = 0;
202
+
203
+ try {
204
+ stdout = execSync(cmd, {
205
+ encoding: 'utf8',
206
+ timeout: (timeout + 30) * 1000,
207
+ maxBuffer: 10 * 1024 * 1024,
208
+ stdio: ['pipe', 'pipe', 'pipe'],
209
+ cwd: process.cwd(),
210
+ windowsHide: true,
211
+ });
212
+ } catch (e) {
213
+ exitCode = e.status || (e.killed ? 124 : 1);
214
+ stdout = e.stdout || "";
215
+ stderr = e.stderr || "";
216
+ }
217
+
218
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
219
+
220
+ // 임시 파일 정리
221
+ try { unlinkSync(promptFile); } catch {}
222
+
223
+ // 실행 로그 기록
224
+ logExecution(cliType, exitCode, elapsed, timeout, stderr, runMode);
225
+
226
+ if (exitCode === 0 && stdout) {
227
+ return { success: true, output: stdout, cli: cliType, elapsed };
228
+ }
229
+
230
+ const isTimeout = exitCode === 124;
231
+ return {
232
+ success: false,
233
+ output: stdout,
234
+ error: isTimeout ? `timeout (${timeout}s)` : `exit ${exitCode}`,
235
+ stderr: stderr.slice(-500),
236
+ cli: cliType,
237
+ elapsed,
238
+ };
239
+ }
240
+
241
+ // ── Codex JSON-line 출력 정리 ──
242
+ function cleanCodexOutput(raw) {
243
+ const lines = raw.split(/\r?\n/);
244
+ const texts = [];
245
+
246
+ for (const line of lines) {
247
+ const trimmed = line.trim();
248
+ if (!trimmed) continue;
249
+
250
+ // JSON-line 형식이면 파싱
251
+ if (trimmed.startsWith("{")) {
252
+ try {
253
+ const obj = JSON.parse(trimmed);
254
+ if (["message", "completed", "output_text"].includes(obj.type)) {
255
+ const text = obj.text || obj.content || obj.output || "";
256
+ if (text) texts.push(text);
257
+ }
258
+ continue;
259
+ } catch {
260
+ // JSON 파싱 실패 → 일반 텍스트로 처리
261
+ }
262
+ }
263
+
264
+ texts.push(line);
265
+ }
266
+
267
+ return texts.join("\n");
268
+ }
269
+
270
+ // ── 실행 로그 (tfx-route.sh 호환) ──
271
+ function logExecution(cliType, exitCode, elapsed, timeout, stderr, runMode = 'fg') {
272
+ try {
273
+ const logDir = dirname(LOG_FILE);
274
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
275
+
276
+ const ts = new Date().toISOString();
277
+ const status = exitCode === 0 ? 'success' : exitCode === 124 ? 'timeout' : 'failed';
278
+ const entry = JSON.stringify({
279
+ ts,
280
+ agent: 'notion-read',
281
+ cli: cliType,
282
+ effort: cliType === 'codex' ? 'high' : cliType === 'claude' ? 'sonnet' : 'flash',
283
+ run_mode: runMode,
284
+ opus_oversight: 'false',
285
+ status,
286
+ exit_code: exitCode,
287
+ elapsed_sec: elapsed,
288
+ timeout_sec: timeout,
289
+ mcp_profile: runMode === 'delegate' ? 'notion-guest' : 'notion',
290
+ input_tokens: 0,
291
+ output_tokens: 0,
292
+ total_tokens: 0,
293
+ });
294
+ appendFileSync(LOG_FILE, entry + '\n');
295
+ } catch {}
296
+ }
297
+
298
+ // ── 메인 ──
299
+ function main() {
300
+ const args = process.argv.slice(2);
301
+
302
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
303
+ console.log(`
304
+ ${AMBER}${BOLD}notion-read${RESET} ${DIM}v${VERSION}${RESET}
305
+ ${GRAY}Notion 대형 페이지 리더 — Codex/Gemini MCP 위임${RESET}
306
+
307
+ ${BOLD}사용법${RESET}
308
+ tfx notion-read <notion-url-or-page-id> [옵션]
309
+
310
+ ${BOLD}옵션${RESET}
311
+ --output, -o <file> 결과 파일 저장 (기본: stdout)
312
+ --timeout, -t <sec> CLI 타임아웃 (기본: 600)
313
+ --cli, -c <codex|gemini|claude> CLI 강제 지정 (기본: 자동 + 폴백)
314
+ --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
315
+ --comments 블록/페이지 댓글 포함
316
+ --guest notion-guest 통합 사용
317
+ --delegate Claude 이관 모드 (notion-guest 우선, 파일 저장)
318
+
319
+ ${BOLD}폴백 체인${RESET}
320
+ Codex(무료) → Gemini(무료) → Claude(최후) → 에러
321
+
322
+ ${BOLD}예시${RESET}
323
+ tfx notion-read https://notion.so/Page-abc123def456...
324
+ tfx notion-read abc123def456... --output page.md --comments
325
+ tfx notion-read abc123def456... --cli gemini --timeout 900
326
+ tfx notion-read abc123def456... --guest --comments
327
+ tfx notion-read abc123def456... --delegate
328
+ tfx notion-read abc123def456... --delegate --output .notion-cache/page.md
329
+ `);
330
+ return;
331
+ }
332
+
333
+ // 인자 파싱
334
+ const input = args[0];
335
+ let outputFile = null;
336
+ let timeout = 600;
337
+ let forceCli = null;
338
+ let depth = 3;
339
+ let useGuest = false;
340
+ let includeComments = false;
341
+ let delegateMode = false;
342
+
343
+ for (let i = 1; i < args.length; i++) {
344
+ switch (args[i]) {
345
+ case "--output":
346
+ case "-o":
347
+ outputFile = args[++i];
348
+ break;
349
+ case "--timeout":
350
+ case "-t":
351
+ timeout = parseInt(args[++i], 10) || 600;
352
+ break;
353
+ case "--cli":
354
+ case "-c":
355
+ forceCli = args[++i];
356
+ break;
357
+ case "--depth":
358
+ case "-d":
359
+ depth = parseInt(args[++i], 10) || 3;
360
+ break;
361
+ case "--guest":
362
+ useGuest = true;
363
+ break;
364
+ case "--comments":
365
+ includeComments = true;
366
+ break;
367
+ case '--delegate':
368
+ delegateMode = true;
369
+ break;
370
+ }
371
+ }
372
+
373
+ // URL 파싱
374
+ const parsed = parseNotionUrl(input);
375
+ if (!parsed) {
376
+ console.error(`${RED}✗${RESET} 유효하지 않은 Notion URL/ID: ${input}`);
377
+ process.exit(1);
378
+ }
379
+
380
+ console.error(
381
+ `${AMBER}▸${RESET} 페이지: ${parsed.pageId}${parsed.blockId ? ` (블록: ${parsed.blockId})` : ""}`,
382
+ );
383
+ console.error(`${GRAY} 통합: ${delegateMode ? 'notion-guest(우선)' : useGuest ? 'notion-guest' : 'notion'} | 깊이: ${depth} | 댓글: ${includeComments ? 'O' : 'X'} | 타임아웃: ${timeout}s${RESET}`);
384
+
385
+ // 프롬프트 생성
386
+ const prompt = buildPrompt(parsed.pageId, parsed.blockId, depth, useGuest, includeComments);
387
+ // Claude 폴백용: notion/notion-guest 양쪽 시도 프롬프트
388
+ const claudePrompt = buildPrompt(parsed.pageId, parsed.blockId, depth, false, includeComments)
389
+ .replace(
390
+ 'notion MCP 서버의 도구를 사용하라.',
391
+ '가능하면 notion-guest MCP 서버를 먼저 사용하라. 실패하면 notion MCP 서버를 사용하라.',
392
+ );
393
+
394
+ // delegate 모드: Claude 단독 + notion-guest 우선 + 파일 저장
395
+ if (delegateMode) {
396
+ console.error(`${AMBER}▸${RESET} delegate 모드 활성화: Claude로 notion-guest 우선 접근`);
397
+
398
+ const delegatePrompt = `${claudePrompt}
399
+
400
+ ### delegate 모드 추가 지시
401
+ - notion-guest MCP 서버를 최우선으로 먼저 시도하라.
402
+ - notion-guest가 실패하거나 미구성일 때만 notion 서버로 폴백하라.
403
+ - 도구 호출 결과를 바탕으로 최종 마크다운만 출력하라.`;
404
+
405
+ const delegateResult = runWithCli('claude', delegatePrompt, timeout, 'delegate');
406
+ if (!delegateResult.success) {
407
+ console.error(`${RED}✗${RESET} delegate 모드 실패: ${delegateResult.error}`);
408
+ if (delegateResult.stderr) {
409
+ console.error(`${GRAY} stderr: ${delegateResult.stderr.slice(0, 250)}${RESET}`);
410
+ }
411
+ console.error(`${GRAY} 대안: --delegate 없이 실행해 기존 폴백 체인을 사용하세요.${RESET}`);
412
+ console.error(`${GRAY} 예: tfx notion-read ${parsed.pageId} --comments${RESET}`);
413
+ process.exit(1);
414
+ }
415
+
416
+ const delegateOutput = delegateResult.output.trim();
417
+ const isDelegateFailureOutput =
418
+ (delegateOutput.includes('조회 실패') || delegateOutput.includes('읽기 실패') || delegateOutput.includes('not_found')) &&
419
+ delegateOutput.length < 500;
420
+
421
+ if (delegateOutput.length <= 100 || isDelegateFailureOutput) {
422
+ console.error(`${RED}✗${RESET} delegate 모드 실패: Claude 결과가 비정상적입니다.`);
423
+ console.error(`${GRAY} 대안: --delegate 없이 실행해 Codex/Gemini/Claude 폴백 체인을 사용하세요.${RESET}`);
424
+ process.exit(1);
425
+ }
426
+
427
+ const delegateTarget = outputFile || join('.notion-cache', `${parsed.pageId}.md`);
428
+ const delegateDir = dirname(delegateTarget);
429
+ if (delegateDir && delegateDir !== '.' && !existsSync(delegateDir)) {
430
+ mkdirSync(delegateDir, { recursive: true });
431
+ }
432
+ writeFileSync(delegateTarget, delegateOutput, 'utf8');
433
+
434
+ const savedPath = resolve(delegateTarget);
435
+ console.error(`${GREEN}✓${RESET} delegate 결과 저장: ${savedPath}`);
436
+ console.error(`${GRAY} 후속 작업 참조 경로: ${savedPath}${RESET}`);
437
+ return;
438
+ }
439
+
440
+ // MCP 가용성 확인
441
+ const mcpAvail = getNotionMcpClis(useGuest);
442
+ console.error(
443
+ `${GRAY} MCP: codex=${mcpAvail.codex ? "O" : "X"} gemini=${mcpAvail.gemini ? "O" : "X"}${RESET}`,
444
+ );
445
+
446
+ // MCP 미설치 안내
447
+ if (!mcpAvail.codex && !mcpAvail.gemini) {
448
+ console.error(`${YELLOW}!${RESET} Codex/Gemini에 Notion MCP 미설치.`);
449
+ console.error(`${GRAY} Codex: codex mcp add notion${RESET}`);
450
+ console.error(`${GRAY} Gemini: ~/.gemini/settings.json에 notion 서버 추가${RESET}`);
451
+ console.error(`${GRAY} 설치 후 tfx doctor --reset으로 캐시 갱신${RESET}`);
452
+ } else if (!mcpAvail.codex) {
453
+ console.error(`${GRAY} Codex에 Notion MCP 미설치: codex mcp add notion${RESET}`);
454
+ } else if (!mcpAvail.gemini) {
455
+ console.error(`${GRAY} Gemini에 Notion MCP 미설치: ~/.gemini/settings.json 확인${RESET}`);
456
+ }
457
+
458
+ // CLI 실행 순서 결정 (Codex → Gemini → Claude)
459
+ let cliOrder;
460
+ if (forceCli) {
461
+ cliOrder = [forceCli];
462
+ } else {
463
+ cliOrder = [];
464
+ if (mcpAvail.codex) cliOrder.push("codex");
465
+ if (mcpAvail.gemini) cliOrder.push("gemini");
466
+ // Claude는 항상 최종 폴백 (자체 Notion MCP — notion-guest 포함)
467
+ cliOrder.push("claude");
468
+ }
469
+
470
+ // 실행 + 폴백
471
+ let lastResult = null;
472
+ let notionAccessFailed = false;
473
+
474
+ for (const cli of cliOrder) {
475
+ const currentPrompt = cli === "claude" ? claudePrompt : prompt;
476
+ const result = runWithCli(cli, currentPrompt, timeout);
477
+ lastResult = result;
478
+
479
+ if (result.success) {
480
+ // Codex JSON-line 출력 정리
481
+ let output = cli === "codex" ? cleanCodexOutput(result.output) : result.output;
482
+ output = output.trim();
483
+
484
+ // 실패 마커 감지 (404, 접근 실패 등)
485
+ const isFailureOutput =
486
+ (output.includes("조회 실패") || output.includes("읽기 실패") || output.includes("not_found")) &&
487
+ output.length < 500;
488
+
489
+ if (output.length > 100 && !isFailureOutput) {
490
+ console.error(`${GREEN}✓${RESET} ${cli}로 성공 (${output.length} chars, ${result.elapsed}s)`);
491
+
492
+ if (outputFile) {
493
+ const outDir = dirname(outputFile);
494
+ if (outDir && outDir !== "." && !existsSync(outDir)) {
495
+ mkdirSync(outDir, { recursive: true });
496
+ }
497
+ writeFileSync(outputFile, output, "utf8");
498
+ console.error(`${GREEN}✓${RESET} 저장: ${outputFile}`);
499
+ } else {
500
+ console.log(output);
501
+ }
502
+ return;
503
+ }
504
+
505
+ // 404 접근 실패 감지
506
+ if (isFailureOutput) {
507
+ notionAccessFailed = true;
508
+ console.error(`${YELLOW}!${RESET} ${cli}: Notion 접근 실패 (404) — 폴백`);
509
+ if (!useGuest && cli !== "claude") {
510
+ console.error(`${GRAY} --guest 플래그로 notion-guest 통합 시도 가능${RESET}`);
511
+ }
512
+ } else {
513
+ console.error(`${YELLOW}!${RESET} ${cli}: 출력 부족 (${output.length} chars) — 폴백`);
514
+ }
515
+ } else {
516
+ console.error(`${YELLOW}!${RESET} ${cli} 실패: ${result.error}`);
517
+ if (result.stderr) {
518
+ console.error(`${GRAY} stderr: ${result.stderr.slice(0, 200)}${RESET}`);
519
+ }
520
+ }
521
+
522
+ // 다음 CLI로 폴백
523
+ const idx = cliOrder.indexOf(cli);
524
+ if (idx < cliOrder.length - 1) {
525
+ const next = cliOrder[idx + 1];
526
+ console.error(`${AMBER}▸${RESET} ${next}로 폴백 시도...`);
527
+ }
528
+ }
529
+
530
+ // 전체 실패
531
+ console.error(`${RED}✗${RESET} 모든 CLI 실패`);
532
+ if (notionAccessFailed) {
533
+ console.error(`${YELLOW}!${RESET} Notion 페이지 접근 권한 문제.`);
534
+ console.error(`${GRAY} 1. Notion에서 페이지 → ... → 연결(Connections)에서 통합 추가${RESET}`);
535
+ console.error(`${GRAY} 2. --guest 플래그로 notion-guest 통합 시도${RESET}`);
536
+ console.error(`${GRAY} 3. --cli claude로 Claude 직접 사용 (Claude에 접근 권한 있는 경우)${RESET}`);
537
+ }
538
+
539
+ // 부분 결과라도 출력
540
+ if (lastResult?.output) {
541
+ const partial = lastResult.output.trim();
542
+ if (partial.length > 10) {
543
+ const cleaned = lastResult.cli === "codex" ? cleanCodexOutput(partial) : partial;
544
+ if (outputFile) {
545
+ writeFileSync(outputFile, cleaned, "utf8");
546
+ console.error(`${YELLOW}!${RESET} 부분 결과 저장: ${outputFile}`);
547
+ } else {
548
+ console.log(cleaned);
549
+ }
550
+ }
551
+ }
552
+
553
+ process.exit(1);
554
+ }
555
+
556
556
  main();