@triflux/core 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.
Files changed (75) hide show
  1. package/hooks/agent-route-guard.mjs +109 -0
  2. package/hooks/cross-review-tracker.mjs +122 -0
  3. package/hooks/error-context.mjs +148 -0
  4. package/hooks/hook-manager.mjs +352 -0
  5. package/hooks/hook-orchestrator.mjs +312 -0
  6. package/hooks/hook-registry.json +213 -0
  7. package/hooks/hooks.json +89 -0
  8. package/hooks/keyword-rules.json +581 -0
  9. package/hooks/lib/resolve-root.mjs +59 -0
  10. package/hooks/mcp-config-watcher.mjs +85 -0
  11. package/hooks/pipeline-stop.mjs +76 -0
  12. package/hooks/safety-guard.mjs +106 -0
  13. package/hooks/subagent-verifier.mjs +80 -0
  14. package/hub/assign-callbacks.mjs +133 -0
  15. package/hub/bridge.mjs +799 -0
  16. package/hub/cli-adapter-base.mjs +192 -0
  17. package/hub/codex-adapter.mjs +190 -0
  18. package/hub/codex-compat.mjs +78 -0
  19. package/hub/codex-preflight.mjs +147 -0
  20. package/hub/delegator/contracts.mjs +37 -0
  21. package/hub/delegator/index.mjs +14 -0
  22. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  23. package/hub/delegator/service.mjs +307 -0
  24. package/hub/delegator/tool-definitions.mjs +35 -0
  25. package/hub/fullcycle.mjs +96 -0
  26. package/hub/gemini-adapter.mjs +179 -0
  27. package/hub/hitl.mjs +143 -0
  28. package/hub/intent.mjs +193 -0
  29. package/hub/lib/process-utils.mjs +361 -0
  30. package/hub/middleware/request-logger.mjs +81 -0
  31. package/hub/paths.mjs +30 -0
  32. package/hub/pipeline/gates/confidence.mjs +56 -0
  33. package/hub/pipeline/gates/consensus.mjs +94 -0
  34. package/hub/pipeline/gates/index.mjs +5 -0
  35. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  36. package/hub/pipeline/index.mjs +318 -0
  37. package/hub/pipeline/state.mjs +191 -0
  38. package/hub/pipeline/transitions.mjs +124 -0
  39. package/hub/platform.mjs +225 -0
  40. package/hub/quality/deslop.mjs +253 -0
  41. package/hub/reflexion.mjs +372 -0
  42. package/hub/research.mjs +146 -0
  43. package/hub/router.mjs +791 -0
  44. package/hub/routing/complexity.mjs +166 -0
  45. package/hub/routing/index.mjs +117 -0
  46. package/hub/routing/q-learning.mjs +336 -0
  47. package/hub/session-fingerprint.mjs +352 -0
  48. package/hub/state.mjs +245 -0
  49. package/hub/team-bridge.mjs +25 -0
  50. package/hub/token-mode.mjs +224 -0
  51. package/hub/workers/worker-utils.mjs +104 -0
  52. package/hud/colors.mjs +88 -0
  53. package/hud/constants.mjs +81 -0
  54. package/hud/hud-qos-status.mjs +206 -0
  55. package/hud/providers/claude.mjs +309 -0
  56. package/hud/providers/codex.mjs +151 -0
  57. package/hud/providers/gemini.mjs +320 -0
  58. package/hud/renderers.mjs +424 -0
  59. package/hud/terminal.mjs +140 -0
  60. package/hud/utils.mjs +287 -0
  61. package/package.json +31 -0
  62. package/scripts/lib/claudemd-manager.mjs +325 -0
  63. package/scripts/lib/context.mjs +67 -0
  64. package/scripts/lib/cross-review-utils.mjs +51 -0
  65. package/scripts/lib/env-probe.mjs +241 -0
  66. package/scripts/lib/gemini-profiles.mjs +85 -0
  67. package/scripts/lib/hook-utils.mjs +14 -0
  68. package/scripts/lib/keyword-rules.mjs +166 -0
  69. package/scripts/lib/logger.mjs +105 -0
  70. package/scripts/lib/mcp-filter.mjs +739 -0
  71. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  72. package/scripts/lib/mcp-manifest.mjs +79 -0
  73. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  74. package/scripts/lib/psmux-info.mjs +119 -0
  75. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
@@ -0,0 +1,79 @@
1
+ // scripts/lib/mcp-manifest.mjs
2
+ // MCP 서버 활성화 매니페스트 — 단일 진실 소스.
3
+ // tfx-setup 위저드가 저장하고, gateway/filter가 참조한다.
4
+
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ export const MANIFEST_PATH = join(homedir(), '.claude', 'cache', 'mcp-enabled.json');
10
+
11
+ /** API 키 불필요 — 항상 활성화 */
12
+ export const CORE_SERVERS = Object.freeze(['context7', 'serena']);
13
+
14
+ /** 검색 MCP — API 키 필요 */
15
+ export const SEARCH_SERVERS = Object.freeze([
16
+ { name: 'brave-search', envVars: ['BRAVE_API_KEY'] },
17
+ { name: 'exa', envVars: ['EXA_API_KEY'] },
18
+ { name: 'tavily', envVars: ['TAVILY_API_KEY'] },
19
+ ]);
20
+
21
+ /** 통합 MCP — API 키 + 추가 설정 필요 */
22
+ export const INTEGRATION_SERVERS = Object.freeze([
23
+ { name: 'jira', envVars: ['JIRA_API_TOKEN', 'JIRA_EMAIL', 'JIRA_INSTANCE_URL'] },
24
+ { name: 'notion', envVars: ['NOTION_TOKEN'] },
25
+ { name: 'notion-guest', envVars: ['NOTION_TOKEN'] },
26
+ ]);
27
+
28
+ export function readManifest() {
29
+ if (!existsSync(MANIFEST_PATH)) return null;
30
+ try {
31
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function writeManifest(enabledServers) {
38
+ const dir = dirname(MANIFEST_PATH);
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
+ const manifest = {
41
+ version: 1,
42
+ updatedAt: new Date().toISOString(),
43
+ enabled: [...new Set([...CORE_SERVERS, ...enabledServers])],
44
+ };
45
+ writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
46
+ return manifest;
47
+ }
48
+
49
+ /**
50
+ * 매니페스트 기준으로 활성 서버만 필터링.
51
+ * @param {Array<string|{name:string}>} allServers — 전체 서버 목록
52
+ * @returns {Array} 활성 서버 목록. 매니페스트 미존재 시 null (레거시 모드).
53
+ */
54
+ export function filterByManifest(allServers) {
55
+ const manifest = readManifest();
56
+ if (!manifest) return null;
57
+ const enabled = new Set(manifest.enabled || []);
58
+ for (const core of CORE_SERVERS) enabled.add(core);
59
+ return allServers.filter((s) => enabled.has(typeof s === 'string' ? s : s.name));
60
+ }
61
+
62
+ /**
63
+ * 단일 서버 활성화 여부 확인.
64
+ * 매니페스트 미존재 시 true (레거시 호환).
65
+ */
66
+ export function isServerEnabled(serverName) {
67
+ const manifest = readManifest();
68
+ if (!manifest) return true;
69
+ if (CORE_SERVERS.includes(serverName)) return true;
70
+ return (manifest.enabled || []).includes(serverName);
71
+ }
72
+
73
+ /** 특정 서버에 필요한 환경변수 중 누락된 것 반환 */
74
+ export function getMissingEnvVars(serverName) {
75
+ const all = [...SEARCH_SERVERS, ...INTEGRATION_SERVERS];
76
+ const entry = all.find((s) => s.name === serverName);
77
+ if (!entry) return [];
78
+ return entry.envVars.filter((k) => !process.env[k]);
79
+ }
@@ -0,0 +1,118 @@
1
+ export const SEARCH_SERVER_ORDER = Object.freeze(['brave-search', 'tavily', 'exa']);
2
+
3
+ export const MCP_SERVER_TOOL_CATALOG = Object.freeze({
4
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
5
+ 'brave-search': Object.freeze(['brave_web_search', 'brave_news_search']),
6
+ exa: Object.freeze(['web_search_exa', 'get_code_context_exa']),
7
+ tavily: Object.freeze(['tavily_search', 'tavily_extract']),
8
+ playwright: Object.freeze([
9
+ 'browser_navigate',
10
+ 'browser_navigate_back',
11
+ 'browser_snapshot',
12
+ 'browser_take_screenshot',
13
+ 'browser_wait_for',
14
+ ]),
15
+ 'sequential-thinking': Object.freeze(['sequentialthinking']),
16
+ });
17
+
18
+ export const MCP_SERVER_DOMAIN_TAGS = Object.freeze({
19
+ context7: Object.freeze(['docs', 'reference', 'api', 'sdk', 'library']),
20
+ 'brave-search': Object.freeze(['web', 'search', 'news', 'current']),
21
+ exa: Object.freeze(['code', 'repository', 'examples', 'search']),
22
+ tavily: Object.freeze(['research', 'search', 'news', 'verification', 'current']),
23
+ playwright: Object.freeze(['browser', 'ui', 'visual', 'e2e']),
24
+ 'sequential-thinking': Object.freeze(['analysis', 'planning', 'reasoning', 'security', 'review']),
25
+ });
26
+
27
+ export const DOMAIN_TAG_KEYWORDS = Object.freeze({
28
+ docs: Object.freeze(['docs', 'documentation', 'manual', 'guide', '문서', '가이드', '매뉴얼']),
29
+ reference: Object.freeze(['reference', 'spec', 'schema', 'official', '레퍼런스', '공식', '스펙', '스키마']),
30
+ api: Object.freeze(['api', 'endpoint', 'interface', 'sdk', '호출', '엔드포인트']),
31
+ sdk: Object.freeze(['sdk', 'library', 'package', 'framework', '라이브러리', '패키지', '프레임워크']),
32
+ library: Object.freeze(['library', 'package', 'framework', 'module', '라이브러리', '패키지', '모듈']),
33
+ web: Object.freeze(['web', 'site', 'article', 'forum', 'blog', 'reddit', '웹', '사이트', '기사', '포럼', '블로그']),
34
+ search: Object.freeze(['search', 'browse', 'lookup', 'find', '검색', '조회', '탐색', '찾아']),
35
+ news: Object.freeze(['latest', 'recent', 'news', 'today', 'release', 'announcement', '최신', '최근', '뉴스', '오늘', '릴리즈', '공지']),
36
+ current: Object.freeze(['current', 'status', 'pricing', 'changelog', 'up-to-date', '현재', '상태', '가격', '변경사항']),
37
+ research: Object.freeze(['research', 'verify', 'fact-check', 'investigate', '리서치', '검증', '조사']),
38
+ verification: Object.freeze(['verify', 'validation', 'fact-check', 'audit', '검증', '확인', '감사']),
39
+ code: Object.freeze(['code', 'repo', 'repository', 'source', 'implementation', 'bug', 'fix', 'test', 'snippet', 'cli', '코드', '리포', '저장소', '구현', '버그', '테스트', '예제', '스크립트']),
40
+ repository: Object.freeze(['repo', 'repository', 'source', 'git', 'github', '리포', '저장소', '소스']),
41
+ examples: Object.freeze(['example', 'examples', 'snippet', 'sample', '예제', '샘플']),
42
+ browser: Object.freeze(['browser', 'page', 'dom', 'screenshot', 'render', '브라우저', '페이지', '스크린샷', '렌더']),
43
+ ui: Object.freeze(['ui', 'ux', 'layout', 'responsive', 'css', 'html', '디자인', '레이아웃', '반응형']),
44
+ visual: Object.freeze(['visual', 'screenshot', 'layout', 'render', 'screen', '화면', '시각', '스크린샷']),
45
+ e2e: Object.freeze(['playwright', 'e2e', 'click', 'navigate', 'automation', 'playwright', '클릭', '이동', '자동화']),
46
+ analysis: Object.freeze(['analysis', 'analyze', 'audit', 'compare', 'root cause', '분석', '검토', '비교', '원인']),
47
+ planning: Object.freeze(['plan', 'planning', 'strategy', 'design', '계획', '전략', '설계']),
48
+ reasoning: Object.freeze(['reason', 'reasoning', 'think', 'critique', '추론', '사고', '비평']),
49
+ security: Object.freeze(['security', 'risk', 'threat', 'vulnerability', '보안', '위험', '취약점']),
50
+ review: Object.freeze(['review', 'reviewer', 'inspect', '리뷰', '검수']),
51
+ });
52
+
53
+ export const SERVER_EXPLICIT_KEYWORDS = Object.freeze({
54
+ context7: Object.freeze(['context7']),
55
+ 'brave-search': Object.freeze(['brave', 'brave-search']),
56
+ exa: Object.freeze(['exa']),
57
+ tavily: Object.freeze(['tavily']),
58
+ playwright: Object.freeze(['playwright']),
59
+ 'sequential-thinking': Object.freeze(['sequential-thinking', 'sequential thinking']),
60
+ });
61
+
62
+ export function uniqueStrings(values = []) {
63
+ return [...new Set(
64
+ values
65
+ .filter((value) => typeof value === 'string' && value.trim())
66
+ .map((value) => value.trim()),
67
+ )];
68
+ }
69
+
70
+ export function inferDomainTagsFromText(text = '') {
71
+ if (typeof text !== 'string' || !text.trim()) return [];
72
+ const normalized = text.toLocaleLowerCase();
73
+ const matched = [];
74
+
75
+ for (const [tag, keywords] of Object.entries(DOMAIN_TAG_KEYWORDS)) {
76
+ if (keywords.some((keyword) => normalized.includes(String(keyword).toLocaleLowerCase()))) {
77
+ matched.push(tag);
78
+ }
79
+ }
80
+
81
+ return uniqueStrings(matched);
82
+ }
83
+
84
+ export function getDefaultServerMetadata(serverName = '') {
85
+ const toolCount = MCP_SERVER_TOOL_CATALOG[serverName]?.length || 0;
86
+ const domainTags = uniqueStrings([
87
+ ...(MCP_SERVER_DOMAIN_TAGS[serverName] || []),
88
+ ...inferDomainTagsFromText(serverName),
89
+ ]);
90
+
91
+ return {
92
+ tool_count: toolCount,
93
+ domain_tags: domainTags,
94
+ };
95
+ }
96
+
97
+ export function normalizeServerMetadata(serverName = '', metadata = {}) {
98
+ const fallback = getDefaultServerMetadata(serverName);
99
+ const toolCount = Number.isFinite(metadata.tool_count)
100
+ ? Math.max(0, Math.trunc(metadata.tool_count))
101
+ : fallback.tool_count;
102
+ const domainTags = uniqueStrings([
103
+ ...fallback.domain_tags,
104
+ ...(Array.isArray(metadata.domain_tags) ? metadata.domain_tags : []),
105
+ ...inferDomainTagsFromText([
106
+ serverName,
107
+ typeof metadata.command === 'string' ? metadata.command : '',
108
+ typeof metadata.url === 'string' ? metadata.url : '',
109
+ ...(Array.isArray(metadata.args) ? metadata.args : []),
110
+ ...(metadata.env && typeof metadata.env === 'object' ? Object.keys(metadata.env) : []),
111
+ ].join(' ')),
112
+ ]);
113
+
114
+ return {
115
+ tool_count: toolCount,
116
+ domain_tags: domainTags,
117
+ };
118
+ }
@@ -0,0 +1,119 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export const PSMUX_RECOMMENDED_VERSION = "3.3.1";
4
+ export const PSMUX_REQUIRED_COMMANDS = [
5
+ "new-session",
6
+ "attach-session",
7
+ "kill-session",
8
+ "capture-pane",
9
+ ];
10
+
11
+ export const PSMUX_OPTIONAL_COMMANDS = [
12
+ "detach-client",
13
+ ];
14
+
15
+ export const PSMUX_INSTALL_COMMANDS = [
16
+ "winget install marlocarlo.psmux",
17
+ "scoop install psmux",
18
+ "choco install psmux",
19
+ "cargo install psmux",
20
+ ];
21
+
22
+ export const PSMUX_UPDATE_COMMANDS = [
23
+ "winget upgrade marlocarlo.psmux",
24
+ "scoop update psmux",
25
+ "choco upgrade psmux",
26
+ "cargo install psmux --force",
27
+ ];
28
+
29
+ export function formatPsmuxCommandList(commands = PSMUX_INSTALL_COMMANDS, indent = "") {
30
+ return commands.map((command) => `${indent}${command}`).join("\n");
31
+ }
32
+
33
+ export function formatPsmuxInstallGuidance(indent = "") {
34
+ return formatPsmuxCommandList(PSMUX_INSTALL_COMMANDS, indent);
35
+ }
36
+
37
+ export function formatPsmuxUpdateGuidance(indent = "") {
38
+ return formatPsmuxCommandList(PSMUX_UPDATE_COMMANDS, indent);
39
+ }
40
+
41
+ export function parsePsmuxVersion(output = "") {
42
+ const match = String(output).match(/psmux\s+v?(\d+\.\d+\.\d+)/i);
43
+ return match?.[1] || null;
44
+ }
45
+
46
+ export function compareSemver(a, b) {
47
+ const left = String(a || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
48
+ const right = String(b || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
49
+ for (let index = 0; index < 3; index += 1) {
50
+ if (left[index] > right[index]) return 1;
51
+ if (left[index] < right[index]) return -1;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ export function isRecommendedPsmuxVersion(version) {
57
+ if (!version) return false;
58
+ return compareSemver(version, PSMUX_RECOMMENDED_VERSION) >= 0;
59
+ }
60
+
61
+ export function probePsmuxSupport(options = {}) {
62
+ const execFileSyncFn = options.execFileSyncFn || execFileSync;
63
+ const bin = options.bin || "psmux";
64
+
65
+ try {
66
+ const versionOutput = execFileSyncFn(bin, ["-V"], {
67
+ encoding: "utf8",
68
+ timeout: 2000,
69
+ stdio: ["ignore", "pipe", "pipe"],
70
+ windowsHide: true,
71
+ });
72
+ const version = parsePsmuxVersion(versionOutput);
73
+
74
+ let helpOutput = "";
75
+ try {
76
+ helpOutput = execFileSyncFn(bin, ["--help"], {
77
+ encoding: "utf8",
78
+ timeout: 2000,
79
+ stdio: ["ignore", "pipe", "pipe"],
80
+ windowsHide: true,
81
+ });
82
+ } catch {
83
+ helpOutput = "";
84
+ }
85
+
86
+ const missingCommands = PSMUX_REQUIRED_COMMANDS.filter(
87
+ (command) => !helpOutput || !helpOutput.includes(command),
88
+ );
89
+ const missingOptionalCommands = PSMUX_OPTIONAL_COMMANDS.filter(
90
+ (command) => !helpOutput || !helpOutput.includes(command),
91
+ );
92
+
93
+ return {
94
+ ok: missingCommands.length === 0,
95
+ installed: true,
96
+ version,
97
+ recommendedVersion: PSMUX_RECOMMENDED_VERSION,
98
+ recommended: isRecommendedPsmuxVersion(version),
99
+ missingCommands,
100
+ missingOptionalCommands,
101
+ hasHelp: helpOutput.length > 0,
102
+ installHint: formatPsmuxInstallGuidance(" "),
103
+ updateHint: formatPsmuxUpdateGuidance(" "),
104
+ };
105
+ } catch {
106
+ return {
107
+ ok: false,
108
+ installed: false,
109
+ version: null,
110
+ recommendedVersion: PSMUX_RECOMMENDED_VERSION,
111
+ recommended: false,
112
+ missingCommands: [...PSMUX_REQUIRED_COMMANDS],
113
+ missingOptionalCommands: [...PSMUX_OPTIONAL_COMMANDS],
114
+ hasHelp: false,
115
+ installHint: formatPsmuxInstallGuidance(" "),
116
+ updateHint: formatPsmuxUpdateGuidance(" "),
117
+ };
118
+ }
119
+ }
@@ -0,0 +1,196 @@
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
+ import { basename, dirname, isAbsolute, resolve } from "path";
3
+
4
+ const URL_LIKE_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//u;
5
+ const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/u;
6
+
7
+ function normalizeMarkdownTarget(raw) {
8
+ const trimmed = String(raw || "").trim();
9
+ const match = /^<?([^>\s]+)>?(?:\s+["'][^"']*["'])?$/u.exec(trimmed);
10
+ return match ? match[1] : trimmed;
11
+ }
12
+
13
+ function sanitizeToken(raw) {
14
+ let token = String(raw || "").trim();
15
+ if (!token) return "";
16
+ token = token.replace(/^[<(]+/u, "").replace(/[)>.,;:!?]+$/u, "").trim();
17
+ return token;
18
+ }
19
+
20
+ function isLikelyPathToken(token) {
21
+ if (!token || URL_LIKE_RE.test(token)) return false;
22
+ if (token.startsWith("app://") || token.startsWith("plugin://") || token.startsWith("mention://")) return false;
23
+ if (token === "~") return false;
24
+
25
+ const hasPathSeparator = token.includes("/") || token.includes("\\");
26
+ const hasDotPrefix = token.startsWith("./") || token.startsWith("../") || token.startsWith(".");
27
+ const isHomeRelative = token.startsWith("~/") || token.startsWith("~\\");
28
+ const isWindowsAbs = WINDOWS_ABS_RE.test(token);
29
+ const hasFileLikeSuffix = /\.[a-zA-Z0-9]{1,16}$/u.test(token);
30
+
31
+ return hasPathSeparator || hasDotPrefix || isHomeRelative || isWindowsAbs || hasFileLikeSuffix;
32
+ }
33
+
34
+ export function extractExplicitFileTokens(text) {
35
+ const content = String(text || "");
36
+ const candidates = [];
37
+
38
+ for (const match of content.matchAll(/\[[^\]]+\]\(([^)\n]+)\)/gu)) {
39
+ candidates.push(normalizeMarkdownTarget(match[1]));
40
+ }
41
+ for (const match of content.matchAll(/`([^`\n]+)`/gu)) {
42
+ candidates.push(match[1]);
43
+ }
44
+ for (const match of content.matchAll(/"([^"\n]+)"/gu)) {
45
+ candidates.push(match[1]);
46
+ }
47
+ for (const match of content.matchAll(/'([^'\n]+)'/gu)) {
48
+ candidates.push(match[1]);
49
+ }
50
+
51
+ const unique = new Set();
52
+ for (const raw of candidates) {
53
+ const token = sanitizeToken(raw);
54
+ if (!isLikelyPathToken(token)) continue;
55
+ unique.add(token);
56
+ }
57
+
58
+ return Array.from(unique);
59
+ }
60
+
61
+ function validateTransferFile(filePath, maxBytes) {
62
+ if (!existsSync(filePath)) {
63
+ throw new Error(`referenced file not found: ${filePath}`);
64
+ }
65
+ const stats = statSync(filePath);
66
+ if (!stats.isFile()) {
67
+ throw new Error(`referenced path is not a file: ${filePath}`);
68
+ }
69
+ if (stats.size > maxBytes) {
70
+ throw new Error(`referenced file too large: ${stats.size} bytes (max ${maxBytes}) for ${filePath}`);
71
+ }
72
+ return stats.size;
73
+ }
74
+
75
+ function resolveReferencePath(token, handoffAbsPath, cwd) {
76
+ if (WINDOWS_ABS_RE.test(token) || isAbsolute(token)) {
77
+ return resolve(token);
78
+ }
79
+
80
+ if (token.startsWith("~/") || token.startsWith("~\\")) {
81
+ if (!process.env.HOME && !process.env.USERPROFILE) {
82
+ return null;
83
+ }
84
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
85
+ return resolve(homeDir, token.slice(2));
86
+ }
87
+
88
+ const handoffDir = dirname(handoffAbsPath);
89
+ const primary = resolve(handoffDir, token);
90
+ if (existsSync(primary)) {
91
+ return primary;
92
+ }
93
+
94
+ const fallback = resolve(cwd, token);
95
+ return existsSync(fallback) ? fallback : primary;
96
+ }
97
+
98
+ function toRemotePath(stageRoot, relativePath) {
99
+ const root = String(stageRoot).replace(/\\/gu, "/").replace(/\/+$/u, "");
100
+ const rel = String(relativePath).replace(/\\/gu, "/").replace(/^\/+/, "");
101
+ return `${root}/${rel}`;
102
+ }
103
+
104
+ export function buildRemoteTransferPlan(options = {}) {
105
+ const {
106
+ cwd = process.cwd(),
107
+ handoffPath = null,
108
+ maxBytes,
109
+ remoteStageRoot,
110
+ userPrompt = null,
111
+ maxReferenceFiles = 32,
112
+ } = options;
113
+
114
+ if (!handoffPath) {
115
+ return {
116
+ prompt: userPrompt || "",
117
+ replacements: [],
118
+ stagedHandoffPath: null,
119
+ transfers: [],
120
+ };
121
+ }
122
+
123
+ if (!remoteStageRoot) {
124
+ throw new Error("remoteStageRoot is required when handoffPath is provided");
125
+ }
126
+
127
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
128
+ throw new Error("maxBytes must be a positive number");
129
+ }
130
+
131
+ const absoluteHandoffPath = resolve(cwd, handoffPath);
132
+ validateTransferFile(absoluteHandoffPath, maxBytes);
133
+ const handoffContent = readFileSync(absoluteHandoffPath, "utf8").trim();
134
+
135
+ const stagedHandoffPath = toRemotePath(remoteStageRoot, `handoff/${basename(absoluteHandoffPath)}`);
136
+ const transfers = [{
137
+ localPath: absoluteHandoffPath,
138
+ remotePath: stagedHandoffPath,
139
+ type: "handoff",
140
+ }];
141
+
142
+ const tokens = extractExplicitFileTokens(handoffContent);
143
+ if (tokens.length > maxReferenceFiles) {
144
+ throw new Error(`too many referenced files: ${tokens.length} (max ${maxReferenceFiles})`);
145
+ }
146
+
147
+ const stagedByLocalPath = new Map();
148
+ const replacements = [];
149
+ let fileIndex = 0;
150
+
151
+ for (const token of tokens) {
152
+ const resolvedPath = resolveReferencePath(token, absoluteHandoffPath, cwd);
153
+ validateTransferFile(resolvedPath, maxBytes);
154
+
155
+ if (!stagedByLocalPath.has(resolvedPath)) {
156
+ fileIndex += 1;
157
+ const stagedPath = toRemotePath(
158
+ remoteStageRoot,
159
+ `refs/${String(fileIndex).padStart(2, "0")}-${basename(resolvedPath)}`,
160
+ );
161
+ stagedByLocalPath.set(resolvedPath, stagedPath);
162
+ transfers.push({
163
+ localPath: resolvedPath,
164
+ remotePath: stagedPath,
165
+ type: "reference",
166
+ });
167
+ }
168
+
169
+ replacements.push({ token, stagedPath: stagedByLocalPath.get(resolvedPath) });
170
+ }
171
+
172
+ const replacementEntries = Array.from(
173
+ replacements
174
+ .reduce((map, entry) => map.set(entry.token, entry.stagedPath), new Map())
175
+ .entries(),
176
+ ).sort((a, b) => b[0].length - a[0].length);
177
+
178
+ let rewrittenHandoff = handoffContent;
179
+ for (const [token, stagedPath] of replacementEntries) {
180
+ rewrittenHandoff = rewrittenHandoff.split(token).join(stagedPath);
181
+ }
182
+
183
+ const prefix = `Staged handoff file: ${stagedHandoffPath}`;
184
+ let prompt = rewrittenHandoff ? `${prefix}\n\n${rewrittenHandoff}` : prefix;
185
+
186
+ if (userPrompt) {
187
+ prompt = `${prompt}\n\n---\n\n${userPrompt}`;
188
+ }
189
+
190
+ return {
191
+ prompt,
192
+ replacements: replacementEntries.map(([token, stagedPath]) => ({ stagedPath, token })),
193
+ stagedHandoffPath,
194
+ transfers,
195
+ };
196
+ }