@triflux/core 10.0.0-alpha.1 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/hooks/hook-adaptive-collector.mjs +86 -0
  2. package/hooks/hook-manager.mjs +15 -2
  3. package/hooks/hook-registry.json +37 -4
  4. package/hooks/keyword-rules.json +2 -1
  5. package/hooks/mcp-config-watcher.mjs +2 -7
  6. package/hooks/safety-guard.mjs +37 -0
  7. package/hub/account-broker.mjs +251 -0
  8. package/hub/adaptive-diagnostic.mjs +323 -0
  9. package/hub/adaptive-inject.mjs +186 -0
  10. package/hub/adaptive-memory.mjs +163 -0
  11. package/hub/adaptive.mjs +143 -0
  12. package/hub/cli-adapter-base.mjs +89 -1
  13. package/hub/codex-adapter.mjs +12 -3
  14. package/hub/codex-compat.mjs +11 -78
  15. package/hub/codex-preflight.mjs +20 -1
  16. package/hub/gemini-adapter.mjs +1 -0
  17. package/hub/index.mjs +34 -0
  18. package/hub/lib/cache-guard.mjs +114 -0
  19. package/hub/lib/known-errors.json +72 -0
  20. package/hub/lib/memory-store.mjs +748 -0
  21. package/hub/lib/ssh-command.mjs +150 -0
  22. package/hub/lib/uuidv7.mjs +44 -0
  23. package/hub/memory-doctor.mjs +480 -0
  24. package/hub/middleware/request-logger.mjs +80 -0
  25. package/hub/router.mjs +1 -1
  26. package/hub/team-bridge.mjs +21 -19
  27. package/hud/constants.mjs +7 -0
  28. package/hud/context-monitor.mjs +403 -0
  29. package/hud/hud-qos-status.mjs +8 -4
  30. package/hud/providers/claude.mjs +5 -0
  31. package/hud/renderers.mjs +32 -14
  32. package/hud/utils.mjs +26 -0
  33. package/package.json +3 -2
  34. package/scripts/lib/claudemd-scanner.mjs +218 -0
  35. package/scripts/lib/handoff.mjs +171 -0
  36. package/scripts/lib/mcp-guard-engine.mjs +20 -6
  37. package/scripts/lib/skill-template.mjs +269 -0
  38. package/scripts/lib/claudemd-manager.mjs +0 -325
package/hud/renderers.mjs CHANGED
@@ -16,14 +16,15 @@ import {
16
16
  TEAM_STATE_PATH, SV_ACCUMULATOR_PATH, LEGACY_SV_ACCUMULATOR,
17
17
  } from "./constants.mjs";
18
18
  import {
19
- readJson, readJsonMigrate, stripAnsi, padAnsiRight, fitText,
19
+ readJson, readJsonMigrate, stripAnsi, padAnsiRight, fitText, truncateAnsi,
20
20
  clampPercent, formatPercentCell, formatPlaceholderPercentCell,
21
21
  formatTimeCell, formatTimeCellDH,
22
22
  formatResetRemaining, formatResetRemainingDayHour,
23
- getContextPercent, formatTokenCount, formatSvPct, formatSavings,
23
+ formatTokenCount, formatSvPct, formatSavings,
24
24
  } from "./utils.mjs";
25
- import { tierBar, tierDimBar } from "./terminal.mjs";
25
+ import { tierBar, tierDimBar, getTerminalColumns } from "./terminal.mjs";
26
26
  import { deriveGeminiLimits } from "./providers/gemini.mjs";
27
+ import { buildContextUsageView } from "./context-monitor.mjs";
27
28
 
28
29
  // ============================================================================
29
30
  // 최근 벤치마크 diff 파일 읽기
@@ -125,27 +126,39 @@ export function getTeamRow(currentTier) {
125
126
  // 행 정렬 렌더링
126
127
  // ============================================================================
127
128
  export function renderAlignedRows(rows) {
129
+ const cols = getTerminalColumns() || 120;
128
130
  const rightRows = rows.filter((row) => stripAnsi(String(row.right || "")).trim().length > 0);
129
131
  const rawLeftWidth = rightRows.reduce((max, row) => Math.max(max, stripAnsi(row.left).length), 0);
130
132
  return rows.map((row) => {
131
133
  const prefix = padAnsiRight(row.prefix, PROVIDER_PREFIX_WIDTH);
132
134
  const hasRight = stripAnsi(String(row.right || "")).trim().length > 0;
133
135
  if (!hasRight) {
134
- return `${prefix} ${row.left}`;
136
+ return truncateAnsi(`${prefix} ${row.left}`, cols);
135
137
  }
136
138
  // 자기 left 대비 패딩 상한: 최대 2칸까지만 패딩 (과도한 공백 방지)
137
139
  const ownLen = stripAnsi(row.left).length;
138
140
  const effectiveWidth = Math.min(rawLeftWidth, ownLen + 2);
139
141
  const left = padAnsiRight(row.left, effectiveWidth);
140
- return `${prefix} ${left} ${dim("|")} ${row.right}`;
142
+ // 우선순위 기반 truncate: right 먼저 축소, 그래도 넘치면 left 축소
143
+ // prefix(PROVIDER_PREFIX_WIDTH) + " "(1) + left(effectiveWidth) + " | "(3) + right
144
+ const fixedWidth = PROVIDER_PREFIX_WIDTH + 1 + effectiveWidth + 3;
145
+ const availableForRight = cols - fixedWidth;
146
+ if (availableForRight <= 0) {
147
+ return truncateAnsi(`${prefix} ${left}`, cols);
148
+ }
149
+ const rightVisible = stripAnsi(row.right).length;
150
+ const right = rightVisible <= availableForRight
151
+ ? row.right
152
+ : truncateAnsi(row.right, availableForRight);
153
+ return `${prefix} ${left} ${dim("|")} ${right}`;
141
154
  });
142
155
  }
143
156
 
144
157
  // ============================================================================
145
158
  // micro tier: 모든 프로바이더를 1줄로 압축
146
159
  // ============================================================================
147
- export function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
148
- const ctx = getContextPercent(stdin);
160
+ export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
161
+ const ctxView = contextView || buildContextUsageView({}, null);
149
162
 
150
163
  // Claude 5h/1w
151
164
  const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
@@ -180,18 +193,20 @@ export function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, ge
180
193
  // sv
181
194
  const sv = formatSvPct(combinedSvPct || 0).trim();
182
195
 
183
- return `${bold(claudeOrange("c"))}${dim(":")}${cVal} ` +
196
+ const cols = getTerminalColumns() || 120;
197
+ const line = `${bold(claudeOrange("c"))}${dim(":")}${cVal} ` +
184
198
  `${bold(codexWhite("x"))}${dim(":")}${xVal} ` +
185
199
  `${bold(geminiBlue("g"))}${dim(":")}${gVal} ` +
186
200
  `${dim("sv:")}${sv} ` +
187
- `${dim("ctx:")}${colorByPercent(ctx, `${ctx}%`)}`;
201
+ `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
202
+ return truncateAnsi(line, cols);
188
203
  }
189
204
 
190
205
  // ============================================================================
191
206
  // Claude 행 렌더러
192
207
  // ============================================================================
193
- export function getClaudeRows(currentTier, stdin, claudeUsage, combinedSvPct) {
194
- const contextPercent = getContextPercent(stdin);
208
+ export function getClaudeRows(currentTier, contextView, claudeUsage, combinedSvPct) {
209
+ const ctxView = contextView || buildContextUsageView({}, null);
195
210
  const prefix = `${bold(claudeOrange("c"))}:`;
196
211
 
197
212
  // 절약 퍼센트
@@ -226,18 +241,21 @@ export function getClaudeRows(currentTier, stdin, claudeUsage, combinedSvPct) {
226
241
 
227
242
  if (currentTier === "minimal") {
228
243
  const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}`;
229
- return [{ prefix, left: quotaSection, right: "" }];
244
+ const right = `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
245
+ return [{ prefix, left: quotaSection, right }];
230
246
  }
231
247
 
232
248
  if (currentTier === "compact") {
233
249
  const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
234
- const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
250
+ const warning = ctxView.warningTag ? ` ${dim("|")} ${yellow(ctxView.warningTag)}` : "";
251
+ const contextSection = `${svSuffix} ${dim("|")} ${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${warning}`;
235
252
  return [{ prefix, left: quotaSection, right: contextSection }];
236
253
  }
237
254
 
238
255
  // full tier (>= 120 cols)
239
256
  const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
240
- const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
257
+ const warning = ctxView.warningTag ? ` ${dim("|")} ${yellow(ctxView.warningTag)}` : "";
258
+ const contextSection = `${svSuffix} ${dim("|")} ${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${warning}`;
241
259
  return [{ prefix, left: quotaSection, right: contextSection }];
242
260
  }
243
261
 
package/hud/utils.mjs CHANGED
@@ -82,6 +82,32 @@ export function fitText(text, width) {
82
82
  return `${t.slice(0, width - 1)}…`;
83
83
  }
84
84
 
85
+ /**
86
+ * ANSI 이스케이프 시퀀스를 보존하면서 visible width 기준으로 truncate.
87
+ * maxWidth 초과 시 잘라내고 RESET(\x1b[0m)을 추가한다.
88
+ */
89
+ export function truncateAnsi(text, maxWidth) {
90
+ if (maxWidth <= 0) return "";
91
+ const str = String(text);
92
+ let visible = 0;
93
+ let i = 0;
94
+ while (i < str.length) {
95
+ if (str.charCodeAt(i) === 0x1b && str[i + 1] === "[") {
96
+ const mIdx = str.indexOf("m", i + 2);
97
+ if (mIdx !== -1) {
98
+ i = mIdx + 1;
99
+ continue;
100
+ }
101
+ }
102
+ visible++;
103
+ if (visible > maxWidth) {
104
+ return str.slice(0, i) + "\x1b[0m";
105
+ }
106
+ i++;
107
+ }
108
+ return str;
109
+ }
110
+
85
111
  export function makeHash(text) {
86
112
  return createHash("sha256").update(String(text || ""), "utf8").digest("hex").slice(0, 16);
87
113
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.0.0-alpha.1",
3
+ "version": "10.0.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
7
7
  "exports": {
8
8
  ".": "./hub/index.mjs",
9
- "./hub/*": "./hub/*"
9
+ "./hub/*": "./hub/*",
10
+ "./scripts/*": "./scripts/*"
10
11
  },
11
12
  "engines": { "node": ">=18.0.0" },
12
13
  "dependencies": {
@@ -0,0 +1,218 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const TFX_START = "<!-- TFX:START -->";
7
+ export const TFX_END = "<!-- TFX:END -->";
8
+ export const OMC_END = "<!-- OMC:END -->";
9
+ const TFX_VERSION_RE = /<!-- TFX:VERSION:([\d.]+) -->/u;
10
+
11
+ const LEGACY_PATTERNS = [
12
+ /<user_cli_routing>/u,
13
+ /Codex Pro 무료 기간/u,
14
+ /codex exec --dangerously-bypass.*skip-git-repo-check/u,
15
+ /OMC 에이전트 → CLI 매핑/u,
16
+ /Spark 가드레일/u,
17
+ ];
18
+
19
+ const DEFAULT_TFX_TEMPLATE = [
20
+ "### triflux CLI routing (managed)",
21
+ "- 이 블록은 triflux setup에서 자동으로 관리됩니다.",
22
+ "- 직접 수정이 필요하면 블록 바깥에 사용자 섹션을 추가하세요.",
23
+ ].join("\n");
24
+
25
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
26
+ const DEFAULT_TEMPLATE_PATH = join(SCRIPT_DIR, "..", "templates", "claudemd-tfx-section.md");
27
+
28
+ function resolveVersion(version) {
29
+ if (version) return version;
30
+ try {
31
+ const pkgPath = join(SCRIPT_DIR, "..", "..", "package.json");
32
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
33
+ return pkg.version || "0.0.0";
34
+ } catch {
35
+ return "0.0.0";
36
+ }
37
+ }
38
+
39
+ function resolveTemplate(template, templatePath = DEFAULT_TEMPLATE_PATH) {
40
+ if (typeof template === "string" && template.trim()) {
41
+ return template.trim();
42
+ }
43
+ if (existsSync(templatePath)) {
44
+ const raw = readFileSync(templatePath, "utf8").trim();
45
+ if (raw) return raw;
46
+ }
47
+ return DEFAULT_TFX_TEMPLATE;
48
+ }
49
+
50
+ function findManagedSection(rawText) {
51
+ const raw = String(rawText || "");
52
+ const startIdx = raw.indexOf(TFX_START);
53
+ const endMarkerIdx = raw.indexOf(TFX_END);
54
+ if (startIdx === -1 || endMarkerIdx === -1 || endMarkerIdx <= startIdx) {
55
+ return {
56
+ found: false,
57
+ content: null,
58
+ version: null,
59
+ startIdx: -1,
60
+ endIdx: -1,
61
+ };
62
+ }
63
+
64
+ const endIdx = endMarkerIdx + TFX_END.length;
65
+ const content = raw.slice(startIdx, endIdx);
66
+ const versionMatch = content.match(TFX_VERSION_RE);
67
+
68
+ return {
69
+ found: true,
70
+ content,
71
+ version: versionMatch ? versionMatch[1] : null,
72
+ startIdx,
73
+ endIdx,
74
+ };
75
+ }
76
+
77
+ function detectLegacyRange(rawText) {
78
+ const raw = String(rawText || "");
79
+ const matches = LEGACY_PATTERNS.some((pattern) => pattern.test(raw));
80
+ if (!matches) {
81
+ return { found: false, startIdx: -1, endIdx: -1 };
82
+ }
83
+
84
+ const startTag = raw.indexOf("<user_cli_routing>");
85
+ const endTag = raw.indexOf("</user_cli_routing>");
86
+ if (startTag === -1 || endTag === -1 || endTag <= startTag) {
87
+ return { found: true, startIdx: -1, endIdx: -1 };
88
+ }
89
+
90
+ let removeStart = startTag;
91
+ const userOverridesComment = "<!-- USER OVERRIDES";
92
+ const commentIdx = raw.lastIndexOf(userOverridesComment, startTag);
93
+ if (commentIdx !== -1 && startTag - commentIdx < 200) {
94
+ removeStart = commentIdx;
95
+ }
96
+
97
+ return {
98
+ found: true,
99
+ startIdx: removeStart,
100
+ endIdx: endTag + "</user_cli_routing>".length,
101
+ };
102
+ }
103
+
104
+ function normalizeSpacing(text) {
105
+ return String(text || "").replace(/\n{3,}/gu, "\n\n");
106
+ }
107
+
108
+ export function findAllClaudeMdPaths(options = {}) {
109
+ const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
110
+ const homeDir = options.homeDir ? resolve(options.homeDir) : homedir();
111
+ const includeGlobal = options.includeGlobal !== false;
112
+ const includeProject = options.includeProject !== false;
113
+
114
+ const candidates = [];
115
+ if (includeGlobal) candidates.push(join(homeDir, ".claude", "CLAUDE.md"));
116
+ if (includeProject) candidates.push(join(cwd, "CLAUDE.md"));
117
+
118
+ const seen = new Set();
119
+ const paths = [];
120
+ for (const candidate of candidates) {
121
+ const normalized = resolve(candidate);
122
+ if (seen.has(normalized)) continue;
123
+ seen.add(normalized);
124
+ if (existsSync(normalized)) {
125
+ paths.push(normalized);
126
+ }
127
+ }
128
+ return paths;
129
+ }
130
+
131
+ export function writeSection(filePath, options = {}) {
132
+ const absolutePath = resolve(filePath);
133
+ const version = resolveVersion(options.version);
134
+ const template = resolveTemplate(options.template, options.templatePath);
135
+ const block = [
136
+ TFX_START,
137
+ `<!-- TFX:VERSION:${version} -->`,
138
+ template,
139
+ TFX_END,
140
+ ].join("\n");
141
+
142
+ if (!existsSync(absolutePath)) {
143
+ writeFileSync(absolutePath, `${block}\n`, "utf8");
144
+ return { action: "created", version, path: absolutePath };
145
+ }
146
+
147
+ const raw = readFileSync(absolutePath, "utf8");
148
+ const existing = findManagedSection(raw);
149
+
150
+ if (existing.found) {
151
+ const nextContent = `${raw.slice(0, existing.startIdx)}${block}${raw.slice(existing.endIdx)}`;
152
+ writeFileSync(absolutePath, nextContent, "utf8");
153
+ return {
154
+ action: "updated",
155
+ oldVersion: existing.version,
156
+ version,
157
+ path: absolutePath,
158
+ };
159
+ }
160
+
161
+ const omcEndIdx = raw.indexOf(OMC_END);
162
+ if (omcEndIdx !== -1) {
163
+ const insertAt = omcEndIdx + OMC_END.length;
164
+ const before = raw.slice(0, insertAt);
165
+ const after = raw.slice(insertAt);
166
+ const nextContent = `${before}\n${block}${after}`;
167
+ writeFileSync(absolutePath, nextContent, "utf8");
168
+ return { action: "inserted_after_omc", version, path: absolutePath };
169
+ }
170
+
171
+ const separator = raw.endsWith("\n") ? "" : "\n";
172
+ writeFileSync(absolutePath, `${raw}${separator}${block}\n`, "utf8");
173
+ return { action: "appended", version, path: absolutePath };
174
+ }
175
+
176
+ export function migrateClaudeMd(filePath, options = {}) {
177
+ const absolutePath = resolve(filePath);
178
+ if (!existsSync(absolutePath)) {
179
+ return { action: "no_file", removed: [], path: absolutePath };
180
+ }
181
+
182
+ const raw = readFileSync(absolutePath, "utf8");
183
+ const existing = findManagedSection(raw);
184
+ const legacy = detectLegacyRange(raw);
185
+
186
+ if (existing.found && !legacy.found) {
187
+ return {
188
+ action: "already_managed",
189
+ removed: [],
190
+ version: existing.version,
191
+ path: absolutePath,
192
+ };
193
+ }
194
+
195
+ const removed = [];
196
+ let nextContent = raw;
197
+
198
+ if (legacy.found && legacy.startIdx !== -1 && legacy.endIdx !== -1) {
199
+ nextContent = `${nextContent.slice(0, legacy.startIdx)}${nextContent.slice(legacy.endIdx)}`;
200
+ removed.push("<user_cli_routing> block");
201
+ }
202
+
203
+ const existingAfterLegacy = findManagedSection(nextContent);
204
+ if (existingAfterLegacy.found) {
205
+ nextContent = `${nextContent.slice(0, existingAfterLegacy.startIdx)}${nextContent.slice(existingAfterLegacy.endIdx)}`;
206
+ removed.push("old TFX block");
207
+ }
208
+
209
+ writeFileSync(absolutePath, normalizeSpacing(nextContent), "utf8");
210
+ const writeResult = writeSection(absolutePath, options);
211
+
212
+ return {
213
+ action: "migrated",
214
+ removed,
215
+ version: writeResult.version,
216
+ path: absolutePath,
217
+ };
218
+ }
@@ -0,0 +1,171 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, resolve } from "node:path";
4
+ import { findAllClaudeMdPaths } from "./claudemd-scanner.mjs";
5
+
6
+ function runCommand(command, cwd, executor = execSync) {
7
+ try {
8
+ const output = executor(command, {
9
+ cwd,
10
+ encoding: "utf8",
11
+ stdio: ["ignore", "pipe", "ignore"],
12
+ windowsHide: true,
13
+ });
14
+ return String(output || "").trimEnd();
15
+ } catch {
16
+ return "";
17
+ }
18
+ }
19
+
20
+ function parseStatusLines(statusText) {
21
+ const lines = String(statusText || "").split(/\r?\n/u).filter(Boolean);
22
+ const changedFiles = [];
23
+ const status = [];
24
+
25
+ for (const line of lines) {
26
+ const normalized = line.trimEnd();
27
+ if (!normalized) continue;
28
+
29
+ const rawStatus = normalized.slice(0, 2).trim() || "??";
30
+ const rawPath = normalized.slice(3).trim();
31
+ const filePath = rawPath.includes(" -> ") ? rawPath.split(" -> ").at(-1)?.trim() : rawPath;
32
+ if (!filePath) continue;
33
+
34
+ changedFiles.push(filePath);
35
+ status.push({ path: filePath, status: rawStatus });
36
+ }
37
+
38
+ return {
39
+ changedFiles: Array.from(new Set(changedFiles)),
40
+ status,
41
+ };
42
+ }
43
+
44
+ function normalizeDecisionLine(line) {
45
+ return String(line || "")
46
+ .trim()
47
+ .replace(/^[-*]\s+/u, "")
48
+ .trim();
49
+ }
50
+
51
+ function parseDecisionFile(decisionFile) {
52
+ if (!decisionFile) return [];
53
+ const absolute = resolve(decisionFile);
54
+ if (!existsSync(absolute)) return [];
55
+
56
+ const raw = readFileSync(absolute, "utf8");
57
+ return raw
58
+ .split(/\r?\n/u)
59
+ .map(normalizeDecisionLine)
60
+ .filter((line) => line.length > 0);
61
+ }
62
+
63
+ function normalizeDecisions(decisions, decisionFile) {
64
+ const merged = [
65
+ ...(Array.isArray(decisions) ? decisions : []),
66
+ ...parseDecisionFile(decisionFile),
67
+ ]
68
+ .map(normalizeDecisionLine)
69
+ .filter(Boolean);
70
+
71
+ return Array.from(new Set(merged));
72
+ }
73
+
74
+ function formatAheadBehind(value) {
75
+ if (!value) return null;
76
+ const [behindRaw, aheadRaw] = value.split(/\s+/u);
77
+ const behind = Number.parseInt(behindRaw, 10);
78
+ const ahead = Number.parseInt(aheadRaw, 10);
79
+ if (!Number.isFinite(behind) || !Number.isFinite(ahead)) return null;
80
+ return { ahead, behind };
81
+ }
82
+
83
+ export function collectHandoffContext(options = {}) {
84
+ const cwd = resolve(options.cwd || process.cwd());
85
+ const executor = typeof options.commandRunner === "function" ? options.commandRunner : execSync;
86
+ const target = options.target === "local" ? "local" : "remote";
87
+ const decisions = normalizeDecisions(options.decisions, options.decisionFile);
88
+ const generatedAt = options.generatedAt || new Date().toISOString();
89
+ const claudeMdPaths = Array.isArray(options.claudeMdPaths)
90
+ ? options.claudeMdPaths
91
+ : findAllClaudeMdPaths({
92
+ cwd,
93
+ homeDir: options.homeDir,
94
+ });
95
+
96
+ const gitRoot = runCommand("git rev-parse --show-toplevel", cwd, executor);
97
+ const branch = runCommand("git rev-parse --abbrev-ref HEAD", cwd, executor) || null;
98
+ const shortStatus = runCommand("git status --short", cwd, executor);
99
+ const diffStat = runCommand("git diff --stat --no-color", cwd, executor);
100
+ const upstreamRaw = runCommand("git rev-list --left-right --count @{upstream}...HEAD", cwd, executor);
101
+ const parsedStatus = parseStatusLines(shortStatus);
102
+
103
+ return {
104
+ generatedAt,
105
+ target,
106
+ cwd,
107
+ gitRoot: gitRoot || null,
108
+ repository: gitRoot ? basename(gitRoot) : basename(cwd),
109
+ branch,
110
+ upstream: formatAheadBehind(upstreamRaw),
111
+ changedFiles: parsedStatus.changedFiles,
112
+ fileStatus: parsedStatus.status,
113
+ diffStat,
114
+ decisions,
115
+ claudeMdPaths,
116
+ };
117
+ }
118
+
119
+ export function buildHandoffPrompt(context) {
120
+ const safeContext = context || collectHandoffContext();
121
+ const branch = safeContext.branch || "unknown";
122
+ const upstream = safeContext.upstream
123
+ ? `ahead ${safeContext.upstream.ahead}, behind ${safeContext.upstream.behind}`
124
+ : "unknown";
125
+ const changedFiles = safeContext.changedFiles.length > 0
126
+ ? safeContext.changedFiles.map((file) => `- ${file}`).join("\n")
127
+ : "- 변경 파일 없음";
128
+ const decisions = safeContext.decisions.length > 0
129
+ ? safeContext.decisions.map((decision) => `- ${decision}`).join("\n")
130
+ : "- 명시된 결정사항 없음";
131
+ const claudeMdList = Array.isArray(safeContext.claudeMdPaths) && safeContext.claudeMdPaths.length > 0
132
+ ? safeContext.claudeMdPaths.map((path) => `- ${path}`).join("\n")
133
+ : "- 자동 탐지된 CLAUDE.md 없음";
134
+ const diffStat = safeContext.diffStat || "(diff stat 없음)";
135
+
136
+ return [
137
+ "## TFX Remote Handoff",
138
+ `- generated_at: ${safeContext.generatedAt}`,
139
+ `- target: ${safeContext.target}`,
140
+ `- repository: ${safeContext.repository}`,
141
+ `- branch: ${branch} (${upstream})`,
142
+ `- cwd: ${safeContext.cwd}`,
143
+ "",
144
+ "### 변경 파일",
145
+ changedFiles,
146
+ "",
147
+ "### 변경 요약 (git diff --stat)",
148
+ "```",
149
+ diffStat,
150
+ "```",
151
+ "",
152
+ "### 결정사항",
153
+ decisions,
154
+ "",
155
+ "### CLAUDE.md 참조",
156
+ claudeMdList,
157
+ "",
158
+ "### 다음 세션 지시",
159
+ "- 위 변경사항을 먼저 검토하고 누락된 테스트를 확인하세요.",
160
+ "- 필요 시 CLAUDE.md 지침을 재확인한 뒤 작업을 이어가세요.",
161
+ "- 작업 완료 후 변경 파일과 검증 결과를 요약하세요.",
162
+ ].join("\n");
163
+ }
164
+
165
+ export function serializeHandoff(options = {}) {
166
+ const context = collectHandoffContext(options);
167
+ return {
168
+ ...context,
169
+ prompt: buildHandoffPrompt(context),
170
+ };
171
+ }
@@ -543,6 +543,18 @@ export function loadRegistry() {
543
543
  };
544
544
  }
545
545
 
546
+ export function loadRegistryOrDefault() {
547
+ const state = inspectRegistry();
548
+ if (!state.exists) return cloneDefaultRegistry();
549
+ if (!state.valid) return cloneDefaultRegistry();
550
+ return {
551
+ ...state.registry,
552
+ defaults: { ...(state.registry?.defaults || {}) },
553
+ servers: { ...(state.registry?.servers || {}) },
554
+ policies: { ...(state.registry?.policies || {}) },
555
+ };
556
+ }
557
+
546
558
  export function saveRegistry(registry) {
547
559
  const errors = validateRegistry(registry);
548
560
  if (errors.length > 0) {
@@ -552,7 +564,7 @@ export function saveRegistry(registry) {
552
564
  return registry;
553
565
  }
554
566
 
555
- export function listManagedConfigTargets(registry = loadRegistry()) {
567
+ export function listManagedConfigTargets(registry = loadRegistryOrDefault()) {
556
568
  return (registry?.policies?.watched_paths || []).map((watchedPath) => {
557
569
  const filePath = resolveFilePath(watchedPath);
558
570
  return {
@@ -565,18 +577,18 @@ export function listManagedConfigTargets(registry = loadRegistry()) {
565
577
  });
566
578
  }
567
579
 
568
- export function listPrimaryConfigTargets(registry = loadRegistry()) {
580
+ export function listPrimaryConfigTargets(registry = loadRegistryOrDefault()) {
569
581
  return listManagedConfigTargets(registry).filter((target) => isPrimaryConfigTarget(target.filePath));
570
582
  }
571
583
 
572
- export function scanManagedConfigs(registry = loadRegistry()) {
584
+ export function scanManagedConfigs(registry = loadRegistryOrDefault()) {
573
585
  return listManagedConfigTargets(registry).map((target) => ({
574
586
  ...target,
575
587
  ...scanConfig(target.filePath),
576
588
  }));
577
589
  }
578
590
 
579
- export function inspectRegistryStatus(registry = loadRegistry()) {
591
+ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
580
592
  const configs = scanManagedConfigs(registry);
581
593
  const primaryTargets = new Set(listPrimaryConfigTargets(registry).map((target) => normalizeForMatch(target.filePath)));
582
594
  const rows = [];
@@ -710,7 +722,7 @@ export function remediate(filePath, stdioServers, policy = {}) {
710
722
  let replacement = null;
711
723
 
712
724
  if (action === "replace-with-hub") {
713
- const registry = loadRegistry();
725
+ const registry = loadRegistryOrDefault();
714
726
  const [hubServerName, hubServerConfig] = getHubServerEntry(registry);
715
727
  const desired = buildDesiredServerRecord(hubServerName, hubServerConfig, resolvedPath);
716
728
  replacement = { name: desired.name, ...desired.config };
@@ -823,6 +835,8 @@ export function removeRegistryServer(name) {
823
835
  const trimmedName = String(name || "").trim();
824
836
  if (!trimmedName) throw new Error("server name is required");
825
837
 
838
+ const state = inspectRegistry();
839
+ if (!state.exists || !state.valid) return null;
826
840
  const registry = loadRegistry();
827
841
  const existing = registry.servers[trimmedName] || null;
828
842
  if (existing) {
@@ -881,7 +895,7 @@ export function removeServerFromTargets(name, options = {}) {
881
895
  }
882
896
 
883
897
  export function syncRegistryTargets(options = {}) {
884
- const registry = options.registry || loadRegistry();
898
+ const registry = options.registry || loadRegistryOrDefault();
885
899
  const actions = [];
886
900
 
887
901
  for (const target of listManagedConfigTargets(registry)) {