@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.
- package/hooks/hook-adaptive-collector.mjs +86 -0
- package/hooks/hook-manager.mjs +15 -2
- package/hooks/hook-registry.json +37 -4
- package/hooks/keyword-rules.json +2 -1
- package/hooks/mcp-config-watcher.mjs +2 -7
- package/hooks/safety-guard.mjs +37 -0
- package/hub/account-broker.mjs +251 -0
- package/hub/adaptive-diagnostic.mjs +323 -0
- package/hub/adaptive-inject.mjs +186 -0
- package/hub/adaptive-memory.mjs +163 -0
- package/hub/adaptive.mjs +143 -0
- package/hub/cli-adapter-base.mjs +89 -1
- package/hub/codex-adapter.mjs +12 -3
- package/hub/codex-compat.mjs +11 -78
- package/hub/codex-preflight.mjs +20 -1
- package/hub/gemini-adapter.mjs +1 -0
- package/hub/index.mjs +34 -0
- package/hub/lib/cache-guard.mjs +114 -0
- package/hub/lib/known-errors.json +72 -0
- package/hub/lib/memory-store.mjs +748 -0
- package/hub/lib/ssh-command.mjs +150 -0
- package/hub/lib/uuidv7.mjs +44 -0
- package/hub/memory-doctor.mjs +480 -0
- package/hub/middleware/request-logger.mjs +80 -0
- package/hub/router.mjs +1 -1
- package/hub/team-bridge.mjs +21 -19
- package/hud/constants.mjs +7 -0
- package/hud/context-monitor.mjs +403 -0
- package/hud/hud-qos-status.mjs +8 -4
- package/hud/providers/claude.mjs +5 -0
- package/hud/renderers.mjs +32 -14
- package/hud/utils.mjs +26 -0
- package/package.json +3 -2
- package/scripts/lib/claudemd-scanner.mjs +218 -0
- package/scripts/lib/handoff.mjs +171 -0
- package/scripts/lib/mcp-guard-engine.mjs +20 -6
- package/scripts/lib/skill-template.mjs +269 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
148
|
-
const
|
|
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
|
-
|
|
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("
|
|
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,
|
|
194
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ||
|
|
898
|
+
const registry = options.registry || loadRegistryOrDefault();
|
|
885
899
|
const actions = [];
|
|
886
900
|
|
|
887
901
|
for (const target of listManagedConfigTargets(registry)) {
|