sanjang 0.3.7 → 0.4.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.
package/dashboard/app.js CHANGED
@@ -16,8 +16,12 @@ const logs = new Map();
16
16
  // Route inference — map file paths to preview routes
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
+ /** @type {Map<string, string|null>} Client-side cache for AI-inferred routes */
20
+ const aiRouteCache = new Map();
21
+ const AI_ROUTE_CACHE_MAX = 200;
22
+
19
23
  /**
20
- * Infer a preview route from a file path.
24
+ * Infer a preview route from a file path (sync, pattern-based).
21
25
  * Only works for file-based routing patterns (pages/, app/, views/).
22
26
  * Returns null if no route can be inferred.
23
27
  */
@@ -47,9 +51,45 @@ function inferRouteFromPath(filePath) {
47
51
  return route || '/';
48
52
  }
49
53
  }
54
+
55
+ // Return cached AI result if available
56
+ if (aiRouteCache.has(filePath)) return aiRouteCache.get(filePath);
57
+
50
58
  return null;
51
59
  }
52
60
 
61
+ /**
62
+ * Fetch AI-inferred route from the server for a file path.
63
+ * Results are cached to avoid duplicate calls.
64
+ * @param {string} filePath
65
+ * @param {string} [framework]
66
+ * @returns {Promise<string|null>}
67
+ */
68
+ async function inferRouteFromPathAI(filePath, framework) {
69
+ if (aiRouteCache.has(filePath)) return aiRouteCache.get(filePath);
70
+
71
+ // Mark as in-flight to prevent duplicate requests
72
+ if (aiRouteCache.size >= AI_ROUTE_CACHE_MAX) {
73
+ const oldest = aiRouteCache.keys().next().value;
74
+ aiRouteCache.delete(oldest);
75
+ }
76
+ aiRouteCache.set(filePath, null);
77
+
78
+ try {
79
+ const data = await api('POST', '/api/infer-route', { filePath, framework });
80
+ const route = data.route || null;
81
+ if (aiRouteCache.size >= AI_ROUTE_CACHE_MAX) {
82
+ const oldest = aiRouteCache.keys().next().value;
83
+ aiRouteCache.delete(oldest);
84
+ }
85
+ aiRouteCache.set(filePath, route);
86
+ return route;
87
+ } catch {
88
+ aiRouteCache.delete(filePath);
89
+ return null;
90
+ }
91
+ }
92
+
53
93
  /**
54
94
  * Navigate the preview iframe to a given route.
55
95
  */
@@ -1629,24 +1669,59 @@ async function fetchAndRenderReport(campName, withAi = false) {
1629
1669
  const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1630
1670
  return route
1631
1671
  ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></li>`
1632
- : `<li>${escHtml(item)}</li>`;
1672
+ : `<li data-ai-route-path="${cat === 'ui' && file ? escHtml(file.path) : ''}">${escHtml(item)}</li>`;
1633
1673
  }).join('')}</ul>`
1634
1674
  : `<ul class="ws-report-cat-items">${files.map(f => {
1635
1675
  const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1636
1676
  const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1637
1677
  return route
1638
1678
  ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></li>`
1639
- : `<li>${label}</li>`;
1679
+ : `<li data-ai-route-path="${cat === 'ui' ? escHtml(f.path) : ''}">${label}</li>`;
1640
1680
  }).join('')}</ul>`
1641
1681
  }
1642
1682
  </div>`;
1643
1683
  }).join('');
1644
1684
 
1685
+ // Async AI fallback: resolve routes for UI files that pattern matching missed
1686
+ resolveAiRoutes(categoriesEl);
1687
+
1645
1688
  } catch {
1646
1689
  section.style.display = 'none';
1647
1690
  }
1648
1691
  }
1649
1692
 
1693
+ /**
1694
+ * After report renders, find UI file items that didn't get a route via pattern matching
1695
+ * and attempt AI-based route inference for them, updating the DOM when routes are found.
1696
+ * @param {HTMLElement} container
1697
+ */
1698
+ async function resolveAiRoutes(container) {
1699
+ const pending = [...container.querySelectorAll('li[data-ai-route-path]')];
1700
+ const tasks = pending
1701
+ .map(li => {
1702
+ const filePath = li.getAttribute('data-ai-route-path');
1703
+ if (!filePath) return null;
1704
+ return { li, filePath, promise: inferRouteFromPathAI(filePath) };
1705
+ })
1706
+ .filter(Boolean);
1707
+
1708
+ const results = await Promise.allSettled(tasks.map(t => t.promise));
1709
+
1710
+ for (let i = 0; i < tasks.length; i++) {
1711
+ const { li, filePath } = tasks[i];
1712
+ const result = results[i];
1713
+ const route = result.status === 'fulfilled' ? result.value : null;
1714
+ if (route) {
1715
+ const text = li.textContent;
1716
+ li.className = 'ws-report-nav-item';
1717
+ li.setAttribute('onclick', `navigatePreview('${escHtml(route)}')`);
1718
+ li.setAttribute('title', `${filePath} → ${route}`);
1719
+ li.innerHTML = `${escHtml(text)} <span class="ws-report-nav-hint">→ 보기</span>`;
1720
+ }
1721
+ li.removeAttribute('data-ai-route-path');
1722
+ }
1723
+ }
1724
+
1650
1725
  function transitionReportToSaved() {
1651
1726
  const section = document.getElementById('ws-report-section');
1652
1727
  if (!section || !lastReport) return;
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
+ import { claudeSync, extractJson, isDangerousCommand } from "./engine/ai.js";
12
13
  import { deepFindEnvFiles, detectSetupIssues } from "./engine/smart-init.js";
13
14
  const CONFIG_FILE = "sanjang.config.js";
14
15
  const DEFAULTS = {
@@ -190,6 +191,10 @@ export function detectProject(projectRoot) {
190
191
  copyFiles: findEnvFiles(projectRoot),
191
192
  };
192
193
  }
194
+ // AI fallback: ask Claude to analyze the project and suggest a dev command
195
+ const aiDetected = detectProjectWithAI(projectRoot, pkg);
196
+ if (aiDetected)
197
+ return aiDetected;
193
198
  return {
194
199
  framework: "unknown",
195
200
  dev: { command: "npm run dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
@@ -197,6 +202,63 @@ export function detectProject(projectRoot) {
197
202
  copyFiles: [],
198
203
  };
199
204
  }
205
+ function detectProjectWithAI(projectRoot, pkg) {
206
+ const contextParts = [];
207
+ if (!existsSync(projectRoot))
208
+ return null;
209
+ const lsResult = readdirSync(projectRoot, { withFileTypes: true });
210
+ const listing = lsResult
211
+ .map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
212
+ .join("\n");
213
+ contextParts.push(`## Files in project root:\n${listing}`);
214
+ if (pkg?.scripts) {
215
+ contextParts.push(`## package.json scripts:\n${JSON.stringify(pkg.scripts, null, 2)}`);
216
+ }
217
+ const makefilePath = join(projectRoot, "Makefile");
218
+ if (existsSync(makefilePath)) {
219
+ try {
220
+ const content = readFileSync(makefilePath, "utf8");
221
+ contextParts.push(`## Makefile (first 20 lines):\n${content.split("\n").slice(0, 20).join("\n")}`);
222
+ }
223
+ catch { /* ignore */ }
224
+ }
225
+ const prompt = `You are analyzing a software project to determine how to run its dev server.
226
+
227
+ ${contextParts.join("\n\n")}
228
+
229
+ Based on these files, determine the correct command to start the development server.
230
+ Respond with ONLY a JSON object (no markdown, no explanation):
231
+ {"command": "the dev command", "port": 3000, "portFlag": "--port", "setup": "install command or null"}
232
+
233
+ Rules:
234
+ - command: the shell command to start the dev server
235
+ - port: the default port number (number, not string)
236
+ - portFlag: the CLI flag to override port, or null if not applicable
237
+ - setup: the install/setup command, or null if none needed`;
238
+ const raw = claudeSync(prompt, { model: "haiku" });
239
+ if (!raw)
240
+ return null;
241
+ const suggestion = extractJson(raw);
242
+ if (!suggestion?.command || typeof suggestion.command !== "string")
243
+ return null;
244
+ if (isDangerousCommand(suggestion.command))
245
+ return null;
246
+ if (suggestion.setup && isDangerousCommand(suggestion.setup))
247
+ return null;
248
+ return {
249
+ framework: "AI-detected",
250
+ dev: {
251
+ command: suggestion.command,
252
+ port: typeof suggestion.port === "number" ? suggestion.port : 3000,
253
+ portFlag: suggestion.portFlag ?? null,
254
+ cwd: ".",
255
+ env: {},
256
+ },
257
+ setup: suggestion.setup ?? "npm install",
258
+ copyFiles: findEnvFiles(projectRoot),
259
+ _note: "Project type detected by AI. Verify the dev command is correct.",
260
+ };
261
+ }
200
262
  /**
201
263
  * Scan first-level subdirectories for app candidates.
202
264
  * Returns array of { dir, framework, detected } sorted by dir name.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared AI (Claude CLI) utilities.
3
+ *
4
+ * Centralises claude CLI availability checks, prompt execution,
5
+ * JSON extraction, and dangerous command detection so that engine
6
+ * modules do not duplicate these patterns.
7
+ */
8
+ export declare function isClaudeAvailable(): boolean;
9
+ export interface ClaudeSyncOptions {
10
+ model?: string;
11
+ timeout?: number;
12
+ cwd?: string;
13
+ outputFormat?: string;
14
+ }
15
+ /**
16
+ * Call `claude -p` synchronously and return trimmed stdout, or null on failure.
17
+ * Checks availability first (cached), strips markdown fences from output.
18
+ */
19
+ export declare function claudeSync(prompt: string, opts?: ClaudeSyncOptions): string | null;
20
+ /**
21
+ * Extract a JSON object or array from text that may contain markdown fences
22
+ * or surrounding prose. Returns parsed value or null.
23
+ */
24
+ export declare function extractJson<T = unknown>(text: string): T | null;
25
+ export declare function isDangerousCommand(cmd: string): boolean;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Shared AI (Claude CLI) utilities.
3
+ *
4
+ * Centralises claude CLI availability checks, prompt execution,
5
+ * JSON extraction, and dangerous command detection so that engine
6
+ * modules do not duplicate these patterns.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ // ---------------------------------------------------------------------------
10
+ // Claude CLI availability (cached)
11
+ // ---------------------------------------------------------------------------
12
+ let _claudeAvailable = null;
13
+ export function isClaudeAvailable() {
14
+ if (_claudeAvailable === null) {
15
+ _claudeAvailable = spawnSync("which", ["claude"], { stdio: "pipe" }).status === 0;
16
+ }
17
+ return _claudeAvailable;
18
+ }
19
+ /**
20
+ * Call `claude -p` synchronously and return trimmed stdout, or null on failure.
21
+ * Checks availability first (cached), strips markdown fences from output.
22
+ */
23
+ export function claudeSync(prompt, opts) {
24
+ if (!isClaudeAvailable())
25
+ return null;
26
+ const args = ["-p", prompt];
27
+ if (opts?.model)
28
+ args.push("--model", opts.model);
29
+ if (opts?.outputFormat)
30
+ args.push("--output-format", opts.outputFormat);
31
+ try {
32
+ const result = spawnSync("claude", args, {
33
+ encoding: "utf8",
34
+ stdio: ["pipe", "pipe", "pipe"],
35
+ timeout: opts?.timeout ?? 15_000,
36
+ cwd: opts?.cwd,
37
+ });
38
+ if (result.status !== 0 || !result.stdout)
39
+ return null;
40
+ return result.stdout.trim();
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // JSON extraction
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * Extract a JSON object or array from text that may contain markdown fences
51
+ * or surrounding prose. Returns parsed value or null.
52
+ */
53
+ export function extractJson(text) {
54
+ if (!text)
55
+ return null;
56
+ // Strip markdown code fences
57
+ const stripped = text.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
58
+ // Try direct parse first (common case: entire response is JSON)
59
+ try {
60
+ return JSON.parse(stripped);
61
+ }
62
+ catch { /* not pure JSON, try extraction */ }
63
+ // Find JSON boundaries by trying to parse from each opening bracket
64
+ for (let i = 0; i < stripped.length; i++) {
65
+ const ch = stripped[i];
66
+ if (ch !== "{" && ch !== "[")
67
+ continue;
68
+ // Try progressively shorter substrings from this position
69
+ for (let j = stripped.length; j > i; j--) {
70
+ const endCh = stripped[j - 1];
71
+ if ((ch === "{" && endCh !== "}") || (ch === "[" && endCh !== "]"))
72
+ continue;
73
+ try {
74
+ return JSON.parse(stripped.slice(i, j));
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Dangerous command detection
85
+ // ---------------------------------------------------------------------------
86
+ const DANGEROUS_COMMAND_PATTERNS = [
87
+ /\brm\s+(-\w*[rf]|--recursive|--force)/i,
88
+ /\bsudo\b/i,
89
+ /\bcurl\b.*\|\s*\b(sh|bash|zsh)\b/i,
90
+ /\bwget\b.*\|\s*\b(sh|bash|zsh)\b/i,
91
+ /\bmkfs\b/i,
92
+ /\bdd\s+(if=|of=)/i,
93
+ /\b(shutdown|reboot|halt|poweroff)\b/i,
94
+ /\bchmod\s+777\b/i,
95
+ /\b>\s*\/dev\//i,
96
+ /:\(\)\{.*\|.*&\s*\}|fork\s+bomb/i,
97
+ ];
98
+ export function isDangerousCommand(cmd) {
99
+ return DANGEROUS_COMMAND_PATTERNS.some((p) => p.test(cmd));
100
+ }
@@ -5,6 +5,12 @@ type FileCategory = "ui" | "api" | "config" | "test" | "docs" | "other";
5
5
  * Rules are checked in priority order: test > ui > api > docs > config > other
6
6
  */
7
7
  export declare function categorizeFile(filePath: string): FileCategory;
8
+ /**
9
+ * Re-classify "other" files using AI (claude -p --model haiku).
10
+ * Only file paths are sent — file contents are never read (cost/speed optimisation).
11
+ * On failure the original categories are preserved.
12
+ */
13
+ export declare function recategorizeWithAI(files: ChangeReportFile[]): ChangeReportFile[];
8
14
  /**
9
15
  * Detect warnings from a list of categorized files.
10
16
  * Returns deduplicated warnings (one per type).
@@ -17,7 +23,9 @@ export declare function detectWarnings(files: ChangeReportFile[]): ChangeReportW
17
23
  export declare function buildChangeReport(rawFiles: {
18
24
  path: string;
19
25
  status: ChangeReportFile["status"];
20
- }[]): ChangeReport;
26
+ }[], options?: {
27
+ ai?: boolean;
28
+ }): ChangeReport;
21
29
  /**
22
30
  * Enrich a ChangeReport with AI-generated category-level descriptions.
23
31
  * Each category gets human-readable bullet points explaining what changed.
@@ -1,5 +1,5 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { basename, extname } from "node:path";
2
+ import { claudeSync, extractJson } from "./ai.js";
3
3
  /**
4
4
  * Classify a file path into one of the known categories.
5
5
  * Rules are checked in priority order: test > ui > api > docs > config > other
@@ -74,6 +74,41 @@ export function categorizeFile(filePath) {
74
74
  // 6. other
75
75
  return "other";
76
76
  }
77
+ const VALID_AI_CATEGORIES = new Set(["ui", "api", "config", "test", "docs"]);
78
+ /**
79
+ * Re-classify "other" files using AI (claude -p --model haiku).
80
+ * Only file paths are sent — file contents are never read (cost/speed optimisation).
81
+ * On failure the original categories are preserved.
82
+ */
83
+ export function recategorizeWithAI(files) {
84
+ const otherFiles = files.filter((f) => f.category === "other");
85
+ if (otherFiles.length === 0)
86
+ return files;
87
+ const pathList = otherFiles.map((f) => f.path).join("\n");
88
+ const prompt = `아래 파일 경로들을 다음 카테고리 중 하나로 분류해: ui, api, config, test, docs
89
+ 경로만 보고 판단해.
90
+
91
+ 파일 목록:
92
+ ${pathList}
93
+
94
+ JSON으로만 응답해 (다른 텍스트 없이):
95
+ { "파일경로": "카테고리", ... }`;
96
+ const raw = claudeSync(prompt, { model: "haiku" });
97
+ if (!raw)
98
+ return files;
99
+ const mapping = extractJson(raw);
100
+ if (!mapping)
101
+ return files;
102
+ return files.map((f) => {
103
+ if (f.category !== "other")
104
+ return f;
105
+ const aiCat = mapping[f.path];
106
+ if (typeof aiCat === "string" && VALID_AI_CATEGORIES.has(aiCat)) {
107
+ return { ...f, category: aiCat };
108
+ }
109
+ return f;
110
+ });
111
+ }
77
112
  /**
78
113
  * Detect warnings from a list of categorized files.
79
114
  * Returns deduplicated warnings (one per type).
@@ -117,12 +152,15 @@ export function detectWarnings(files) {
117
152
  * Build a ChangeReport from raw file list (path + status).
118
153
  * summary and humanDescription are set to null — call generateReportSummary to enrich.
119
154
  */
120
- export function buildChangeReport(rawFiles) {
121
- const files = rawFiles.map((f) => ({
155
+ export function buildChangeReport(rawFiles, options) {
156
+ let files = rawFiles.map((f) => ({
122
157
  path: f.path,
123
158
  status: f.status,
124
159
  category: categorizeFile(f.path),
125
160
  }));
161
+ if (options?.ai) {
162
+ files = recategorizeWithAI(files);
163
+ }
126
164
  const byCategory = {};
127
165
  for (const f of files) {
128
166
  const cat = f.category;
@@ -192,30 +230,18 @@ JSON으로만 응답해 (다른 텍스트 없이):
192
230
  {"summary": "전체 한 줄 요약 (30자 이내)", "categories": {"ui": ["설명1", "설명2"], "api": ["설명1"], ...}}
193
231
 
194
232
  categories의 키는 반드시 다음 중 하나: ${Object.keys(report.byCategory).join(", ")}`;
195
- try {
196
- const result = spawnSync("claude", ["-p", prompt], {
197
- encoding: "utf8",
198
- timeout: 30_000,
199
- });
200
- if (result.status === 0 && result.stdout) {
201
- const output = result.stdout.trim();
202
- const jsonMatch = output.match(/\{[\s\S]*"summary"[\s\S]*"categories"[\s\S]*\}/);
203
- if (jsonMatch) {
204
- const parsed = JSON.parse(jsonMatch[0]);
205
- if (parsed.summary && parsed.categories) {
206
- return {
207
- ...report,
208
- summary: parsed.summary,
209
- humanDescription: null,
210
- categoryDetails: parsed.categories,
211
- };
212
- }
213
- }
233
+ const raw = claudeSync(prompt, { model: "haiku", timeout: 30_000 });
234
+ if (raw) {
235
+ const parsed = extractJson(raw);
236
+ if (parsed?.summary && parsed?.categories) {
237
+ return {
238
+ ...report,
239
+ summary: parsed.summary,
240
+ humanDescription: null,
241
+ categoryDetails: parsed.categories,
242
+ };
214
243
  }
215
244
  }
216
- catch {
217
- // Fall through to fallback
218
- }
219
245
  // Fallback: 파일 기반 설명 생성
220
246
  const fallbackDetails = {};
221
247
  for (const [cat, files] of Object.entries(report.byCategory)) {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { claudeSync, extractJson, isDangerousCommand } from "./ai.js";
3
4
  import { deepFindEnvFiles } from "./smart-init.js";
4
5
  const PATTERNS = [
5
6
  {
@@ -42,10 +43,59 @@ const PATTERNS = [
42
43
  },
43
44
  ];
44
45
  // ---------------------------------------------------------------------------
46
+ // Constants
47
+ // ---------------------------------------------------------------------------
48
+ const CONFIG_FILE = "sanjang.config.js";
49
+ /** Maximum characters of config content to include in AI prompts. */
50
+ const MAX_CONFIG_CONTENT_FOR_PROMPT = 3000;
51
+ // ---------------------------------------------------------------------------
52
+ // AI helpers
53
+ // ---------------------------------------------------------------------------
54
+ function aiFallback(projectRoot, logs) {
55
+ const configPath = join(projectRoot, CONFIG_FILE);
56
+ const configContent = existsSync(configPath)
57
+ ? readFileSync(configPath, "utf8").slice(0, MAX_CONFIG_CONTENT_FOR_PROMPT)
58
+ : "(config file not found)";
59
+ const lastLines = logs.slice(-20).join("\n");
60
+ const prompt = [
61
+ "You are a build-config assistant for the sanjang dev tool.",
62
+ "Given the error log and the current sanjang.config.js, suggest a fix.",
63
+ "",
64
+ "## Error log (last 20 lines)",
65
+ lastLines,
66
+ "",
67
+ "## Current sanjang.config.js",
68
+ configContent,
69
+ "",
70
+ "Respond with ONLY a JSON object (no markdown fences, no explanation):",
71
+ '{ "type": "add-copyfiles" | "update-setup" | "info", "description": "<한국어 설명>", "patch": { ... } }',
72
+ "",
73
+ 'For "update-setup", include patch.setup with the corrected shell command.',
74
+ 'For "add-copyfiles", include patch.copyFiles with file paths array.',
75
+ 'For "info", include a descriptive patch explaining the issue.',
76
+ ].join("\n");
77
+ const raw = claudeSync(prompt, { model: "haiku" });
78
+ if (!raw)
79
+ return null;
80
+ const parsed = extractJson(raw);
81
+ if (!parsed)
82
+ return null;
83
+ if (!parsed.type || !parsed.description || !parsed.patch)
84
+ return null;
85
+ if (!["add-copyfiles", "update-setup", "info"].includes(parsed.type))
86
+ return null;
87
+ if (parsed.type === "update-setup" && typeof parsed.patch.setup === "string") {
88
+ if (isDangerousCommand(parsed.patch.setup))
89
+ return null;
90
+ }
91
+ return parsed;
92
+ }
93
+ // ---------------------------------------------------------------------------
45
94
  // suggestConfigFix — analyze logs and return a fix, or null
46
95
  // ---------------------------------------------------------------------------
47
96
  export function suggestConfigFix(projectRoot, logs) {
48
97
  const combined = logs.join("\n");
98
+ // Fast path: pattern matching
49
99
  for (const pattern of PATTERNS) {
50
100
  const match = combined.match(pattern.test);
51
101
  if (match) {
@@ -54,12 +104,16 @@ export function suggestConfigFix(projectRoot, logs) {
54
104
  return fix;
55
105
  }
56
106
  }
107
+ // Slow path: AI fallback — only if logs contain error signals
108
+ const hasErrorSignal = /\b(error|ERR!|FATAL|ENOSPC|EPERM|EACCES|TypeError|ReferenceError|SyntaxError|failed to|build failed)\b/i.test(combined);
109
+ if (hasErrorSignal) {
110
+ return aiFallback(projectRoot, logs);
111
+ }
57
112
  return null;
58
113
  }
59
114
  // ---------------------------------------------------------------------------
60
115
  // applyConfigFix — modify sanjang.config.js in place
61
116
  // ---------------------------------------------------------------------------
62
- const CONFIG_FILE = "sanjang.config.js";
63
117
  export function applyConfigFix(projectRoot, fix) {
64
118
  const configPath = join(projectRoot, CONFIG_FILE);
65
119
  if (!existsSync(configPath))
@@ -80,7 +134,7 @@ export function applyConfigFix(projectRoot, fix) {
80
134
  break;
81
135
  }
82
136
  case "update-setup": {
83
- // Informational — we don't auto-change setup without explicit user input
137
+ // Informational only — don't auto-change setup without explicit user confirmation
84
138
  return false;
85
139
  }
86
140
  case "info": {
@@ -19,7 +19,9 @@ export interface ConflictSection {
19
19
  * Returns an array of conflict sections found in the file.
20
20
  */
21
21
  export declare function parseConflictSections(content: string): ConflictSection[];
22
- /**
23
- * Build a Claude prompt to resolve merge conflicts.
24
- */
25
- export declare function buildConflictPrompt(conflictFiles: string[]): string;
22
+ /** Detail about a single file's conflict sections, used to embed content in the prompt. */
23
+ export interface ConflictDetail {
24
+ path: string;
25
+ sections: ConflictSection[];
26
+ }
27
+ export declare function buildConflictPrompt(conflictFiles: string[], conflictDetails?: ConflictDetail[]): string;
@@ -55,19 +55,59 @@ export function parseConflictSections(content) {
55
55
  }
56
56
  return sections;
57
57
  }
58
+ /** Truncate text to a maximum number of lines. */
59
+ function truncateLines(text, maxLines) {
60
+ const lines = text.split("\n");
61
+ if (lines.length <= maxLines)
62
+ return text;
63
+ return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines}줄 생략)`;
64
+ }
58
65
  /**
59
66
  * Build a Claude prompt to resolve merge conflicts.
67
+ * When conflictDetails is provided, ours/theirs content is embedded directly
68
+ * so Claude can resolve without re-reading the files.
60
69
  */
61
- export function buildConflictPrompt(conflictFiles) {
62
- return [
70
+ /** Maximum lines to include per conflict side (ours/theirs) in the prompt. */
71
+ const MAX_LINES_PER_SIDE = 50;
72
+ export function buildConflictPrompt(conflictFiles, conflictDetails) {
73
+ const lines = [
63
74
  "아래 파일들에 git merge 충돌이 발생했습니다.",
64
- "각 파일의 충돌 마커(<<<<<<< ======= >>>>>>>)를 읽고,",
65
75
  "두 버전의 의도를 모두 살려서 충돌을 해결해주세요.",
66
76
  "해결 후 충돌 마커는 완전히 제거해야 합니다.",
67
77
  "",
68
- "충돌 파일 목록:",
69
- ...conflictFiles.map((f) => `- ${f}`),
70
- "",
71
- "각 파일을 읽고 수정해주세요.",
72
- ].join("\n");
78
+ ];
79
+ if (conflictDetails?.length) {
80
+ for (const detail of conflictDetails) {
81
+ lines.push(`## ${detail.path}`);
82
+ if (detail.sections.length === 0) {
83
+ lines.push("(충돌 섹션을 파싱하지 못했습니다. 파일을 직접 읽어주세요.)");
84
+ }
85
+ else {
86
+ for (let i = 0; i < detail.sections.length; i++) {
87
+ const s = detail.sections[i];
88
+ lines.push(`### 충돌 #${i + 1} (line ${s.startLine})`);
89
+ lines.push("**Ours (현재 브랜치):**");
90
+ lines.push("```");
91
+ lines.push(truncateLines(s.ours, MAX_LINES_PER_SIDE));
92
+ lines.push("```");
93
+ lines.push("**Theirs (병합 대상):**");
94
+ lines.push("```");
95
+ lines.push(truncateLines(s.theirs, MAX_LINES_PER_SIDE));
96
+ lines.push("```");
97
+ lines.push("");
98
+ }
99
+ }
100
+ lines.push("");
101
+ }
102
+ lines.push("위 내용을 바탕으로 각 파일의 충돌을 해결해주세요.");
103
+ }
104
+ else {
105
+ lines.push("충돌 파일 목록:");
106
+ for (const f of conflictFiles) {
107
+ lines.push(`- ${f}`);
108
+ }
109
+ lines.push("");
110
+ lines.push("각 파일을 읽고 수정해주세요.");
111
+ }
112
+ return lines.join("\n");
73
113
  }